main
Caleb Version 0.3.0: new copper reader, dome via filter, every layer read from the photos, net evidence, measured confidence;… e874d7d
//! The flow file (flows/flow.json, compiled in): the ordered steps, the artifacts that prove each one
//! is done, and what the AI must do in it. `next` names the first step whose artifacts are missing.
//! The binary does the deterministic parts; the AI does the thinking the `do` text asks for.
use serde::Deserialize;
use std::path::Path;

#[derive(Deserialize, Clone)]
pub struct Step { pub name: String, pub done: Vec<String>, #[serde(rename = "do")] pub todo: String }

#[derive(Deserialize)]
#[allow(dead_code)]
pub struct Flow { pub name: String, pub description: String, pub steps: Vec<Step> }

pub fn load() -> Flow { serde_json::from_str(include_str!("../flows/flow.json")).expect("flows/flow.json is valid") }

/// A snapshot copies the project as built from these: when one changes, the saved copy no longer shows it.
pub const SNAPSHOT_INPUTS: [&str; 2] = ["copper.json", "parts.json"];

fn is_snapshot(pat: &str) -> bool { pat.starts_with("snapshots/") && pat.ends_with("manifest.json") }

/// A recorded hash: of the file, or with `geometry:` of its copper shapes only (`attrib::geometry_hash`), so attribution,
/// which writes nets and nothing else, does not make what was measured from the shapes stale.
pub fn matches_hash(path: &Path, want: &str) -> bool {
    if want.starts_with("geometry:") { return crate::attrib::geometry_hash(path) == want; }
    crate::sha256(path) == want
}

/// The files a passed check or a saved snapshot read that changed since (empty when it is fresh).
pub fn stale(flow_dir: &Path, pat: &str) -> Vec<String> {
    let Ok(t) = std::fs::read_to_string(flow_dir.join(pat)) else { return vec![] };
    let v: serde_json::Value = serde_json::from_str(&t).unwrap_or_default();
    if is_snapshot(pat) {
        // a snapshot from before inputs were recorded cannot show it is current, so it is not
        let Some(inputs) = v["inputs"].as_object() else { return SNAPSHOT_INPUTS.iter().map(|f| format!("{f} (the snapshot does not record it)")).collect() };
        return inputs.iter().filter(|(f, h)| h.as_str() != Some(crate::sha256(&flow_dir.join(f.as_str())).as_str())).map(|(f, _)| f.clone()).collect();
    }
    v["files"].as_object().into_iter().flatten()
        .filter(|(f, h)| h.as_str().map(|w| !matches_hash(&flow_dir.join(f.as_str()), w)).unwrap_or(true))
        .map(|(f, _)| f.clone()).collect()
}

/// One artifact pattern, relative to the flow folder; `*` matches within the last path segment.
/// A `checks/<name>.ok` or a snapshot counts only while every file it read is unchanged.
pub fn exists(flow_dir: &Path, pat: &str) -> bool {
    if (pat.starts_with("checks/") && pat.ends_with(".ok")) || is_snapshot(pat) {
        return flow_dir.join(pat).is_file() && stale(flow_dir, pat).is_empty();
    }
    let full = flow_dir.join(pat);
    let name = full.file_name().and_then(|n| n.to_str()).unwrap_or("");
    if !name.contains('*') { return full.exists(); }
    let (pre, post) = name.split_once('*').unwrap_or((name, ""));
    let dir = full.parent().unwrap_or(flow_dir);
    std::fs::read_dir(dir).map(|rd| rd.flatten().any(|e| {
        let n = e.file_name().to_string_lossy().to_string();
        n.starts_with(pre) && n.ends_with(post) && n.len() >= pre.len() + post.len()
    })).unwrap_or(false)
}

pub fn current<'a>(flow: &'a Flow, flow_dir: &Path) -> Option<&'a Step> {
    flow.steps.iter().find(|s| !s.done.iter().all(|p| exists(flow_dir, p)))
}

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

    #[test]
    fn current_step_is_the_first_with_missing_artifacts() {
        let d = std::env::temp_dir().join(format!("flow-test-{}", std::process::id()));
        std::fs::create_dir_all(d.join("checks")).unwrap();
        std::fs::write(d.join("state.json"), "{}").unwrap();
        let f = load();
        assert_eq!(current(&f, &d).map(|s| s.name.as_str()), Some("intake"));
        std::fs::write(d.join("state.json"), "{}").unwrap();
        std::fs::write(d.join("prep.json"), "{}").unwrap();
        let h = crate::sha256(&d.join("prep.json"));
        std::fs::write(d.join("checks/prep.ok"), format!("{{\"files\": {{\"prep.json\": \"{h}\"}}}}")).unwrap();
        assert_eq!(current(&f, &d).map(|s| s.name.as_str()), Some("scale"));
        // editing the artifact after its check makes the check stale and the step current again
        std::fs::write(d.join("prep.json"), "{\"photos\": []}").unwrap();
        assert_eq!(stale(&d, "checks/prep.ok"), vec!["prep.json".to_string()]);
        assert_eq!(current(&f, &d).map(|s| s.name.as_str()), Some("intake"));
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn attribution_does_not_make_the_copper_check_stale_but_a_new_shape_does() {
        let d = std::env::temp_dir().join(format!("flow-geom-{}", std::process::id()));
        std::fs::create_dir_all(d.join("checks")).unwrap();
        let c = |net: &str, x: f64| serde_json::json!({"zones": [{"id": "z", "net": net, "fieldTags": {"net": "MEASURED"}, "netEvidence": {"rung": "copper"}, "polygon_mm": [[x, 0], [1, 0], [1, 1]]}]}).to_string();
        std::fs::write(d.join("copper.json"), c("UNK_1", 0.0)).unwrap();
        let g = crate::attrib::geometry_hash(&d.join("copper.json"));
        std::fs::write(d.join("checks/copper.ok"), format!("{{\"files\": {{\"copper.json\": \"{g}\"}}}}")).unwrap();
        std::fs::write(d.join("copper.json"), c("GND", 0.0)).unwrap();
        assert!(stale(&d, "checks/copper.ok").is_empty());
        std::fs::write(d.join("copper.json"), c("GND", 0.5)).unwrap();
        assert_eq!(stale(&d, "checks/copper.ok"), vec!["copper.json".to_string()]);
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_snapshot_goes_stale_when_the_copper_changes() {
        let d = std::env::temp_dir().join(format!("flow-snap-{}", std::process::id()));
        std::fs::create_dir_all(d.join("snapshots/mimic")).unwrap();
        std::fs::write(d.join("copper.json"), "{}").unwrap();
        std::fs::write(d.join("parts.json"), "{}").unwrap();
        let m = "snapshots/mimic/manifest.json";
        // one from before the input record is stale, the state of the published run
        std::fs::write(d.join(m), "{\"name\": \"mimic\", \"files\": {\"a.kicad_pcb\": \"x\"}}").unwrap();
        assert!(!exists(&d, m));
        assert!(stale(&d, m)[0].contains("copper.json"));
        let (c, p) = (crate::sha256(&d.join("copper.json")), crate::sha256(&d.join("parts.json")));
        std::fs::write(d.join(m), format!("{{\"inputs\": {{\"copper.json\": \"{c}\", \"parts.json\": \"{p}\"}}}}")).unwrap();
        assert!(exists(&d, m));
        assert!(stale(&d, m).is_empty());
        std::fs::write(d.join("copper.json"), "{\"zones\": []}").unwrap();
        assert!(!exists(&d, m));
        assert_eq!(stale(&d, m), vec!["copper.json".to_string()]);
        std::fs::remove_dir_all(&d).ok();
    }
}