main
Caleb Add the Rust source, flow definition, assets and install scripts cf9809b
//! The flow window's content, and the record of the process. Every time a stage completes or is
//! updated (a check passes, a snapshot is saved, a review pass is recorded, the report is built, or
//! the AI runs `view` after editing the project), the stage's view is generated and appended to
//! views.json: the interactive PCB (adom-2dboard embed) and/or the interactive schematic
//! (adom-schematic embed) of the KiCad project as it stood, or the photos before a project exists.
//! Views are numbered and never overwritten, so views.json plus views/ replay the whole process. An
//! unchanged board or schematic reuses the previous file instead of embedding it again.
//! A view that fails to build is reported as a hint; it never fails the design check.
use crate::{ledger, sha256};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::process::Command;

/// What each stage shows. Stages before a KiCad project exists fall back to the photos.
pub fn kinds_for(step: &str) -> &'static [&'static str] {
    match step {
        "intake" | "scale" => &["photos"],
        "parts" | "copper" | "snapshot" => &["pcb"],
        "blocks" => &["sch"],
        _ => &["pcb", "sch"],
    }
}

pub fn disabled() -> bool { std::env::var("ADOM_AIFLOW_REVERSE_VIEWS").map(|v| v == "off").unwrap_or(false) }

fn sch_hash(sch: &Path, sheet_dir: &Path) -> String {
    // the root sheet and every sibling sheet: a child-sheet edit changes the schematic view too
    let mut files: Vec<PathBuf> = std::fs::read_dir(sheet_dir).map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "kicad_sch").unwrap_or(false)).collect()).unwrap_or_default();
    if !files.contains(&sch.to_path_buf()) { files.push(sch.to_path_buf()); }
    files.sort();
    files.iter().map(|f| sha256(f)).collect::<Vec<_>>().join("")
}

fn previous(doc: &Value, kind: &str, hash: &str) -> Option<String> {
    doc["entries"].as_array()?.iter().rev().flat_map(|e| e["views"].as_array().cloned().unwrap_or_default())
        .find(|v| v["kind"] == json!(kind) && v["sha"] == json!(hash)).and_then(|v| v["file"].as_str().map(str::to_string))
}

fn embed(tool: &str, args: &[&str]) -> Result<(), String> {
    let o = Command::new(tool).args(args).output().map_err(|e| format!("{tool} is not runnable ({e}): adom-wiki pkg install adom/{tool}"))?;
    let out = String::from_utf8_lossy(&o.stdout).to_string() + &String::from_utf8_lossy(&o.stderr);
    if o.status.success() && out.contains("OK") { Ok(()) } else { Err(format!("{tool} embed failed: {}", out.trim().chars().take(300).collect::<String>())) }
}

/// Record the views for `step`. `pcb_override` shows a specific board (the snapshot's copy).
/// Returns the hints to print (what was recorded, or why a view could not be built).
pub fn record(flow: &Path, state: &Value, step: &str, reason: &str, thread: &str, pcb_override: Option<&Path>) -> Vec<String> {
    if disabled() { return vec![]; }
    let path = flow.join("views.json");
    let mut doc = crate::items::read_json(&path).unwrap_or(json!({"entries": []}));
    let n = doc["entries"].as_array().map(|a| a.len()).unwrap_or(0) + 1;
    std::fs::create_dir_all(flow.join("views")).ok();
    let kicad = &state["kicad"];
    let pcb = pcb_override.map(Path::to_path_buf).or_else(|| kicad["pcb"].as_str().map(PathBuf::from)).filter(|p| p.is_file());
    let sch = kicad["sch"].as_str().map(PathBuf::from).filter(|p| p.is_file());
    let mut views = vec![];
    let mut hints = vec![];
    for kind in kinds_for(step) {
        let (k, src) = match *kind {
            "pcb" if pcb.is_some() => ("pcb", pcb.clone()),
            "sch" if sch.is_some() => ("sch", sch.clone()),
            "pcb" | "sch" if pcb.is_some() => ("pcb", pcb.clone()),
            _ => ("photos", None),
        };
        if views.iter().any(|v: &Value| v["kind"] == json!(k)) { continue; }
        let Some(src) = src else { views.push(json!({"kind": "photos"})); continue };
        let hash = if k == "sch" { sch_hash(&src, src.parent().unwrap_or(Path::new("."))) } else { sha256(&src) };
        let file = match previous(&doc, k, &hash) {
            Some(f) if flow.join(&f).is_file() => f,
            _ => {
                let f = format!("views/{n:03}-{step}-{k}.html");
                let out = flow.join(&f);
                let (o, s) = (out.display().to_string(), src.display().to_string());
                let r = if k == "pcb" { embed("adom-2dboard", &["embed", "--file", &s, "--out", &o]) } else {
                    let dir = src.parent().unwrap_or(Path::new(".")).display().to_string();
                    embed("adom-schematic", &["embed", "--file", &s, "--sheet-dir", &dir, "--out", &o])
                };
                if let Err(e) = r { hints.push(format!("flow window: the {k} view was not built: {e}")); continue; }
                f
            }
        };
        views.push(json!({"kind": k, "file": file, "source": src.display().to_string(), "sha": hash}));
    }
    let entry = json!({"n": n, "at": ledger::now(), "step": step, "reason": reason, "aiThread": thread, "views": views});
    doc["entries"].as_array_mut().unwrap().push(entry);
    if let Err(e) = std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap() + "\n") { hints.push(format!("flow window: views.json not written: {e}")); return hints; }
    let shown: Vec<String> = views.iter().filter_map(|v| v["kind"].as_str().map(str::to_string)).collect();
    hints.insert(0, format!("flow window: view {n} recorded for {step} ({})", if shown.is_empty() { "nothing".into() } else { shown.join(" + ") }));
    if state["kicad"]["pcb"].is_null() && step != "intake" && step != "scale" {
        hints.push("No KiCad project registered yet, so the flow window shows the photos. Register it with `kicad --pcb <board> --sch <root sheet>` as soon as the model writes one.".into());
    }
    hints
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn stage_views() {
        assert_eq!(kinds_for("intake"), &["photos"]);
        assert_eq!(kinds_for("blocks"), &["sch"]);
        assert_eq!(kinds_for("connect"), &["pcb", "sch"]);
    }

    #[test]
    fn photos_without_a_project_and_history_numbering() {
        let d = std::env::temp_dir().join(format!("views-test-{}", std::process::id()));
        std::fs::create_dir_all(&d).unwrap();
        let st = json!({});
        let h = record(&d, &st, "parts", "check parts", "t", None);
        assert!(h[0].contains("view 1 recorded for parts (photos)"), "{h:?}");
        let h = record(&d, &st, "blocks", "check blocks", "t", None);
        assert!(h[0].contains("view 2"), "{h:?}");
        let doc = crate::items::read_json(&d.join("views.json")).unwrap();
        assert_eq!(doc["entries"].as_array().unwrap().len(), 2);
        std::fs::remove_dir_all(&d).ok();
    }
}