app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
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 & 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");
}
}