main
Caleb Publish 0.5.0 1984b40
//! An ERC error that comes from what the copper reading joined is a finding about that join, not a stop.
//!
//! `check schematic` runs KiCad's ERC on the sheets, and the sheets say what nets.json says. When the copper reader joins two
//! outputs (test board 6, 2026-11-11: QB and QD of a shift register on one island, an op-amp output on its own supply pin) the
//! ERC is right to object, and nothing whoever runs the flow may type clears it: the nets are measured, and nobody edits them by
//! hand. Such an error is recorded with the pins, the net, the copper that joins them and a test, `check schematic` lists it
//! and passes, and the connectivity score carries it. An ERC error of any other kind (a symbol, a wire, a label: the
//! generator's own work), or one on a net somebody stated rather than the copper, still fails the check.
use crate::{flow, items};
use serde_json::{json, Value};
use std::path::Path;

/// The ERC error types that speak about which pins share a net, and so can come from a join.
const ELECTRICAL: [&str; 3] = ["pin_to_pin", "pin_not_driven", "power_pin_not_driven"];

/// "Symbol U17 Pin 7 [OUT2, Output, Line]" -> "U17.7". Power flags and other `#` symbols are the generator's own.
fn pin_of(description: &str) -> Option<String> {
    let w: Vec<&str> = description.split_whitespace().collect();
    if w.len() >= 4 && w[0] == "Symbol" && w[2] == "Pin" { Some(format!("{}.{}", w[1], w[3])) } else { None }
}

/// The net a pin is on, when the copper put it there: the net is `fromCopper` and the pin is among the pins seen joined to it.
fn copper_net<'a>(nets: &'a Value, pin: &str) -> Option<(&'a Value, Vec<String>)> {
    let n = nets["nets"].as_array()?.iter().find(|n| n["pins"].as_array().into_iter().flatten().any(|p| p.as_str() == Some(pin)))?;
    // a net built from the pinouts carries the pins the copper joined in `copperGroups` too: a clash inside such a group is
    // the copper's join as much as on a net the copper alone made (test board 6: nine op-amp pins in one ground group)
    if n["fromCopper"] != json!(true) && n["copperGroups"].as_array().map_or(true, |g| g.is_empty()) { return None; }
    let group = n["copperGroups"].as_array().into_iter().flatten().find(|g| g.as_array().into_iter().flatten().any(|p| p.as_str() == Some(pin)))
        .map(|g| g.as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).collect::<Vec<_>>());
    match group {
        Some(g) => Some((n, g)),
        // a run from before the groups were recorded: a measured net's pins are the ones the copper joined
        None if n["copperGroups"].is_null() && n["tag"] == json!("MEASURED") => Some((n, n["pins"].as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).collect())),
        None => None,
    }
}

/// One ERC violation, judged: `Some(finding)` when it comes from pins the copper reading joined, `None` when it must still fail.
pub fn classify(viol: &Value, nets: &Value, copper: &Value) -> Option<Value> {
    let ty = viol["type"].as_str()?;
    if !ELECTRICAL.contains(&ty) { return None; }
    let all: Vec<String> = viol["items"].as_array().into_iter().flatten().filter_map(|i| i["description"].as_str()).filter_map(pin_of).collect();
    let pins: Vec<String> = all.iter().filter(|p| !p.starts_with('#')).cloned().collect();
    let flagged = pins.len() < all.len();
    if pins.is_empty() { return None; }
    let (net, group) = copper_net(nets, &pins[0])?;
    let name = net["name"].as_str().unwrap_or("");
    // every pin the error names sits on that one net, joined by the same copper
    if !pins.iter().all(|p| group.contains(p)) { return None; }
    // a clash needs a join: two pins of the error, or one pin and the rest of its copper
    if ty == "pin_to_pin" && pins.len() < 2 && group.len() < 2 { return None; }
    let pieces: Vec<String> = ["zones", "traces"].iter().flat_map(|k| copper[*k].as_array().into_iter().flatten()).filter(|c| c["net"].as_str() == Some(name)).filter_map(|c| c["id"].as_str().map(str::to_string)).collect();
    let said = viol["description"].as_str().unwrap_or("");
    let finding = match ty {
        "pin_to_pin" if pins.len() >= 2 => format!("{} are joined by the copper as read (net {name}), and KiCad's rules object: {said}. Either the reader joined two conductors, or a pin is named or turned wrongly", pins.join(" and ")),
        "pin_to_pin" => format!("{} sits on net {name}, which the copper as read joins to {}, and KiCad's rules object: {said}{}. Either the reader joined two conductors, or a pin is named or turned wrongly", pins[0], group.iter().filter(|p| **p != pins[0]).take(4).cloned().collect::<Vec<_>>().join(", "), if flagged { " (the power output is the flag the schematic puts on a rail)" } else { "" }),
        _ => format!("{} is an input that nothing drives on net {name} as the copper was read ({} pin(s) joined: {}): its driver is on copper the photos do not show joined, or the reader joined the wrong pins", pins[0], group.len(), group.iter().take(6).cloned().collect::<Vec<_>>().join(", ")),
    };
    let id = format!("E-{}", pins.join("-").replace('.', "_"));
    Some(json!({"id": id, "erc": ty, "ercSays": said, "pins": pins, "net": name, "joinedBy": group, "copper": pieces.iter().take(8).collect::<Vec<_>>(), "copperPieces": pieces.len(), "finding": finding, "test": format!("T-erc-{}", pins.join("-").replace('.', "_"))}))
}

/// What `check schematic` recorded, while that check stands.
pub fn recorded(flow_dir: &Path) -> Vec<Value> {
    if !flow::exists(flow_dir, "checks/schematic.ok") { return vec![]; }
    items::read_json(&flow_dir.join("checks/schematic.ok")).ok().and_then(|v| v["summary"]["copperFindings"].as_array().cloned()).unwrap_or_default()
}

pub fn lines(findings: &[Value]) -> Vec<String> {
    findings.iter().map(|f| format!("FINDING, not passed ({}): {}. Copper: {}{}. Test {}", f["id"].as_str().unwrap_or(""), f["finding"].as_str().unwrap_or(""), f["copper"].as_array().into_iter().flatten().filter_map(|c| c.as_str()).collect::<Vec<_>>().join(", "), match f["copperPieces"].as_u64() { Some(n) if n > 8 => format!(" and {} more pieces", n - 8), _ => String::new() }, f["test"].as_str().unwrap_or(""))).collect()
}

/// A continuity test for each finding: it settles the net.
pub fn tests(flow_dir: &Path) -> Vec<Value> {
    recorded(flow_dir).iter().map(|f| {
        let pins: Vec<&str> = f["pins"].as_array().into_iter().flatten().filter_map(|p| p.as_str()).collect();
        let others: Vec<&str> = f["joinedBy"].as_array().into_iter().flatten().filter_map(|p| p.as_str()).filter(|p| !pins.contains(p)).take(4).collect();
        let method = if pins.len() >= 2 { format!("Continuity between {}, board unpowered", pins.join(" and ")) } else { format!("Continuity from {} to {}, board unpowered", pins.first().copied().unwrap_or(""), if others.is_empty() { "the other pins of its net".to_string() } else { others.join(", ") }) };
        json!({"id": f["test"], "settles": [format!("net:{}", f["net"].as_str().unwrap_or(""))], "generated": true, "method": format!("{method}. {}", f["finding"].as_str().unwrap_or("")),
            "equipment": "DMM in continuity mode, fine probes", "expected": "no continuity where KiCad's rules object to the join: the copper reading joined two conductors", "ifNot": "the join is real: check the part's identity, its pin names and which way it is turned, then correct parts.json or the datasheet pins"})
    }).collect()
}

pub const CEILING: &str = "nets the electrical rules accept";

/// The connectivity ceiling: of the nets with two or more pins, the share no finding names.
pub fn ceiling(flow_dir: &Path, nets: &[Value]) -> Option<(f64, String)> {
    let f = recorded(flow_dir);
    if f.is_empty() { return None; }
    let multi = nets.iter().filter(|n| n["pins"].as_array().map(|p| p.len() >= 2).unwrap_or(false)).count().max(1);
    let mut bad: Vec<&str> = f.iter().filter_map(|x| x["net"].as_str()).collect(); bad.sort(); bad.dedup();
    let ok = multi.saturating_sub(bad.len());
    Some((100.0 * ok as f64 / multi as f64, format!("{} of {multi} nets with two or more pins carry an ERC finding from the copper as read ({}): {}", bad.len(), f.len(), bad.iter().take(6).cloned().collect::<Vec<_>>().join(", "))))
}

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

    fn viol(ty: &str, said: &str, items: &[&str]) -> Value { json!({"type": ty, "severity": "error", "description": said, "items": items.iter().map(|d| json!({"description": d})).collect::<Vec<_>>()}) }

    #[test]
    fn an_erc_error_from_pins_the_copper_joined_is_a_finding_and_any_other_still_fails() {
        let nets = json!({"nets": [
            {"name": "U3_QB", "fromCopper": true, "tag": "MEASURED", "pins": ["U3.1", "U3.3"], "copperGroups": [["U3.1", "U3.3"]]},
            {"name": "U18_VCC+", "fromCopper": true, "tag": "MEASURED", "pins": ["U18.7", "U18.8"], "copperGroups": [["U18.7", "U18.8"]]},
            {"name": "U17_IN2+", "fromCopper": true, "tag": "MEASURED", "pins": ["U17.5", "U17.6"], "copperGroups": [["U17.5", "U17.6"]]},
            {"name": "SDA", "tag": "INFERRED", "pins": ["U1.4", "U2.4"]},
            {"name": "MIX", "fromCopper": true, "tag": "MEASURED", "pins": ["U5.1", "U5.2", "U9.9"], "copperGroups": [["U5.1", "U5.2"]]}]});
        let copper = json!({"zones": [{"id": "isl-top-7", "net": "U3_QB"}], "traces": [{"id": "trk-top-9", "net": "U3_QB"}]});
        // board 6: two outputs of a shift register on one island
        let f = classify(&viol("pin_to_pin", "Pins of type Output and Output are connected", &["Symbol U3 Pin 1 [QB, Output, Line]", "Symbol U3 Pin 3 [QD, Output, Line]"]), &nets, &copper).unwrap();
        assert_eq!(f["pins"], json!(["U3.1", "U3.3"])); assert_eq!(f["net"], "U3_QB"); assert_eq!(f["copper"], json!(["isl-top-7", "trk-top-9"]));
        assert_eq!(f["test"], "T-erc-U3_1-U3_3");
        assert!(f["finding"].as_str().unwrap().contains("U3.1 and U3.3 are joined by the copper as read (net U3_QB)"));
        // an output on its own supply pin: the other end of the error is the rail's flag
        let f = classify(&viol("pin_to_pin", "Pins of type Output and Power output are connected", &["Symbol U18 Pin 7 [OUT2, Output, Line]", "Symbol #FLG0002 Pin 1 [pwr, Power output, Line]"]), &nets, &copper).unwrap();
        assert_eq!(f["pins"], json!(["U18.7"])); assert!(f["finding"].as_str().unwrap().contains("joins to U18.8"));
        // an input the copper as read leaves with no driver
        assert!(classify(&viol("pin_not_driven", "Input pin not driven by any Output pins", &["Symbol U17 Pin 5 [IN2+, Input, Line]"]), &nets, &copper).is_some());
        // the same clash on a net somebody stated: theirs to correct, so it still fails
        assert!(classify(&viol("pin_to_pin", "Pins of type Output and Output are connected", &["Symbol U1 Pin 4 [SDA, Output, Line]", "Symbol U2 Pin 4 [SDA, Output, Line]"]), &nets, &copper).is_none());
        // a pin that is on a copper net by statement, not by a seen join, with one that is
        assert!(classify(&viol("pin_to_pin", "Pins of type Output and Output are connected", &["Symbol U5 Pin 1 [A, Output, Line]", "Symbol U9 Pin 9 [B, Output, Line]"]), &nets, &copper).is_none());
        // the generator's own faults always fail
        for ty in ["wire_dangling", "label_dangling", "lib_symbol_issues", "duplicate_reference", "multiple_net_names"] { assert!(classify(&viol(ty, "x", &["Symbol U3 Pin 1 [QB, Output, Line]", "Symbol U3 Pin 3 [QD, Output, Line]"]), &nets, &copper).is_none(), "{ty}"); }
        // and an electrical error with only the generator's symbols in it
        assert!(classify(&viol("pin_to_pin", "x", &["Symbol #FLG01 Pin 1 [pwr, Power output, Line]", "Symbol #FLG02 Pin 1 [pwr, Power output, Line]"]), &nets, &copper).is_none());
    }

    #[test]
    fn a_recorded_finding_brings_a_test_and_a_ceiling_and_goes_stale_with_the_check() {
        let d = std::env::temp_dir().join(format!("ercfind-{}", std::process::id())).join("reverse-flow");
        let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(d.join("checks")).unwrap();
        let nets = json!({"nets": [{"name": "U3_QB", "pins": ["U3.1", "U3.3"]}, {"name": "A", "pins": ["R1.1", "R2.1"]}, {"name": "B", "pins": ["R1.2"]}, {"name": "C", "pins": ["R3.1", "R4.1"]}, {"name": "D", "pins": ["R5.1", "R6.1"]}]});
        std::fs::write(d.join("nets.json"), nets.to_string()).unwrap();
        assert!(recorded(&d).is_empty() && tests(&d).is_empty() && ceiling(&d, nets["nets"].as_array().unwrap()).is_none());
        let f = json!({"id": "E-U3_1-U3_3", "erc": "pin_to_pin", "pins": ["U3.1", "U3.3"], "net": "U3_QB", "joinedBy": ["U3.1", "U3.3"], "copper": ["isl-top-7"], "copperPieces": 1, "finding": "U3.1 and U3.3 are joined", "test": "T-erc-U3_1-U3_3"});
        std::fs::write(d.join("checks/schematic.ok"), json!({"check": "schematic", "files": {"nets.json": crate::sha256(&d.join("nets.json"))}, "summary": {"copperFindings": [f]}}).to_string()).unwrap();
        let t = tests(&d);
        assert_eq!(t[0]["id"], "T-erc-U3_1-U3_3"); assert_eq!(t[0]["settles"], json!(["net:U3_QB"]));
        assert!(t[0]["method"].as_str().unwrap().starts_with("Continuity between U3.1 and U3.3"));
        let (v, why) = ceiling(&d, nets["nets"].as_array().unwrap()).unwrap();
        assert_eq!(v, 75.0); assert!(why.contains("1 of 4 nets"), "{why}");
        assert!(lines(&recorded(&d))[0].starts_with("FINDING, not passed (E-U3_1-U3_3)"));
        // the nets changed: the check is stale and its findings no longer stand
        std::fs::write(d.join("nets.json"), json!({"nets": []}).to_string()).unwrap();
        assert!(recorded(&d).is_empty());
        std::fs::remove_dir_all(d.parent().unwrap()).ok();
    }
}