master
Colby Knox Merge PR #362: kicad-lane: 1.0.93, schematic_review checks the drawing; PWR_FLAG and escaped label fixes 47fc422 11d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878
//! Netlist extraction and connectivity queries, ported from `handlers/netlist.py`.
//!
//! One export (`kicad-cli sch export netlist --format kicadxml`), one parse into
//! `Netlist {components, nets}`, and pure query functions over that model for the
//! four verbs (extract, trace_net, find_connections, analyze_connections). The
//! queries never touch the OS, so they are unit-tested against an inline sample
//! of KiCad's kicadxml shape without kicad-cli.

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;

use quick_xml::events::{BytesStart, Event};
use quick_xml::Reader;
use serde::Serialize;

use crate::cli;

/// Python `_NETLIST_TIMEOUT`: kicad-cli gets this long per export.
pub const NETLIST_TIMEOUT: Duration = Duration::from_secs(120);

/// The formats `kicad-cli sch export netlist --format` accepts for rawFormat, sorted
/// the way the Python verb lists them in its `allowed` key.
pub const RAW_FORMATS: &[&str] = &["allegro", "cadstar", "kicadsexpr", "kicadxml", "orcadpcb2", "pads", "spice", "spicemodel"];

/// Python caps the raw netlist text at 200,000 characters.
pub const RAW_TEXT_CAP: usize = 200_000;

#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Component {
    #[serde(skip)]
    pub reference: String,
    pub value: String,
    pub footprint: String,
    pub libsource: String,
    pub description: String,
    /// The symbol's uuid (`<tstamps>`) and its sheet path (`<sheetpath tstamps>`): together the
    /// `(path ...)` a board footprint carries to link back to it.
    #[serde(skip)]
    pub tstamp: String,
    #[serde(skip)]
    pub sheet_tstamps: String,
    /// The symbol's fields (`<fields><field name="MPN">...`) and flags (`<property name="dnp"/>`,
    /// `exclude_from_bom`), for the BOM; not part of the netlist verbs' replies.
    #[serde(skip)]
    pub fields: Vec<(String, String)>,
    #[serde(skip)]
    pub properties: Vec<(String, String)>,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Node {
    #[serde(rename = "ref")]
    pub reference: String,
    pub pin: String,
    pub pinfunction: String,
    pub pintype: String,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Net {
    pub name: String,
    pub code: String,
    pub nodes: Vec<Node>,
}

/// The parsed model. Components keep the XML order (Python's dict insertion order);
/// a duplicate ref replaces the earlier entry in place, as a dict assignment would.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Netlist {
    pub components: Vec<Component>,
    pub nets: Vec<Net>,
}

impl Netlist {
    pub fn component(&self, reference: &str) -> Option<&Component> {
        self.components.iter().find(|c| c.reference == reference)
    }
}

/// An error from the export or sniff steps: the Python `error` text, an optional
/// stderr tail, and the `_hint` the calling AI should read.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NetlistError {
    pub code: &'static str,
    pub error: String,
    pub stderr: Option<String>,
    pub hint: String,
}

impl NetlistError {
    fn new(code: &'static str, error: impl Into<String>, hint: impl Into<String>) -> Self {
        NetlistError { code, error: error.into(), stderr: None, hint: hint.into() }
    }
}

// ---------------------------------------------------------------------------
// XML parsing
// ---------------------------------------------------------------------------

fn attr(e: &BytesStart<'_>, name: &str) -> String {
    match e.try_get_attribute(name) {
        Ok(Some(a)) => a.unescape_value().map(|v| v.into_owned()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).into_owned()),
        _ => String::new(),
    }
}

/// Parse KiCad's kicadxml netlist (`<export><components><comp/>...<nets><net><node/>`).
///
/// Mirrors `_parse_netlist_xml`: every `comp` anywhere in the tree becomes a
/// component (value, footprint, libsource as "lib:part", description from the
/// `description` child or the libsource attribute); every `net` becomes a net with
/// its direct `node` children.
pub fn parse_kicadxml(xml: &str) -> Result<Netlist, String> {
    let mut reader = Reader::from_str(xml);
    let mut stack: Vec<String> = Vec::new();
    let mut out = Netlist::default();
    // In-progress component and net, plus the depth at which each was opened so a
    // nested element with the same name (there is none in practice) cannot confuse us.
    let mut comp: Option<(usize, Component, Option<String>)> = None; // (depth, comp, libsource description)
    let mut net: Option<(usize, Net)> = None;
    // Text accumulated for the comp child currently open (value, footprint, description).
    let mut text_target: Option<(String, String)> = None; // (child name, text so far)

    loop {
        let ev = reader.read_event().map_err(|e| format!("could not parse netlist XML: {e}"))?;
        match ev {
            Event::Start(e) => open_element(&e, &mut stack, &mut comp, &mut net, &mut text_target),
            Event::Empty(e) => {
                // An Empty event has no matching End, so open and close it right away.
                open_element(&e, &mut stack, &mut comp, &mut net, &mut text_target);
                close_element(&mut stack, &mut comp, &mut net, &mut text_target, &mut out);
            }
            Event::End(_) => close_element(&mut stack, &mut comp, &mut net, &mut text_target, &mut out),
            Event::Text(t) => {
                if let Some((_, buf)) = text_target.as_mut() {
                    // Only text directly inside the child element counts (findtext semantics).
                    let s = t.unescape().map(|v| v.into_owned()).unwrap_or_else(|_| String::from_utf8_lossy(&t).into_owned());
                    buf.push_str(&s);
                }
            }
            Event::CData(c) => {
                if let Some((_, buf)) = text_target.as_mut() {
                    buf.push_str(&String::from_utf8_lossy(&c));
                }
            }
            Event::Eof => break,
            _ => {}
        }
    }
    Ok(out)
}

/// A Start or Empty event: push the element and, when it is one we care about,
/// begin a component, a net, a node, or text capture for a comp child.
fn open_element(
    e: &BytesStart<'_>,
    stack: &mut Vec<String>,
    comp: &mut Option<(usize, Component, Option<String>)>,
    net: &mut Option<(usize, Net)>,
    text_target: &mut Option<(String, String)>,
) {
    let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
    stack.push(name.clone());
    let depth = stack.len();
    let parent = if depth >= 2 { stack[depth - 2].as_str() } else { "" };
    let comp_child = parent == "comp" && comp.as_ref().map(|c| c.0 + 1 == depth).unwrap_or(false);
    let net_child = parent == "net" && net.as_ref().map(|n| n.0 + 1 == depth).unwrap_or(false);
    match name.as_str() {
        "comp" if comp.is_none() => {
            *comp = Some((depth, Component { reference: attr(e, "ref"), ..Default::default() }, None));
        }
        "libsource" if comp_child => {
            if let Some((_, c, lib_desc)) = comp.as_mut() {
                // Python find() takes the first libsource child only.
                if lib_desc.is_none() {
                    let lib = attr(e, "lib");
                    let part = attr(e, "part");
                    c.libsource = if !lib.is_empty() && !part.is_empty() { format!("{lib}:{part}") } else { String::new() };
                    *lib_desc = Some(attr(e, "description"));
                }
            }
        }
        "sheetpath" if comp_child => {
            if let Some((_, c, _)) = comp.as_mut() {
                c.sheet_tstamps = attr(e, "tstamps");
            }
        }
        "tstamps" if comp_child => {
            *text_target = Some((name.clone(), String::new()));
        }
        "value" | "footprint" | "description" if comp_child => {
            // Python findtext returns the first matching child; a repeat is ignored.
            let already = comp.as_ref().map(|(_, c, _)| match name.as_str() {
                "value" => !c.value.is_empty(),
                "footprint" => !c.footprint.is_empty(),
                _ => !c.description.is_empty(),
            }).unwrap_or(false);
            if !already {
                *text_target = Some((name.clone(), String::new()));
            }
        }
        // a field is a grandchild (comp > fields > field); a property is a child
        "field" if parent == "fields" && comp.as_ref().map(|c| c.0 + 2 == depth).unwrap_or(false) => {
            *text_target = Some((format!("field:{}", attr(e, "name")), String::new()));
        }
        "property" if comp_child => {
            if let Some((_, c, _)) = comp.as_mut() {
                c.properties.push((attr(e, "name"), attr(e, "value")));
            }
        }
        "net" if net.is_none() => {
            *net = Some((depth, Net { name: attr(e, "name"), code: attr(e, "code"), nodes: Vec::new() }));
        }
        "node" if net_child => {
            if let Some((_, n)) = net.as_mut() {
                n.nodes.push(Node { reference: attr(e, "ref"), pin: attr(e, "pin"), pinfunction: attr(e, "pinfunction"), pintype: attr(e, "pintype") });
            }
        }
        _ => {}
    }
}

fn close_element(
    stack: &mut Vec<String>,
    comp: &mut Option<(usize, Component, Option<String>)>,
    net: &mut Option<(usize, Net)>,
    text_target: &mut Option<(String, String)>,
    out: &mut Netlist,
) {
    let depth = stack.len();
    let Some(name) = stack.pop() else { return };
    if let Some((child, text)) = text_target.take() {
        if child == name || (name == "field" && child.starts_with("field:")) {
            if let Some((_, c, _)) = comp.as_mut() {
                let t = text.trim().to_string();
                match child.as_str() {
                    "value" => c.value = t,
                    "footprint" => c.footprint = t,
                    "description" => c.description = t,
                    "tstamps" => c.tstamp = t,
                    f if f.starts_with("field:") => c.fields.push((f["field:".len()..].to_string(), t)),
                    _ => {}
                }
            }
        } else {
            // Not the element we were collecting for; keep collecting.
            *text_target = Some((child, text));
        }
    }
    if let Some((d, _, _)) = comp.as_ref() {
        if *d == depth && name == "comp" {
            let (_, mut c, lib_desc) = comp.take().unwrap();
            if c.description.is_empty() {
                c.description = lib_desc.unwrap_or_default().trim().to_string();
            }
            match out.components.iter_mut().find(|x| x.reference == c.reference) {
                Some(slot) => *slot = c,
                None => out.components.push(c),
            }
        }
    }
    if let Some((d, _)) = net.as_ref() {
        if *d == depth && name == "net" {
            let (_, n) = net.take().unwrap();
            out.nets.push(n);
        }
    }
}

// ---------------------------------------------------------------------------
// Queries (pure)
// ---------------------------------------------------------------------------

/// `kicad_trace_net`: exact name first, then case-insensitive; on a miss, up to 12
/// substring candidates so a typo reads as a broken query, not a broken schematic.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TraceResult<'a> {
    Found(&'a Net),
    Miss { candidates: Vec<String> },
}

/// The standard's schematic_review inputs (sheetParts, netlist) from KiCad's own netlist: each part with its pins
/// (number, name, electrical type in the engine's words), and the nets without KiCad's single-pin
/// `unconnected-(...)` nets, so a pin wired to nothing reads as unconnected. A pin KiCad marks no-connect
/// (`+no_connect`) is `not_connected`.
pub fn review_inputs(model: &Netlist) -> (serde_json::Value, serde_json::Value) {
    use serde_json::json;
    let kind = |t: &str| -> &'static str {
        if t.contains("no_connect") {
            return "not_connected";
        }
        match t {
            "power_in" | "power_out" => "power",
            "input" => "input",
            "output" => "output",
            "bidirectional" => "io",
            "tri_state" => "hiz",
            "open_collector" | "open_emitter" => "open_collector",
            "passive" | "free" => "passive",
            _ => "unknown",
        }
    };
    let mut parts: std::collections::BTreeMap<String, Vec<serde_json::Value>> = std::collections::BTreeMap::new();
    let mut nets = serde_json::Map::new();
    for n in &model.nets {
        for nd in &n.nodes {
            parts.entry(nd.reference.clone()).or_default().push(json!({"number": nd.pin, "name": nd.pinfunction, "electrical": kind(&nd.pintype)}));
        }
        if !n.name.starts_with("unconnected-(") {
            nets.insert(n.name.trim_start_matches('/').to_string(), json!(n.nodes.iter().map(|nd| format!("{}.{}", nd.reference, nd.pin)).collect::<Vec<_>>()));
        }
    }
    let value = |r: &str| model.component(r).map(|c| c.value.clone()).unwrap_or_default();
    // the symbol's fields as the standard's params (MPN, LCSC, Mouser, Datasheet, ...): an empty field, or KiCad's "~"
    // for no datasheet, is not there
    let params = |r: &str| -> Vec<serde_json::Value> {
        model.component(r).map(|c| c.fields.iter().filter(|(_, v)| !v.trim().is_empty() && v.trim() != "~")
            .map(|(k, v)| json!({"name": k, "value": v})).collect()).unwrap_or_default()
    };
    // libRef ("Lib:Symbol") lets the review tell an LED, a regulator or a crystal by its symbol (engine 0.22.0, #29)
    let lib_ref = |r: &str| model.component(r).map(|c| c.libsource.clone()).unwrap_or_default();
    let comps: Vec<serde_json::Value> = parts.into_iter().map(|(r, pins)| { let v = value(&r); json!({"designator": r, "pins": pins, "value": v, "comment": v, "libRef": lib_ref(&r), "params": params(&r)}) }).collect();
    (json!({"components": comps}), json!({"nets": nets}))
}

pub fn trace_net<'a>(model: &'a Netlist, net_name: &str) -> TraceResult<'a> {
    if let Some(n) = model.nets.iter().find(|n| n.name == net_name) {
        return TraceResult::Found(n);
    }
    let lower = net_name.to_lowercase();
    if let Some(n) = model.nets.iter().find(|n| n.name.to_lowercase() == lower) {
        return TraceResult::Found(n);
    }
    let candidates = model.nets.iter().filter(|n| n.name.to_lowercase().contains(&lower)).map(|n| n.name.clone()).take(12).collect();
    TraceResult::Miss { candidates }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Peer {
    #[serde(rename = "ref")]
    pub reference: String,
    pub pin: String,
    pub pinfunction: String,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PinConnection {
    pub pin: String,
    pub pinfunction: String,
    pub pintype: String,
    pub net: String,
    #[serde(rename = "connectedTo")]
    pub connected_to: Vec<Peer>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FindResult<'a> {
    Found { component: &'a Component, pins: Vec<PinConnection> },
    Miss { candidates: Vec<String> },
}

/// `kicad_find_connections`: every pin of one reference with its net and peer pins,
/// sorted by (pin length, pin) so "2" comes before "10". A miss returns the
/// case-insensitive match if there is one, else up to 12 references sharing the
/// first letter.
pub fn find_connections<'a>(model: &'a Netlist, reference: &str) -> FindResult<'a> {
    let Some(component) = model.component(reference) else {
        let lower = reference.to_lowercase();
        let mut near: Vec<String> = model.components.iter().filter(|c| c.reference.to_lowercase() == lower).map(|c| c.reference.clone()).collect();
        if near.is_empty() {
            let first: String = reference.chars().next().map(|c| c.to_lowercase().collect()).unwrap_or_default();
            let mut sorted: Vec<&str> = model.components.iter().map(|c| c.reference.as_str()).collect();
            sorted.sort();
            near = sorted.into_iter().filter(|r| r.to_lowercase().starts_with(&first)).take(12).map(String::from).collect();
        }
        return FindResult::Miss { candidates: near };
    };
    let mut pins: Vec<PinConnection> = Vec::new();
    for net in &model.nets {
        for node in &net.nodes {
            if node.reference != reference {
                continue;
            }
            let peers = net
                .nodes
                .iter()
                .filter(|p| p.reference != reference)
                .map(|p| Peer { reference: p.reference.clone(), pin: p.pin.clone(), pinfunction: p.pinfunction.clone() })
                .collect();
            pins.push(PinConnection {
                pin: node.pin.clone(),
                pinfunction: node.pinfunction.clone(),
                pintype: node.pintype.clone(),
                net: net.name.clone(),
                connected_to: peers,
            });
        }
    }
    pins.sort_by(|a, b| (a.pin.len(), &a.pin).cmp(&(b.pin.len(), &b.pin)));
    FindResult::Found { component, pins }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct SingleNode {
    pub net: String,
    pub node: Node,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct NetCount {
    pub net: String,
    #[serde(rename = "nodeCount")]
    pub node_count: usize,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ComponentFanout {
    #[serde(rename = "ref")]
    pub reference: String,
    #[serde(rename = "connectedPins")]
    pub connected_pins: usize,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Analysis {
    #[serde(rename = "componentCount")]
    pub component_count: usize,
    #[serde(rename = "netCount")]
    pub net_count: usize,
    #[serde(rename = "suspectedUnconnected")]
    pub suspected_unconnected: Vec<SingleNode>,
    #[serde(rename = "powerNets")]
    pub power_nets: Vec<NetCount>,
    #[serde(rename = "highestFanoutNets")]
    pub highest_fanout_nets: Vec<NetCount>,
    #[serde(rename = "mostConnectedComponents")]
    pub most_connected_components: Vec<ComponentFanout>,
}

/// `kicad_analyze_connections`: single-node nets, power nets (any power_in or
/// power_out pin), top ten nets by node count, top ten components by connected pins.
/// Sorts are stable so ties keep netlist order, as Python's sorted() does.
pub fn analyze(model: &Netlist) -> Analysis {
    let suspected_unconnected = model
        .nets
        .iter()
        .filter(|n| n.nodes.len() == 1)
        .map(|n| SingleNode { net: n.name.clone(), node: n.nodes[0].clone() })
        .collect();
    let mut power: Vec<NetCount> = model
        .nets
        .iter()
        .filter(|n| n.nodes.iter().any(|p| p.pintype == "power_in" || p.pintype == "power_out"))
        .map(|n| NetCount { net: n.name.clone(), node_count: n.nodes.len() })
        .collect();
    power.sort_by(|a, b| b.node_count.cmp(&a.node_count));
    power.truncate(10);
    let mut fanout: Vec<NetCount> = model.nets.iter().map(|n| NetCount { net: n.name.clone(), node_count: n.nodes.len() }).collect();
    fanout.sort_by(|a, b| b.node_count.cmp(&a.node_count));
    fanout.truncate(10);
    let mut per_comp: Vec<ComponentFanout> = Vec::new();
    for n in &model.nets {
        for node in &n.nodes {
            match per_comp.iter_mut().find(|c| c.reference == node.reference) {
                Some(c) => c.connected_pins += 1,
                None => per_comp.push(ComponentFanout { reference: node.reference.clone(), connected_pins: 1 }),
            }
        }
    }
    per_comp.sort_by(|a, b| b.connected_pins.cmp(&a.connected_pins));
    per_comp.truncate(10);
    Analysis {
        component_count: model.components.len(),
        net_count: model.nets.len(),
        suspected_unconnected,
        power_nets: power,
        highest_fanout_nets: fanout,
        most_connected_components: per_comp,
    }
}

// ---------------------------------------------------------------------------
// File sniff and kicad-cli export
// ---------------------------------------------------------------------------

/// Python `_file_format_sniff(path, "kicad_sch")`: exists, is a file, starts with
/// `(kicad_sch`. Returns the `(version N)` header value when present.
pub fn sniff_schematic(path: &Path) -> Result<Option<u64>, NetlistError> {
    if !path.exists() {
        return Err(NetlistError::new(
            "file_not_found",
            format!("file not found: {}", path.display()),
            "Verify the path. If the AI is on Docker, the path must exist on the Windows host (use push_file to copy from Docker first).",
        ));
    }
    if !path.is_file() {
        return Err(NetlistError::new("not_a_file", format!("not a file: {}", path.display()), "Expected a .kicad_sch schematic."));
    }
    let mut head = vec![0u8; 512];
    let n = match std::fs::File::open(path).and_then(|mut f| {
        use std::io::Read;
        f.read(&mut head)
    }) {
        Ok(n) => n,
        Err(e) => return Err(NetlistError::new("read_failed", format!("read failed: {e}"), "Expected a .kicad_sch schematic.")),
    };
    head.truncate(n);
    let head_text = String::from_utf8_lossy(&head).trim_start().to_string();
    if !head_text.starts_with("(kicad_sch") {
        let wrong = head_text.starts_with("(kicad_pcb");
        let first: String = head_text.chars().take(80).collect();
        return Err(NetlistError::new(
            "wrong_file_type",
            format!("file is not a kicad_sch (first bytes: {first:?})"),
            if wrong {
                "You passed what looks like a kicad_pcb file to a verb expecting kicad_sch. Use the matching verb instead (kicad_open_board for .kicad_pcb, kicad_open_schematic / kicad_lint_schematic for .kicad_sch).".to_string()
            } else {
                "The file doesn't start with the expected kicad_sch S-expression header. It may be corrupted, truncated, or not a KiCad file at all.".to_string()
            },
        ));
    }
    Ok(file_version(&head_text))
}

fn file_version(head: &str) -> Option<u64> {
    let idx = head.find("(version")?;
    let rest = head[idx + "(version".len()..].trim_start();
    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
    if digits.is_empty() || !rest[digits.len()..].starts_with(')') {
        return None;
    }
    digits.parse().ok()
}

static TMP_SEQ: AtomicU64 = AtomicU64::new(0);

fn temp_path(suffix: &str) -> PathBuf {
    let n = TMP_SEQ.fetch_add(1, Ordering::Relaxed);
    std::env::temp_dir().join(format!("adom-kicad-netlist-{}-{n}.{suffix}", std::process::id()))
}

fn tail(s: &str, n: usize) -> String {
    let count = s.chars().count();
    if count <= n { s.to_string() } else { s.chars().skip(count - n).collect() }
}

/// Run `kicad-cli sch export netlist --format <fmt> --output <tmp> <sch>` and return
/// the file's text. The temp file is always removed.
fn export_netlist(cli_exe: &Path, sch_path: &Path, fmt: &str) -> Result<String, NetlistError> {
    let tmp = temp_path(fmt);
    let tmp_s = tmp.to_string_lossy().replace('\\', "/");
    let sch_s = sch_path.to_string_lossy().replace('\\', "/");
    let r = cli::run(cli_exe, &["sch", "export", "netlist", "--format", fmt, "--output", tmp_s.as_str(), sch_s.as_str()], NETLIST_TIMEOUT);
    let text = std::fs::read(&tmp).ok().map(|b| String::from_utf8_lossy(&b).into_owned());
    let _ = std::fs::remove_file(&tmp);
    if r.timed_out {
        return Err(NetlistError::new("cli_timeout", format!("kicad-cli timed out after {}s", NETLIST_TIMEOUT.as_secs()), "The schematic is very large or kicad-cli hung; try again or run kicad_lint_schematic first."));
    }
    if r.exit_code != 0 || text.as_deref().map(str::is_empty).unwrap_or(true) {
        let mut e = NetlistError::new(
            "cli_failed",
            format!("kicad-cli netlist export failed (rc={})", r.exit_code),
            "Run kicad_lint_schematic first: a parse error there explains this failure in one second instead of a cryptic rc here.",
        );
        e.stderr = Some(tail(&r.stderr, 800));
        return Err(e);
    }
    Ok(text.unwrap_or_default())
}

/// Export the kicadxml netlist and parse it: the shared front half of every verb
/// (Python `_model_for`, minus the arg handling which stays in the verb layer).
pub fn load(cli_exe: &Path, sch_path: &Path) -> Result<Netlist, NetlistError> {
    sniff_schematic(sch_path)?;
    let xml = export_netlist(cli_exe, sch_path, "kicadxml")?;
    parse_kicadxml(&xml).map_err(|e| NetlistError::new("parse_failed", e, "kicad-cli wrote a netlist the bridge could not parse; report this with the schematic attached."))
}

/// Raw export result for `rawFormat`: the netlist text capped at RAW_TEXT_CAP chars.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RawNetlist {
    pub format: String,
    /// Python reports len(text), a character count, under the key `bytes`.
    pub chars: usize,
    pub truncated: bool,
    pub text: String,
}

pub fn export_raw(cli_exe: &Path, sch_path: &Path, fmt: &str) -> Result<RawNetlist, NetlistError> {
    if !RAW_FORMATS.contains(&fmt) {
        return Err(NetlistError::new("bad_arg", format!("unsupported rawFormat: {fmt}"), format!("allowed: {}", RAW_FORMATS.join(", "))));
    }
    sniff_schematic(sch_path)?;
    let text = match export_netlist(cli_exe, sch_path, fmt) {
        Ok(t) => t,
        Err(mut e) => {
            if e.code == "cli_failed" {
                e.error = e.error.replace("kicad-cli netlist export failed", "raw netlist export failed");
            }
            return Err(e);
        }
    };
    let chars = text.chars().count();
    let truncated = chars > RAW_TEXT_CAP;
    let text = if truncated { text.chars().take(RAW_TEXT_CAP).collect() } else { text };
    Ok(RawNetlist { format: fmt.to_string(), chars, truncated, text })
}

// ---------------------------------------------------------------------------
// Tests: no kicad-cli needed
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {

    #[test]
    fn review_inputs_drop_kicads_unconnected_nets_and_map_pin_types() {
        let m = Netlist { components: vec![], nets: vec![
            Net { name: "/VCC".into(), code: "1".into(), nodes: vec![Node { reference: "U1".into(), pin: "8".into(), pinfunction: "VCC".into(), pintype: "power_in".into() }, Node { reference: "C1".into(), pin: "1".into(), pinfunction: "".into(), pintype: "passive".into() }] },
            Net { name: "unconnected-(U1-NC-Pad3)".into(), code: "2".into(), nodes: vec![Node { reference: "U1".into(), pin: "3".into(), pinfunction: "NC".into(), pintype: "input+no_connect".into() }] },
            Net { name: "unconnected-(U1-IN-Pad2)".into(), code: "3".into(), nodes: vec![Node { reference: "U1".into(), pin: "2".into(), pinfunction: "IN".into(), pintype: "tri_state".into() }] },
        ] };
        let (parts, nets) = review_inputs(&m);
        assert_eq!(nets["nets"]["VCC"], serde_json::json!(["U1.8", "C1.1"]));
        assert_eq!(nets["nets"].as_object().unwrap().len(), 1, "KiCad's unconnected-(...) nets are not nets");
        let u1 = parts["components"].as_array().unwrap().iter().find(|c| c["designator"] == "U1").unwrap().clone();
        let ty = |n: &str| u1["pins"].as_array().unwrap().iter().find(|p| p["number"] == n).unwrap()["electrical"].clone();
        assert_eq!((ty("8"), ty("3"), ty("2")), (serde_json::json!("power"), serde_json::json!("not_connected"), serde_json::json!("hiz")));
    }
    use super::*;

    /// Hand-written in the shape kicad-cli 8/9/10 emit for --format kicadxml.
    const SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<export version="E">
  <design>
    <source>C:/Users/john/proj/board.kicad_sch</source>
    <date>2026-09-11</date>
    <tool>Eeschema 10.0.5</tool>
    <sheet number="1" name="/" tstamps="/">
      <title_block><title/><company/></title_block>
    </sheet>
  </design>
  <components>
    <comp ref="U1">
      <value>NE555</value>
      <footprint>Package_DIP:DIP-8_W7.62mm</footprint>
      <libsource lib="Timer" part="NE555P" description="Timer, 555, DIP-8"/>
      <property name="Sheetname" value=""/>
      <sheetpath names="/" tstamps="/"/>
      <tstamps>a1b2</tstamps>
    </comp>
    <comp ref="R1">
      <value>10k &amp; 1%</value>
      <footprint>Resistor_SMD:R_0603_1608Metric</footprint>
      <description>Pull-up</description>
      <libsource lib="Device" part="R" description="Resistor"/>
    </comp>
    <comp ref="R10">
      <value>1k</value>
      <footprint></footprint>
      <libsource lib="Device" part="R"/>
    </comp>
    <comp ref="TP1">
      <value>TestPoint</value>
    </comp>
  </components>
  <libparts>
    <libpart lib="Device" part="R">
      <description>Resistor (libpart, must not leak into comps)</description>
      <pins>
        <pin num="1" name="~" type="passive"/>
        <pin num="2" name="~" type="passive"/>
      </pins>
    </libpart>
  </libparts>
  <libraries>
    <library logical="Device"><uri>C:/KiCad/share/symbols/Device.kicad_sym</uri></library>
  </libraries>
  <nets>
    <net code="1" name="GND">
      <node ref="U1" pin="1" pinfunction="GND" pintype="power_in"/>
      <node ref="R10" pin="2" pintype="passive"/>
    </net>
    <net code="2" name="VCC">
      <node ref="U1" pin="8" pinfunction="VCC" pintype="power_in"/>
      <node ref="R1" pin="1" pintype="passive"/>
    </net>
    <net code="3" name="/Net-(R1-Pad2)">
      <node ref="R1" pin="2" pintype="passive"/>
      <node ref="U1" pin="2" pinfunction="TR" pintype="input"/>
      <node ref="U1" pin="6" pinfunction="THR" pintype="input"/>
    </net>
    <net code="4" name="/OUT">
      <node ref="U1" pin="3" pinfunction="Q" pintype="output"/>
      <node ref="R10" pin="1" pintype="passive"/>
      <node ref="TP1" pin="1" pintype="passive"/>
    </net>
    <net code="5" name="/RESET">
      <node ref="U1" pin="4" pinfunction="~{R}" pintype="input"/>
    </net>
  </nets>
</export>
"#;

    fn model() -> Netlist {
        parse_kicadxml(SAMPLE).expect("sample parses")
    }

    #[test]
    fn parses_components() {
        let m = model();
        let refs: Vec<&str> = m.components.iter().map(|c| c.reference.as_str()).collect();
        assert_eq!(refs, vec!["U1", "R1", "R10", "TP1"], "XML order kept, libparts ignored");
        let u1 = m.component("U1").unwrap();
        assert_eq!(u1.value, "NE555");
        assert_eq!(u1.footprint, "Package_DIP:DIP-8_W7.62mm");
        assert_eq!(u1.libsource, "Timer:NE555P");
        assert_eq!(u1.description, "Timer, 555, DIP-8", "description falls back to the libsource attribute");
        let r1 = m.component("R1").unwrap();
        assert_eq!(r1.value, "10k & 1%", "entities are unescaped");
        assert_eq!(r1.description, "Pull-up", "a description child wins over the libsource attribute");
        let r10 = m.component("R10").unwrap();
        assert_eq!(r10.footprint, "");
        assert_eq!(r10.description, "");
        let tp = m.component("TP1").unwrap();
        assert_eq!(tp.libsource, "", "no libsource means empty, not a stray colon");
    }

    #[test]
    fn review_parts_carry_their_fields_as_params() {
        let xml = r#"<export><components><comp ref="C1"><value>100n</value>
            <fields><field name="Datasheet">~</field><field name="LCSC">C1525</field><field name="Empty"/></fields></comp></components>
            <nets><net code="1" name="GND"><node ref="C1" pin="1" pintype="passive"/></net></nets></export>"#;
        let (parts, _) = review_inputs(&parse_kicadxml(xml).unwrap());
        assert_eq!(parts["components"][0]["params"], serde_json::json!([{"name": "LCSC", "value": "C1525"}]));
    }

    #[test]
    fn keeps_fields_and_flags_for_the_bom() {
        let xml = r#"<export><components><comp ref="C1"><value>100n</value><footprint>Capacitor_SMD:C_0402_1005Metric</footprint>
            <fields><field name="Footprint">Capacitor_SMD:C_0402_1005Metric</field><field name="MPN">GRM155R71C104KA88D</field><field name="LCSC">C1525</field><field name="Empty"/></fields>
            <libsource lib="Device" part="C" description="Unpolarized capacitor"/><property name="dnp"/><property name="Sheetname" value="Root"/></comp></components><nets/></export>"#;
        let c = parse_kicadxml(xml).unwrap().components.remove(0);
        assert!(c.fields.contains(&("MPN".to_string(), "GRM155R71C104KA88D".to_string())), "{:?}", c.fields);
        assert!(c.fields.contains(&("LCSC".to_string(), "C1525".to_string())));
        assert!(c.fields.contains(&("Empty".to_string(), String::new())));
        assert!(c.properties.iter().any(|(k, _)| k == "dnp"));
        assert!(c.properties.contains(&("Sheetname".to_string(), "Root".to_string())));
    }

    #[test]
    fn parses_nets_and_nodes() {
        let m = model();
        assert_eq!(m.nets.len(), 5);
        let gnd = &m.nets[0];
        assert_eq!(gnd.name, "GND");
        assert_eq!(gnd.code, "1");
        assert_eq!(gnd.nodes.len(), 2);
        assert_eq!(gnd.nodes[0], Node { reference: "U1".into(), pin: "1".into(), pinfunction: "GND".into(), pintype: "power_in".into() });
        assert_eq!(gnd.nodes[1].pinfunction, "", "missing attributes are empty strings");
        assert_eq!(m.nets[2].name, "/Net-(R1-Pad2)");
        assert_eq!(m.nets[4].nodes[0].pinfunction, "~{R}");
    }

    #[test]
    fn node_json_uses_ref_key() {
        let m = model();
        let v = serde_json::to_value(&m.nets[0].nodes[0]).unwrap();
        assert_eq!(v["ref"], "U1");
        assert_eq!(v["pin"], "1");
        assert!(v.get("reference").is_none());
    }

    #[test]
    fn rejects_garbage() {
        assert!(parse_kicadxml("<export><comp ref=\"U1\"").is_err());
        let empty = parse_kicadxml("<export/>").unwrap();
        assert!(empty.components.is_empty() && empty.nets.is_empty());
    }

    #[test]
    fn trace_exact_then_case_insensitive_then_candidates() {
        let m = model();
        match trace_net(&m, "GND") {
            TraceResult::Found(n) => assert_eq!(n.code, "1"),
            other => panic!("expected GND, got {other:?}"),
        }
        match trace_net(&m, "vcc") {
            TraceResult::Found(n) => assert_eq!(n.name, "VCC"),
            other => panic!("expected VCC via case-insensitive match, got {other:?}"),
        }
        match trace_net(&m, "net-") {
            TraceResult::Miss { candidates } => assert_eq!(candidates, vec!["/Net-(R1-Pad2)".to_string()]),
            other => panic!("expected a miss with candidates, got {other:?}"),
        }
        match trace_net(&m, "nope") {
            TraceResult::Miss { candidates } => assert!(candidates.is_empty()),
            other => panic!("expected an empty miss, got {other:?}"),
        }
    }

    #[test]
    fn find_connections_sorts_pins_and_lists_peers() {
        let m = model();
        let FindResult::Found { component, pins } = find_connections(&m, "U1") else { panic!("U1 exists") };
        assert_eq!(component.value, "NE555");
        let order: Vec<&str> = pins.iter().map(|p| p.pin.as_str()).collect();
        assert_eq!(order, vec!["1", "2", "3", "4", "6", "8"]);
        let out = pins.iter().find(|p| p.pin == "3").unwrap();
        assert_eq!(out.net, "/OUT");
        assert_eq!(out.pintype, "output");
        assert_eq!(out.connected_to, vec![
            Peer { reference: "R10".into(), pin: "1".into(), pinfunction: "".into() },
            Peer { reference: "TP1".into(), pin: "1".into(), pinfunction: "".into() },
        ]);
        let reset = pins.iter().find(|p| p.pin == "4").unwrap();
        assert!(reset.connected_to.is_empty());
        // Pin sort is (len, text) so "10" would sort after "9": check with R10's pins.
        let FindResult::Found { pins, .. } = find_connections(&m, "R10") else { panic!() };
        assert_eq!(pins.iter().map(|p| p.pin.as_str()).collect::<Vec<_>>(), vec!["1", "2"]);
    }

    #[test]
    fn find_connections_miss_candidates() {
        let m = model();
        match find_connections(&m, "u1") {
            FindResult::Miss { candidates } => assert_eq!(candidates, vec!["U1".to_string()], "case-insensitive match first"),
            _ => panic!("u1 is not an exact reference"),
        }
        match find_connections(&m, "R7") {
            FindResult::Miss { candidates } => assert_eq!(candidates, vec!["R1".to_string(), "R10".to_string()], "same first letter, sorted"),
            _ => panic!(),
        }
        match find_connections(&m, "X9") {
            FindResult::Miss { candidates } => assert!(candidates.is_empty()),
            _ => panic!(),
        }
    }

    #[test]
    fn analyze_overview() {
        let m = model();
        let a = analyze(&m);
        assert_eq!(a.component_count, 4);
        assert_eq!(a.net_count, 5);
        assert_eq!(a.suspected_unconnected.len(), 1);
        assert_eq!(a.suspected_unconnected[0].net, "/RESET");
        assert_eq!(a.suspected_unconnected[0].node.reference, "U1");
        assert_eq!(a.power_nets.iter().map(|p| p.net.as_str()).collect::<Vec<_>>(), vec!["GND", "VCC"]);
        assert_eq!(a.highest_fanout_nets[0].node_count, 3);
        assert_eq!(a.highest_fanout_nets.iter().map(|n| n.net.as_str()).collect::<Vec<_>>(), vec!["/Net-(R1-Pad2)", "/OUT", "GND", "VCC", "/RESET"], "stable sort keeps netlist order among ties");
        assert_eq!(a.most_connected_components[0], ComponentFanout { reference: "U1".into(), connected_pins: 6 });
        assert_eq!(a.most_connected_components.iter().map(|c| c.reference.as_str()).collect::<Vec<_>>(), vec!["U1", "R10", "R1", "TP1"]);
        let v = serde_json::to_value(&a).unwrap();
        assert!(v.get("suspectedUnconnected").is_some() && v.get("mostConnectedComponents").is_some());
        assert_eq!(v["mostConnectedComponents"][0]["ref"], "U1");
        assert_eq!(v["mostConnectedComponents"][0]["connectedPins"], 6);
    }

    #[test]
    fn sniff_reads_version_and_rejects_wrong_type() {
        assert_eq!(file_version("(kicad_sch (version 20250114) (generator \"eeschema\")"), Some(20250114));
        assert_eq!(file_version("(kicad_sch (generator \"eeschema\")"), None);
        let dir = std::env::temp_dir();
        let sch = dir.join(format!("adom-netlist-test-{}.kicad_sch", std::process::id()));
        std::fs::write(&sch, "(kicad_sch (version 20231120) (generator \"eeschema\"))").unwrap();
        assert_eq!(sniff_schematic(&sch).unwrap(), Some(20231120));
        std::fs::write(&sch, "(kicad_pcb (version 20240108))").unwrap();
        let e = sniff_schematic(&sch).unwrap_err();
        assert_eq!(e.code, "wrong_file_type");
        assert!(e.hint.contains("kicad_pcb"));
        let _ = std::fs::remove_file(&sch);
        let e = sniff_schematic(&dir.join("adom-netlist-does-not-exist.kicad_sch")).unwrap_err();
        assert_eq!(e.code, "file_not_found");
        assert!(RAW_FORMATS.windows(2).all(|w| w[0] < w[1]), "RAW_FORMATS is sorted, matching Python's allowed list");
    }
}