main
John Lauer Publish 0.2.19 81ffca7
//! One reader for an Adom Fields report (issue #94).
//!
//! adom-fields 0.10.0 and later write a lean `fields.json` and move the bulk (each net's
//! `platedConnections`, the river's `flow.nets`, the cavity mode grids, the PDN curves, `parts` and
//! `vias`) to `detail.json` beside it, leaving `"platedConnections": "detail.json"` on a net and
//! `"detail": {"file": "detail.json", ...}` at the top. `merge` is adom-fields' own `slim::merge`, so
//! every aiflow screen reads the same whole document the Fields server serves. A report from before
//! 0.10.0 has no `detail` key and no pointer and reads as is; a stray detail.json beside such a
//! report is ignored (it cannot belong to that solve).
use serde_json::Value;
use std::path::{Path, PathBuf};

pub const DETAIL: &str = "detail.json";

/// True when a report says its bulk lives in detail.json (the top-level `detail` block, or any
/// field that points at the file).
pub fn is_lean(f: &Value) -> bool {
    let points = |v: &Value| v.as_str() == Some(DETAIL);
    f.get("detail").is_some_and(|d| d.is_object())
        || points(&f["flow"]["nets"])
        || f["nets"].as_array().into_iter().flatten().any(|n| points(&n["platedConnections"]))
}

/// The detail.json a lean report at `fields` relies on (None for an inline report).
pub fn detail_path(fields: &Path, f: &Value) -> Option<PathBuf> {
    is_lean(f).then(|| fields.parent().unwrap_or(Path::new(".")).join(DETAIL))
}

/// Put the detail back into a lean report: adom-fields src/slim.rs `merge`, line for line.
pub fn merge(mut f: Value, mut d: Value) -> Value {
    if let Some(n) = d["flow"].get_mut("nets") { f["flow"]["nets"] = n.take(); }
    if let Some(pc) = d["platedConnections"].as_object_mut() {
        for n in f["nets"].as_array_mut().into_iter().flatten() {
            if let Some(v) = pc.remove(n["net"].as_str().unwrap_or("")) { n["platedConnections"] = v; }
        }
    }
    for g in d["cavityGrids"].as_array().into_iter().flatten() {
        for c in f["review"]["noise"]["cavities"].as_array_mut().into_iter().flatten() {
            if c["net"] == g["net"] && c.get("grid").is_none() { c["grid"] = g["grid"].clone(); break; }
        }
    }
    if d["pdn"]["fHz"].is_array() {
        f["review"]["pdn"]["fHz"] = d["pdn"]["fHz"].take();
        for r in f["review"]["pdn"]["rails"].as_array_mut().into_iter().flatten() {
            if let Some(c) = d["pdn"]["curves"].get(r["net"].as_str().unwrap_or("")) {
                for (k, v) in c.as_object().into_iter().flatten() { r[k.as_str()] = v.clone(); }
            }
        }
    }
    for key in ["parts", "vias"] { if !d[key].is_null() { f[key] = d[key].take(); } }
    f
}

/// Merge a lean report with its detail and refuse a pointer the detail did not fill.
pub fn merge_checked(f: Value, d: Value) -> Result<Value, String> {
    let m = merge(f, d);
    let mut left: Vec<String> = m["nets"].as_array().into_iter().flatten()
        .filter(|n| n["platedConnections"].as_str() == Some(DETAIL))
        .map(|n| format!("nets.{}.platedConnections", n["net"].as_str().unwrap_or("?"))).collect();
    if m["flow"]["nets"].as_str() == Some(DETAIL) { left.push("flow.nets".into()); }
    if left.is_empty() { Ok(m) } else { Err(format!("{DETAIL} does not hold {}; it is not the detail of this solve. Run adom-fields analyze again", left.join(", "))) }
}

/// Read a Fields report the way adom-fields' own readers do: fields.json, with detail.json merged
/// back when the report is lean. A lean report whose detail.json is missing or unreadable is an
/// error that names the file.
pub fn load(fields: &Path) -> Result<Value, String> {
    let text = std::fs::read_to_string(fields).map_err(|e| format!("{}: {e}", fields.display()))?;
    let f: Value = serde_json::from_str(&text).map_err(|e| format!("{}: not JSON: {e}", fields.display()))?;
    let Some(dp) = detail_path(fields, &f) else { return Ok(f) };
    let dt = std::fs::read_to_string(&dp).map_err(|e| format!(
        "{} is a lean Adom Fields report (0.10+): its plated connections, flow nets, parts and vias are in {}, which cannot be read ({e}). Keep detail.json beside fields.json (copy both), or run adom-fields analyze again",
        fields.display(), dp.display()))?;
    let d: Value = serde_json::from_str(&dt).map_err(|e| format!("{}: not JSON: {e}", dp.display()))?;
    merge_checked(f, d).map_err(|e| format!("{}: {e}", fields.display()))
}

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

    fn whole() -> Value {
        json!({"inputIdentity": {"board": "b", "spec": "s"}, "flow": {"cellMm": 0.2, "nets": [{"net": "VCC", "x": 1}]},
            "nets": [{"net": "VCC", "amps": 2, "platedConnections": [{"from": "F.Cu", "to": "B.Cu", "amps": 2}]}, {"net": "GND", "platedConnections": []}],
            "parts": {"list": [1]}, "vias": [[0, 0]], "issues": []})
    }
    /// What adom-fields 0.10 slim::split writes for `whole()`.
    fn lean_and_detail() -> (Value, Value) {
        let mut lean = whole();
        lean["flow"]["nets"] = json!(DETAIL);
        lean["nets"][0]["platedConnections"] = json!(DETAIL);
        lean.as_object_mut().unwrap().remove("parts");
        lean.as_object_mut().unwrap().remove("vias");
        lean["detail"] = json!({"file": DETAIL, "holds": "..."});
        let d = json!({"flow": {"nets": [{"net": "VCC", "x": 1}]}, "platedConnections": {"VCC": [{"from": "F.Cu", "to": "B.Cu", "amps": 2}]}, "parts": {"list": [1]}, "vias": [[0, 0]]});
        (lean, d)
    }
    fn dir(tag: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!("aiflow-fields-report-{tag}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        d
    }

    #[test]
    fn lean_plus_detail_reads_as_the_inline_report() {
        let d = dir("lean");
        let (lean, detail) = lean_and_detail();
        std::fs::write(d.join("fields.json"), lean.to_string()).unwrap();
        std::fs::write(d.join(DETAIL), detail.to_string()).unwrap();
        let mut got = load(&d.join("fields.json")).unwrap();
        got.as_object_mut().unwrap().remove("detail");
        assert_eq!(got, whole());
    }

    #[test]
    fn inline_report_reads_as_is_and_ignores_a_stray_detail() {
        let d = dir("inline");
        std::fs::write(d.join("fields.json"), whole().to_string()).unwrap();
        std::fs::write(d.join(DETAIL), json!({"platedConnections": {"VCC": []}}).to_string()).unwrap();
        assert_eq!(load(&d.join("fields.json")).unwrap(), whole());
        assert!(detail_path(&d.join("fields.json"), &whole()).is_none());
    }

    #[test]
    fn lean_report_without_its_detail_is_a_clear_error() {
        let d = dir("missing");
        std::fs::write(d.join("fields.json"), lean_and_detail().0.to_string()).unwrap();
        let e = load(&d.join("fields.json")).unwrap_err();
        assert!(e.contains("detail.json") && e.contains("lean"), "{e}");
        // a detail.json from another solve that lacks this net's connections
        std::fs::write(d.join(DETAIL), json!({"flow": {"nets": []}}).to_string()).unwrap();
        let e = load(&d.join("fields.json")).unwrap_err();
        assert!(e.contains("nets.VCC.platedConnections"), "{e}");
    }
}