main
Caleb Publish 0.2.0 cbe04a0 11h ago
//! The routing stage as a guided flow. `flows/flow.json` (compiled in) holds the steps in order, the
//! file that shows each one was done, and what the AI does in it. `next` names the first step whose
//! file is missing. It is a guide and never a gate: no command checks that an earlier step was done,
//! and a step may be skipped or done another way.
//!
//! A project keeps its state in `<project>/routing-flow/`: `state.json` (the board and spec it was
//! started on), `run.jsonl` (one line per command: time, the AI thread, what happened) and the files
//! the steps leave. Earlier ledger lines are never rewritten.
use serde_json::{json, Value};
use std::io::Write;
use std::path::{Path, PathBuf};

pub const FLOW_DIR: &str = "routing-flow";

pub struct Step {
    pub name: String,
    pub done: Vec<String>,
    pub todo: String,
}

pub fn steps() -> Vec<Step> {
    let v: Value = serde_json::from_str(include_str!("../flows/flow.json")).expect("flows/flow.json is valid JSON");
    v["steps"]
        .as_array()
        .map(|a| {
            a.iter()
                .map(|s| Step {
                    name: s["name"].as_str().unwrap_or("").to_string(),
                    done: s["done"].as_array().map(|d| d.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(),
                    todo: s["do"].as_str().unwrap_or("").to_string(),
                })
                .collect()
        })
        .unwrap_or_default()
}

pub struct Project {
    pub dir: PathBuf,
    pub flow_dir: PathBuf,
    pub state: Value,
}

impl Project {
    /// The nearest folder at or above `start` that holds `routing-flow/state.json`.
    pub fn find(start: &Path) -> Option<Project> {
        let mut d = std::fs::canonicalize(start).ok().or_else(|| Some(start.to_path_buf()));
        while let Some(p) = d {
            let s = p.join(FLOW_DIR).join("state.json");
            if s.is_file() {
                let state = std::fs::read_to_string(&s).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));
                return Some(Project { flow_dir: p.join(FLOW_DIR), dir: p, state });
            }
            d = p.parent().map(Path::to_path_buf);
        }
        None
    }

    pub fn create(dir: &Path, state: Value) -> std::io::Result<Project> {
        let flow_dir = dir.join(FLOW_DIR);
        std::fs::create_dir_all(&flow_dir)?;
        let p = Project { dir: dir.to_path_buf(), flow_dir, state };
        std::fs::write(p.flow_dir.join("state.json"), serde_json::to_string_pretty(&p.state)? + "\n")?;
        Ok(p)
    }

    pub fn path(&self, rel: &str) -> PathBuf {
        self.flow_dir.join(rel)
    }

    /// One line in the ledger. A ledger that cannot be written never stops a command.
    pub fn log(&self, thread: &str, ev: &str, data: Value) {
        let line = json!({"t": now(), "aiThread": thread, "ev": ev, "data": data});
        if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(self.flow_dir.join("run.jsonl")) {
            let _ = writeln!(f, "{line}");
        }
    }

    pub fn is_done(&self, step: &Step) -> bool {
        step.done.iter().all(|p| self.flow_dir.join(p).exists())
    }

    /// The first step whose file is missing, with its place in the list (1-based).
    pub fn current<'a>(&self, steps: &'a [Step]) -> Option<(usize, &'a Step)> {
        steps.iter().enumerate().find(|(_, s)| !self.is_done(s)).map(|(i, s)| (i + 1, s))
    }
}

/// UTC now as 2026-11-10T12:00:00Z, from the system clock (no outside program).
pub fn now() -> String {
    let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;
    let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
    // Days since 1970-01-01 to a civil date (Howard Hinnant's algorithm).
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z.rem_euclid(146_097);
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };
    format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3_600, rem % 3_600 / 60, rem % 60)
}

/// The file's sha256 where `sha256sum` is installed; empty otherwise (it is a record, not a lock).
pub fn sha256(p: &Path) -> String {
    std::process::Command::new("sha256sum")
        .arg(p)
        .output()
        .ok()
        .and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().next().map(str::to_string))
        .unwrap_or_default()
}

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

    #[test]
    fn the_flow_file_reads_and_next_is_the_first_step_with_no_file() {
        let s = steps();
        assert!(s.len() >= 4 && s[0].name == "intake" && s.iter().all(|x| !x.done.is_empty() && !x.todo.is_empty()));
        let d = std::env::temp_dir().join(format!("adom-route-flow-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        let p = Project::create(&d, json!({"board": "b.kicad_pcb"})).unwrap();
        assert_eq!(p.current(&s).map(|(i, x)| (i, x.name.as_str())), Some((2, "plan")));
        std::fs::write(p.path("plan.txt"), "x").unwrap();
        assert_eq!(p.current(&s).map(|(_, x)| x.name.as_str()), Some("route"));
        // A later step done first is fine: next still names the first one missing, and nothing refuses.
        std::fs::write(p.path("check.json"), "{}").unwrap();
        assert_eq!(p.current(&s).map(|(_, x)| x.name.as_str()), Some("route"));
        p.log("t", "test", json!({}));
        assert!(std::fs::read_to_string(p.path("run.jsonl")).unwrap().contains("\"ev\":\"test\""));
        assert!(Project::find(&d.join(FLOW_DIR)).is_some());
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn now_is_a_utc_stamp() {
        let n = now();
        assert!(n.len() == 20 && n.ends_with('Z') && n.as_bytes()[10] == b'T', "{n}");
    }
}