main
John Lauer 0.10.0: lean by default; optional modules with board-specific hints; grouped findings; lean fields.json + detail.json;… 4bbb86d
//! A lean fields.json (0.10.0): what an AI reads stays in fields.json; the bulk the app draws (the river's per-net
//! flow, every plated connection's current, the cavities' mode grids, the PDN curves, the parts' placements and the via
//! list) goes to detail.json beside it. `merge` puts them back: the server, probe and every reader that draws use it,
//! so GET /fields.json is the whole document, as before.
use serde_json::{json, Value};
use std::path::Path;

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

/// Split a summary into the lean part and the detail.
pub fn split(mut f: Value) -> (Value, Value) {
    let mut d = json!({});
    if let Some(n) = f["flow"].get_mut("nets") { d["flow"] = json!({"nets": n.take()}); f["flow"]["nets"] = json!("detail.json"); }
    let mut pc = serde_json::Map::new();
    for n in f["nets"].as_array_mut().into_iter().flatten() { let name = n["net"].as_str().unwrap_or("").to_string(); if let Some(v) = n.get_mut("platedConnections") { if v.as_array().map_or(0, |a| a.len()) > 0 { pc.insert(name, v.take()); n["platedConnections"] = json!("detail.json"); } } }
    if !pc.is_empty() { d["platedConnections"] = Value::Object(pc); }
    let mut grids = Vec::new();
    for c in f["review"]["noise"]["cavities"].as_array_mut().into_iter().flatten() { let name = c["net"].clone(); if let Some(g) = c.get_mut("grid") { grids.push(json!({"net": name, "grid": g.take()})); c.as_object_mut().map(|o| o.remove("grid")); } }
    if !grids.is_empty() { d["cavityGrids"] = json!(grids); }
    if f["review"]["pdn"]["fHz"].is_array() {
        let mut curves = serde_json::Map::new();
        for r in f["review"]["pdn"]["rails"].as_array_mut().into_iter().flatten() { let k = r["net"].as_str().unwrap_or("").to_string(); let mut c = serde_json::Map::new(); for key in ["zOhm", "zAfterOhm"] { if let Some(v) = r.get_mut(key) { if v.is_array() { c.insert(key.into(), v.take()); r.as_object_mut().map(|o| o.remove(key)); } } } if !c.is_empty() { curves.insert(k, Value::Object(c)); } }
        d["pdn"] = json!({"fHz": f["review"]["pdn"]["fHz"].take(), "curves": curves}); f["review"]["pdn"].as_object_mut().map(|o| o.remove("fHz"));
    }
    for key in ["parts", "vias"] { if !f[key].is_null() { d[key] = f[key].take(); f.as_object_mut().map(|o| o.remove(key)); } }
    f["detail"] = json!({"file": DETAIL, "holds": "the river's per-net flow, each plated connection's current, cavity mode grids, PDN |Z(f)| curves, the parts' 3D placements and the via list: for the app; GET /fields.json on the server returns everything merged"});
    (f, d)
}

/// The lean file's layout: one top-level key per line, and one element per line for the lists an AI scans (findings,
/// fix-plan steps, nets, parts): compact, still readable line by line, a third the size of pretty-printed JSON.
pub fn to_text(f: &Value) -> String {
    let Some(o) = f.as_object() else { return f.to_string() };
    let mut out = String::from("{\n"); let n = o.len();
    for (i, (k, v)) in o.iter().enumerate() {
        let body = match v { Value::Array(a) if a.len() > 1 && a.iter().all(|x| x.is_object()) => format!("[\n  {}\n ]", a.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(",\n  ")), _ => v.to_string() };
        out.push_str(&format!(" {}: {}{}\n", Value::String(k.clone()), body, if i + 1 < n { "," } else { "" }));
    }
    out.push('}'); out.push('\n'); out
}

/// Put the detail back into a lean summary (the inverse of split).
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
}

/// fields.json with its detail merged in, from a solve folder (a solve from before 0.10.0 has no detail.json and reads as is)
pub fn load(dir: &Path) -> Option<Value> {
    let f: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("fields.json")).ok()?).ok()?;
    match std::fs::read_to_string(dir.join(DETAIL)).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) { Some(d) => Some(merge(f, d)), None => Some(f) }
}

#[cfg(test)] mod tests {
    use super::*;
    #[test] fn round_trip() {
        let f = json!({"flow": {"cellMm": 0.2, "nets": [{"net": "A", "x": 1}]}, "nets": [{"net": "A", "platedConnections": [{"amps": 1}]}, {"net": "B", "platedConnections": []}],
            "review": {"noise": {"cavities": [{"net": "V", "grid": [1, 2], "modes": [3]}]}, "pdn": {"fHz": [1, 2], "rails": [{"net": "V", "zOhm": [0.1, 0.2], "worst": 1}]}}, "parts": {"list": [1]}, "vias": [[0, 0]], "issues": []});
        let (lean, d) = split(f.clone());
        assert!(lean["parts"].is_null() && lean["vias"].is_null() && lean["review"]["pdn"]["fHz"].is_null() && lean["review"]["noise"]["cavities"][0].get("grid").is_none());
        let mut back = merge(lean, d); back.as_object_mut().unwrap().remove("detail");
        assert_eq!(back, f);
        let t = to_text(&json!({"a": [{"x": 1}, {"x": 2}], "b": {"c": [1, 2]}})); assert_eq!(serde_json::from_str::<Value>(&t).unwrap(), json!({"a": [{"x": 1}, {"x": 2}], "b": {"c": [1, 2]}})); assert_eq!(t.lines().count(), 7);
    }
}