← Commit history
rust/Cargo.lock+265−1
@@ -2,12 +2,40 @@ # It is not intended for manual editing. version = 4 +[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"+ [[package]] name = "ascii" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]]+name = "base64"+version = "0.23.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"++[[package]]+name = "bytes"+version = "1.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"++[[package]]+name = "cc"+version = "1.4.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"+dependencies = [+ "find-msvc-tools",+ "shlex",+]+ [[package]] name = "cfg-if" version = "1.0.4"@@ -20,6 +48,59 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" +[[package]]+name = "crc32fast"+version = "1.5.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"+dependencies = [+ "cfg-if",+]++[[package]]+name = "find-msvc-tools"+version = "0.1.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"++[[package]]+name = "flate2"+version = "1.1.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"+dependencies = [+ "crc32fast",+ "miniz_oxide",+ "zlib-rs",+]++[[package]]+name = "getrandom"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"+dependencies = [+ "cfg-if",+ "libc",+ "wasi",+]++[[package]]+name = "http"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"+dependencies = [+ "bytes",+ "itoa",+]++[[package]]+name = "httparse"+version = "1.10.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"+ [[package]] name = "httpdate" version = "1.0.3"@@ -48,8 +129,10 @@ name = "kicad-core" version = "1.0.0-alpha.2" dependencies = [  "kicad-platform",+ "quick-xml",  "serde",  "serde_json",+ "ureq", ]  [[package]]@@ -61,6 +144,12 @@ dependencies = [  "winreg", ] +[[package]]+name = "libc"+version = "0.2.189"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"+ [[package]] name = "log" version = "0.4.34"@@ -73,6 +162,28 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]]+name = "miniz_oxide"+version = "0.9.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "percent-encoding"+version = "2.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"+ [[package]] name = "proc-macro2" version = "1.0.107"@@ -82,6 +193,15 @@ dependencies = [  "unicode-ident", ] +[[package]]+name = "quick-xml"+version = "0.37.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"+dependencies = [+ "memchr",+]+ [[package]] name = "quote" version = "1.0.47"@@ -91,6 +211,55 @@ dependencies = [  "proc-macro2", ] +[[package]]+name = "ring"+version = "0.17.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"+dependencies = [+ "cc",+ "cfg-if",+ "getrandom",+ "libc",+ "untrusted",+ "windows-sys 0.52.0",+]++[[package]]+name = "rustls"+version = "0.23.44"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"+dependencies = [+ "log",+ "once_cell",+ "ring",+ "rustls-pki-types",+ "rustls-webpki",+ "subtle",+ "zeroize",+]++[[package]]+name = "rustls-pki-types"+version = "1.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"+dependencies = [+ "zeroize",+]++[[package]]+name = "rustls-webpki"+version = "0.103.15"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"+dependencies = [+ "ring",+ "rustls-pki-types",+ "untrusted",+]+ [[package]] name = "serde" version = "1.0.229"@@ -134,6 +303,24 @@ dependencies = [  "zmij", ] +[[package]]+name = "shlex"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"++[[package]]+name = "simd-adler32"+version = "0.3.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"++[[package]]+name = "subtle"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"+ [[package]] name = "syn" version = "3.0.5"@@ -163,6 +350,71 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]]+name = "untrusted"+version = "0.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"++[[package]]+name = "ureq"+version = "3.4.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b"+dependencies = [+ "base64",+ "flate2",+ "log",+ "percent-encoding",+ "rustls",+ "rustls-pki-types",+ "ureq-proto",+ "utf8-zero",+ "webpki-roots",+]++[[package]]+name = "ureq-proto"+version = "0.6.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10"+dependencies = [+ "base64",+ "http",+ "httparse",+ "log",+]++[[package]]+name = "utf8-zero"+version = "0.8.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"++[[package]]+name = "wasi"+version = "0.11.1+wasi-snapshot-preview1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"++[[package]]+name = "webpki-roots"+version = "1.0.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"+dependencies = [+ "rustls-pki-types",+]++[[package]]+name = "windows-sys"+version = "0.52.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"+dependencies = [+ "windows-targets",+]+ [[package]] name = "windows-sys" version = "0.59.0"@@ -243,9 +495,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" dependencies = [  "cfg-if",- "windows-sys",+ "windows-sys 0.59.0", ] +[[package]]+name = "zeroize"+version = "1.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"++[[package]]+name = "zlib-rs"+version = "0.6.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"+ [[package]] name = "zmij" version = "1.0.23"
rust/README.md+1−1
@@ -17,7 +17,7 @@ A verb that needs something the current OS does not implement yet returns `kicad ``` cargo build --release                                   # host OS cargo build --release --target x86_64-pc-windows-gnu    # Windows, from Linux (mingw-w64 installed)-cargo check --target aarch64-apple-darwin               # type-check the macOS file from anywhere+cargo check --target aarch64-apple-darwin --no-default-features   # type-check the macOS file from a box without a darwin C toolchain (net feature off) ```  Per-OS dependencies go in `crates/kicad-platform/Cargo.toml` under the matching `[target.'cfg(...)'.dependencies]` table. Nothing OS-specific belongs in the other two crates.
rust/crates/kicad-bridge/src/catalog.rs+12−3
@@ -161,13 +161,22 @@ pub static VERBS: &[Verb] = &[     }, ]; +/// Every verb: the core table plus each ported group's table.+pub fn all() -> Vec<&'static Verb> {+    let mut v: Vec<&'static Verb> = VERBS.iter().collect();+    for g in crate::groups::GROUPS {+        v.extend((g.verbs)().iter());+    }+    v+}+ pub fn find(name: &str) -> Option<&'static Verb> {-    VERBS.iter().find(|v| v.name == name)+    all().into_iter().find(|v| v.name == name) }  pub fn describe(version: &str) -> Value {-    let verbs: Vec<Value> = VERBS-        .iter()+    let verbs: Vec<Value> = all()+        .into_iter()         .map(|v| {             json!({                 "name": v.name,
rust/crates/kicad-bridge/src/groups.rsadded+31
@@ -0,0 +1,31 @@+//! Registry of verb groups. Each group is one file (`verbs_<group>.rs`) that exposes+//! `pub static VERBS: &[Verb]` and `pub fn dispatch(state, command, args) -> Option<Value>`.+//! To add a group: write the file, add `mod verbs_x;` in main.rs, and one line here.++use serde_json::Value;++use crate::catalog::Verb;+use crate::util::State;++pub struct Group {+    #[allow(dead_code)]+    pub name: &'static str,+    pub verbs: fn() -> &'static [Verb],+    pub dispatch: fn(&mut State, &str, &Value) -> Option<Value>,+}++pub static GROUPS: &[Group] = &[+    Group { name: "netlist", verbs: || crate::verbs_netlist::VERBS, dispatch: crate::verbs_netlist::dispatch },+    Group { name: "lint", verbs: || crate::verbs_lint::VERBS, dispatch: crate::verbs_lint::dispatch },+    Group { name: "libraries", verbs: || crate::verbs_libraries::VERBS, dispatch: crate::verbs_libraries::dispatch },+    Group { name: "maint", verbs: || crate::verbs_maint::VERBS, dispatch: crate::verbs_maint::dispatch },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    for g in GROUPS {+        if let Some(v) = (g.dispatch)(state, command, args) {+            return Some(v);+        }+    }+    None+}
rust/crates/kicad-bridge/src/main.rs+7−1
@@ -8,7 +8,13 @@ //! come from one table.  mod catalog;+mod groups;+mod util; mod verbs;+mod verbs_libraries;+mod verbs_lint;+mod verbs_maint;+mod verbs_netlist;  use std::time::Instant; @@ -83,7 +89,7 @@ fn main() {     let actual_port = server.server_addr().to_ip().map(|a| a.port()).unwrap_or(port);     eprintln!("[kicad-bridge] v{VERSION} listening on {host}:{actual_port}"); -    let mut state = verbs::State::new();+    let mut state = util::State::new();      for mut req in server.incoming_requests() {         request_count += 1;
rust/crates/kicad-bridge/src/util.rsadded+59
@@ -0,0 +1,59 @@+//! Shared helpers for every verb group: detection cache, arg access, error shape,+//! the no-KiCad alternatives block.++use std::time::Duration;++use serde_json::{json, Value};++use kicad_core::detect::{self, KicadInfo};++pub struct State {+    info: Option<(std::time::Instant, KicadInfo)>,+}++impl State {+    pub fn new() -> Self {+        State { info: None }+    }+    /// Detection is cached for 60 s; a verb that needs fresh truth calls refresh().+    pub fn kicad_info(&mut self) -> KicadInfo {+        if let Some((t, ref i)) = self.info {+            if t.elapsed() < Duration::from_secs(60) {+                return i.clone();+            }+        }+        self.refresh()+    }+    pub fn refresh(&mut self) -> KicadInfo {+        let i = detect::detect();+        self.info = Some((std::time::Instant::now(), i.clone()));+        i+    }+}++pub fn fail(code: &str, msg: impl Into<String>, hint: impl Into<String>) -> Value {+    json!({"success": false, "errorCode": code, "error": msg.into(), "_hint": hint.into()})+}++pub fn arg_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {+    args.get(key).and_then(Value::as_str).filter(|s| !s.trim().is_empty())+}++/// First non-empty string among several arg names (Python contract uses `filePath`;+/// the port also accepts the more specific names).+pub fn arg_any<'a>(args: &'a Value, keys: &[&str]) -> Option<&'a str> {+    keys.iter().find_map(|k| arg_str(args, k))+}++pub fn norm(p: &std::path::Path) -> String {+    p.to_string_lossy().replace('\\', "/")+}++pub fn alternatives() -> Value {+    json!([+        {"backend": "service", "what": "The shared service-kicad container runs DRC, ERC, exports, renders and library lookups with no desktop KiCad.", "hint": "Load the service-kicad skill; pass backend:\"service\" to the headless verbs."},+        {"backend": "container", "what": "Install kicad-cli into your own container.", "hint": "The service-kicad skill has the install; then the headless verbs run locally."},+        {"backend": "target", "what": "Run KiCad on another box (a second laptop, a conference room machine, a Grok Computer session) and address it by name through Adom Bridge.", "hint": "adom-bridge targets lists the boxes; add --target <name> to every call."}+    ])+}+
rust/crates/kicad-bridge/src/verbs.rs+10−52
@@ -6,66 +6,24 @@ use std::time::Duration; use serde_json::{json, Value};  use crate::catalog;+use crate::util::*; use kicad_core::cli;-use kicad_core::detect::{self, KicadInfo};+use kicad_core::detect; use kicad_platform::native; use crate::Caller; -pub struct State {-    info: Option<(std::time::Instant, KicadInfo)>,-}--impl State {-    pub fn new() -> Self {-        State { info: None }-    }-    /// Detection is cached for 60 s; a verb that needs fresh truth calls refresh().-    pub fn kicad_info(&mut self) -> KicadInfo {-        if let Some((t, ref i)) = self.info {-            if t.elapsed() < Duration::from_secs(60) {-                return i.clone();-            }-        }-        self.refresh()-    }-    pub fn refresh(&mut self) -> KicadInfo {-        let i = detect::detect();-        self.info = Some((std::time::Instant::now(), i.clone()));-        i-    }-}--fn fail(code: &str, msg: impl Into<String>, hint: impl Into<String>) -> Value {-    json!({"success": false, "errorCode": code, "error": msg.into(), "_hint": hint.into()})-}--fn arg_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {-    args.get(key).and_then(Value::as_str).filter(|s| !s.trim().is_empty())-}--/// First non-empty string among several arg names (Python contract uses `filePath`;-/// the port also accepts the more specific names).-fn arg_any<'a>(args: &'a Value, keys: &[&str]) -> Option<&'a str> {-    keys.iter().find_map(|k| arg_str(args, k))-}--fn norm(p: &std::path::Path) -> String {-    p.to_string_lossy().replace('\\', "/")-}--fn alternatives() -> Value {-    json!([-        {"backend": "service", "what": "The shared service-kicad container runs DRC, ERC, exports, renders and library lookups with no desktop KiCad.", "hint": "Load the service-kicad skill; pass backend:\"service\" to the headless verbs."},-        {"backend": "container", "what": "Install kicad-cli into your own container.", "hint": "The service-kicad skill has the install; then the headless verbs run locally."},-        {"backend": "target", "what": "Run KiCad on another box (a second laptop, a conference room machine, a Grok Computer session) and address it by name through Adom Bridge.", "hint": "adom-bridge targets lists the boxes; add --target <name> to every call."}-    ])-}- pub fn dispatch(state: &mut State, command: &str, args: &Value, _caller: &Caller) -> Value {     let Some(verb) = catalog::find(command) else {-        let names: Vec<&str> = catalog::VERBS.iter().map(|v| v.name).collect();+        let names: Vec<&str> = catalog::all().iter().map(|v| v.name).collect();         return fail("unknown_verb", format!("Unknown command: {command}"), format!("Valid commands: {}", names.join(", ")));     };+    // Verb groups ported later live in their own files; each returns Some when it owns the verb.+    if let Some(mut out) = crate::groups::dispatch(state, command, args) {+        if let Some(o) = out.as_object_mut() {+            o.entry("mechanism").or_insert(json!(verb.mechanism.as_str()));+        }+        return out;+    }     let mut out = match command {         "kicad_describe" => catalog::describe(crate::VERSION),         "kicad_status" => status(state),
rust/crates/kicad-bridge/src/verbs_libraries.rsadded+336
@@ -0,0 +1,336 @@+//! Verb group "libraries": symbol and footprint inventories, the Adom library+//! status, the bridge's own settings, and the fab design rules from the wiki.+//! Same arg names and response shapes as the Python handlers+//! (`handlers/list_symbols.py`, `handlers/install_footprint.py::handle_list_footprints`,+//! `server.py::_handle_adom_library_status`, `handlers/settings.py`,+//! `handlers/design_rules.py`).++use std::path::{Path, PathBuf};+use std::time::Duration;++use serde_json::{json, Map, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::{alternatives, arg_any, arg_str, fail, norm, State};+use kicad_core::design_rules;+use kicad_core::detect::KicadInfo;+use kicad_core::libraries::{self, LibCtx, LibTable};+use kicad_core::settings;+use kicad_platform::native;++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_list_symbols",+        summary: "Names in a registered symbol library, with KiCad's own inventory.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 90,+        input: "{\"libraryName\"?: \"Adom\", \"libraryPath\"?: \"C:/.../x.kicad_sym\"}",+        example: "kicad_list_symbols {\"libraryName\":\"Device\"}",+        hint: "kicad_list_symbols {\"libraryName\":\"Adom\"} (default Adom; or libraryPath). Use a returned name as symbolName; the file name is often an MPN, not the symbol name.",+        related: &["kicad_show_library", "kicad_adom_library_status", "kicad_lint_library"],+        pitfalls: &["kicadParser.symbols is what the editor can show; symbols is the bridge's reader", "a stock library (Device, Connector) resolves under <install>/share/kicad/symbols; a Configure Paths override wins"],+    },+    Verb {+        name: "kicad_list_footprints",+        summary: "What footprints actually exist, per library: the fp-lib-table (URIs expanded, stock libraries included) plus unregistered .pretty folders in the user dir.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"library\"?: \"Resistor_SMD\"}",+        example: "kicad_list_footprints {\"library\":\"Resistor_SMD\"}",+        hint: "Read-only. library (or libraryName) filters to one library, case-insensitive; omit it for every registered .pretty.",+        related: &["kicad_show_footprint", "kicad_install_footprint", "kicad_adom_library_status"],+        pitfalls: &["The footprint NAME is the .kicad_mod file stem, which is what kicad_show_footprint expects; it is often NOT the name declared inside the file"],+    },+    Verb {+        name: "kicad_adom_library_status",+        summary: "Report Adom library install state across KiCad versions.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 90,+        input: "{}", example: "kicad_adom_library_status {}",+        hint: "Read-only; shows which Adom libraries are registered under which KiCad version.",+        related: &["kicad_install_library", "kicad_list_versions"],+        pitfalls: &["creates and registers an empty Adom.kicad_sym when none exists (same as the Python)", "split:true means KiCad reads registeredPath and the bridge's default path is a different file"],+    },+    Verb {+        name: "kicad_get_settings",+        summary: "Read the bridge's user-visible settings (overlay badges, ...).",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 5,+        input: "{}", example: "kicad_get_settings {}",+        hint: "kicad_get_settings {} returns settings+defaults. Currently: overlayBadges (Adom favicon stamped on bridge-driven KiCad taskbar buttons, default on).",+        related: &["kicad_set_settings"], pitfalls: &[],+    },+    Verb {+        name: "kicad_set_settings",+        summary: "Change bridge settings; applies immediately (e.g. overlay badges stamped/cleared on live windows).",+        mechanism: Mechanism::File, risk: "config", timeout_sec: 10,+        input: "{\"overlayBadges\"?: bool, \"activityIndicator\"?: \"progress|flash|both|none\", \"restoreDefaults\"?: bool}",+        example: "kicad_set_settings {\"overlayBadges\": false}",+        hint: "kicad_set_settings {\"overlayBadges\": false}.",+        related: &["kicad_get_settings"],+        pitfalls: &["live re-badging of open KiCad windows arrives with the window verbs (phase 3); the setting persists now"],+    },+    Verb {+        name: "kicad_list_design_rules",+        summary: "List the fabs / processes / layer counts on adom/pcb-design-rules.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 30,+        input: "{\"fab\"?: \"jlcpcb\"}", example: "kicad_list_design_rules {\"fab\":\"jlcpcb\"}",+        hint: "kicad_list_design_rules {\"fab\":\"jlcpcb\"} (fab optional). Rows come from the wiki page at call time; InstaPCB is the house baseline.",+        related: &["kicad_set_design_rules", "kicad_lint_board"],+        pitfalls: &["needs wiki.adom.inc reachable from the desktop"],+    },+    Verb {+        name: "kicad_set_design_rules",+        summary: "Stage a fab's .kicad_dru next to a board and run DRC with it.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 300,+        input: "{\"boardPath\": \"C:/.../x.kicad_pcb\", \"fab\"?: \"adom-instapcb\", \"layers\"?: 2, \"process\"?: \"standard\", \"druPath\"?: \"C:/.../x.kicad_dru\", \"runDrc\"?: true, \"keepExisting\"?: false, \"schematicParity\"?: false}",+        example: "kicad_set_design_rules {\"fab\":\"adom-instapcb\",\"layers\":2,\"boardPath\":\"C:/.../x.kicad_pcb\"}",+        hint: "kicad_set_design_rules {\"fab\":\"adom-instapcb\",\"layers\":2,\"boardPath\":\"C:/.../x.kicad_pcb\"}. layers defaults to the board's copper count; druPath overrides the wiki file; runDrc:false to only stage.",+        related: &["kicad_list_design_rules", "kicad_lint_board", "kicad_run_drc"],+        pitfalls: &["replaces an existing <stem>.kicad_dru (backed up next to it); pass keepExisting:true to refuse instead", "an open PCB editor applies the file on its next DRC run"],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_list_symbols" => list_symbols(state, args),+        "kicad_list_footprints" => list_footprints(state, args),+        "kicad_adom_library_status" => adom_library_status(state),+        "kicad_get_settings" => get_settings(),+        "kicad_set_settings" => settings::set_settings(args),+        "kicad_list_design_rules" => design_rules::list_design_rules(arg_str(args, "fab")),+        "kicad_set_design_rules" => set_design_rules(state, args),+        _ => return None,+    })+}++/// The library context for the KiCad this bridge drives: version, install root,+/// config and user dirs from the platform, Configure Paths overrides loaded.+fn lib_ctx(info: &KicadInfo) -> LibCtx {+    let inst = info.primary();+    let version = inst.map(|i| i.version.clone()).unwrap_or_default();+    let mut ctx = LibCtx {+        version: version.clone(),+        base_dir: inst.map(|i| i.base_dir.clone()).filter(|b| !b.is_empty()),+        config_dir: if version.is_empty() { None } else { native().config_dir(&version) },+        user_dir: if version.is_empty() { None } else { native().user_dir(&version) },+        kicad_cli: inst.map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()),+        env_overrides: Default::default(),+    };+    ctx.load_env_overrides();+    ctx+}++const NOT_INSTALLED_HINT: &str = "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.";++fn list_symbols(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        let mut v = fail("not_installed", "KiCad not installed", NOT_INSTALLED_HINT);+        v["alternatives"] = alternatives();+        return v;+    }+    let ctx = lib_ctx(&info);+    let lib_name = arg_any(args, &["libraryName", "library"]).map(str::trim).unwrap_or("Adom").to_string();+    let explicit = arg_str(args, "libraryPath").map(str::trim).unwrap_or("");++    let (lib_path, how): (Option<PathBuf>, String) = if !explicit.is_empty() {+        (Some(PathBuf::from(explicit)), "libraryPath argument".into())+    } else if lib_name.eq_ignore_ascii_case("adom") {+        match libraries::adom_paths(&ctx) {+            Some((_t, p)) => (Some(p), "sym-lib-table row 'Adom' (the file KiCad reads)".into()),+            None => return json!({"success": false, "error": "Could not resolve the Adom library: KiCad config/user directory not found"}),+        }+    } else {+        let table = match ctx.sym_lib_table() {+            Some(t) => match LibTable::parse_file(&t) {+                Ok(t) => Some(t),+                Err(e) => return json!({"success": false, "error": format!("Could not read sym-lib-table: {e}")}),+            },+            None => None,+        };+        let entries = table.as_ref().map(|t| t.entries.as_slice()).unwrap_or(&[]);+        match entries.iter().find(|e| e.name == lib_name) {+            None => {+                let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).take(60).collect();+                return json!({"success": false, "errorCode": "library_not_registered",+                    "error": format!("No '{lib_name}' row in sym-lib-table"), "registeredLibraries": names,+                    "_hint": "Pick one of registeredLibraries, or pass libraryPath for a file that is not registered."});+            }+            Some(row) => {+                let p = libraries::expand_kicad_uri(&row.uri, &ctx);+                (p.map(PathBuf::from), format!("sym-lib-table row '{lib_name}' ({})", row.uri))+            }+        }+    };++    let shown = lib_path.as_ref().map(|p| norm(p)).unwrap_or_else(|| "None".into());+    let Some(lib_path) = lib_path.filter(|p| p.exists()) else {+        return json!({"success": false, "errorCode": "library_file_missing",+            "error": format!("Library file not found: {shown}"),+            "resolvedVia": how});+    };++    let names = libraries::list_symbols(&lib_path);+    let mut out = json!({"success": true, "libraryName": lib_name, "libraryPath": norm(&lib_path), "resolvedVia": how,+                         "symbolCount": names.len(), "symbols": names});+    let v = libraries::kicad_parser_verdict(ctx.kicad_cli.as_deref(), &lib_path, Duration::from_secs(60));+    let available = v["available"].as_bool().unwrap_or(false);+    let ok = v["ok"].as_bool().unwrap_or(false);+    if available && !ok {+        out["_hint"] = json!(format!(+            "KiCad REFUSES this library ('{}'); the Symbol Editor shows it empty regardless of the {} name(s) above. kicad_adom_library_heal {{\"mode\":\"prune\"}} names the refused symbol(s).",+            v["error"].as_str().unwrap_or(""), names.len()+        ));+    } else if available {+        let ks: Vec<&str> = v["symbols"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();+        let missing: Vec<&str> = names.iter().map(String::as_str).filter(|n| !ks.contains(n)).collect();+        if !missing.is_empty() {+            out["_hint"] = json!(format!(+                "KiCad lists {} symbol(s) but not {:?}; trust kicadParser.symbols for what the editor can show.",+                ks.len(), missing.iter().take(5).collect::<Vec<_>>()+            ));+        }+    }+    out["kicadParser"] = v;+    if out.get("_hint").is_none() {+        out["_hint"] = json!("Use one of `symbols` as symbolName for kicad_show_library; the file name is often an MPN and not the symbol name (wiki #62).");+    }+    out+}++fn list_footprints(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    let ctx = lib_ctx(&info);+    let want = arg_any(args, &["library", "libraryName"]).map(str::trim).filter(|s| !s.is_empty());+    if ctx.user_dir.is_none() {+        return json!({"success": false, "error": "KiCad user directory not found",+                      "_hint": "Launch KiCad once so it creates Documents/KiCad/<ver>/."});+    }+    let (libs, total) = libraries::list_footprints(&ctx, want);+    let mut libraries_obj = Map::new();+    for (name, p) in &libs {+        libraries_obj.insert(name.clone(), json!({"path": p.path, "count": p.footprints.len(), "footprints": p.footprints}));+    }+    let hint = match want {+        Some(w) if libs.is_empty() => format!(+            "No library named '{w}' in this KiCad's fp-lib-table or user footprints dir. Install one with kicad_install_footprint, which creates Adom.pretty on first use."+        ),+        _ => "The footprint NAME is the .kicad_mod file stem, which is what kicad_show_footprint expects - it is often NOT the name declared inside the file. If a show_footprint failed, check the name here before assuming the editor is at fault.".to_string(),+    };+    json!({"success": true, "libraryCount": libs.len(), "footprintCount": total, "libraries": libraries_obj, "_hint": hint})+}++fn adom_library_status(state: &mut State) -> Value {+    let info = state.kicad_info();+    let ctx = lib_ctx(&info);+    let mut result = libraries::ensure_adom_library(&ctx, info.installed);+    let rep = libraries::library_split_report(&ctx);+    if let (Some(dst), Some(src)) = (result.as_object_mut(), rep.as_object()) {+        for (k, v) in src {+            dst.insert(k.clone(), v.clone());+        }+    }+    if result["split"].as_bool().unwrap_or(false) {+        result["_hint"] = json!("SPLIT: the registered Adom library and the bridge's default path are different files. KiCad reads registeredPath; symbols in bridgeDefaultPath are invisible to it. Installs write to registeredPath from 0.9.274.");+    }+    // wiki #71: the count above is the BRIDGE's reader. Ask KiCad too.+    if let Some(reg) = result["registeredPath"].as_str().map(str::to_string) {+        let v = libraries::kicad_parser_verdict(ctx.kicad_cli.as_deref(), Path::new(&reg), Duration::from_secs(60));+        let available = v["available"].as_bool().unwrap_or(false);+        let ok = v["ok"].as_bool().unwrap_or(false);+        let kicad_count = v["symbols"].as_array().map(|a| a.len()).unwrap_or(0);+        if available && !ok {+            result["_hint"] = json!(format!(+                "KICAD REFUSES THIS LIBRARY: kicad-cli says '{}'. The Symbol Editor will list the Adom library EMPTY no matter how many symbols the bridge counts (registeredSymbols={}). One malformed symbol does this to the whole file. kicad_adom_library_heal {{\"mode\":\"prune\",\"dryRun\":true}} names the culprit(s); without dryRun it rebuilds the file from the symbols KiCad accepts.",+                v["error"].as_str().unwrap_or(""), py_value(&result["registeredSymbols"])+            ));+        } else if available && ok {+            let bridge_count = result["registeredSymbols"].as_u64();+            if let Some(bc) = bridge_count {+                if bc as usize != kicad_count {+                    result["_hint"] = json!(format!(+                        "KiCad loads {kicad_count} symbol(s) from the registered file; the bridge's reader counts {bc}. Trust kicadParser.symbols."+                    ));+                }+            }+        }+        result["kicadParser"] = v;+    }+    result["success"] = json!(true);+    result+}++/// `None` for null, else the JSON text (the Python hint interpolates the raw value).+fn py_value(v: &Value) -> String {+    match v {+        Value::Null => "None".into(),+        other => other.to_string(),+    }+}++fn get_settings() -> Value {+    json!({+        "success": true,+        "settings": settings::get_settings(),+        "defaults": settings::defaults_map(),+        "path": norm(&settings::settings_path()),+        "_hint": "kicad_set_settings {\"overlayBadges\": false} to change; changes apply immediately (overlay badges are stamped/cleared on live windows).",+    })+}++fn set_design_rules(state: &mut State, args: &Value) -> Value {+    let board = arg_any(args, &["boardPath", "filePath"]).map(str::trim).unwrap_or("");+    if board.is_empty() {+        return json!({"success": false, "error": "boardPath is required",+            "_hint": "Pass {\"boardPath\": \"<absolute path to the .kicad_pcb>\"}. The rules file is staged next to it as <stem>.kicad_dru, which KiCad loads automatically."});+    }+    let board_path = PathBuf::from(board);+    let is_pcb = board_path.extension().map(|e| e.to_string_lossy().eq_ignore_ascii_case("kicad_pcb")).unwrap_or(false);+    if !board_path.exists() || !is_pcb {+        return json!({"success": false, "error": format!("Board not found or not a .kicad_pcb: {board}"),+            "_hint": "The path must exist on the desktop. Use push_file first if the board lives in the container."});+    }+    let staged = match design_rules::stage_rules(&board_path, args) {+        Ok(s) => s,+        Err(e) => return e,+    };+    let dru_name = staged.dru_path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+    let mut out = json!({+        "success": true,+        "druPath": norm(&staged.dru_path),+        "backup": staged.backup.as_ref().map(|b| norm(b)),+        "source": staged.source,+        "ruleCount": staged.rule_names.len(),+        "rules": staged.rule_names.iter().take(40).collect::<Vec<_>>(),+    });++    let run_drc = args.get("runDrc").map(|v| !matches!(v, Value::Bool(false) | Value::Null)).unwrap_or(true);+    if run_drc {+        let info = state.kicad_info();+        let cli = info.primary().map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file());+        let parity = args.get("schematicParity").and_then(Value::as_bool).unwrap_or(false);+        let drc = design_rules::drc_with_rules(cli.as_deref(), &board_path, parity);+        let data = drc.get("data").cloned().unwrap_or(json!({}));+        let summary = data.get("summary").cloned().unwrap_or(json!({}));+        let viols: Vec<Value> = data.get("violations").and_then(Value::as_array).cloned().unwrap_or_default();+        // The violations that cite one of the fab's rules by name are the ones this+        // verb exists for; list them apart from the board's own errors.+        let from_rules: Vec<&Value> = viols.iter().filter(|v| v.get("description").and_then(Value::as_str).map(|d| d.contains("(rule '")).unwrap_or(false)).collect();+        let other = viols.len() - from_rules.len();+        out["drcSummary"] = summary.clone();+        out["fabRuleViolations"] = json!(from_rules.iter().take(40).map(|v| json!({"type": v.get("type"), "severity": v.get("severity"), "description": v.get("description")})).collect::<Vec<_>>());+        out["otherViolations"] = json!(other);+        let drc_failed = drc.get("success") == Some(&json!(false));+        if drc_failed && viols.is_empty() {+            let why = drc.get("error").or_else(|| drc.get("_hint")).and_then(Value::as_str).unwrap_or("").to_string();+            out["_hint"] = json!(format!("Rules staged as {dru_name}, but DRC did not run: {why}"));+        } else {+            let totals = if summary.as_object().map(|o| !o.is_empty()).unwrap_or(false) { summary.to_string() } else { "n/a".into() };+            out["_hint"] = json!(format!(+                "Rules staged as {dru_name} (KiCad loads <board stem>.kicad_dru automatically; an open PCB editor applies it on its next DRC run). DRC with these rules: {} violation(s) cite the fab's rules, {other} other; totals {totals}.",+                from_rules.len()+            ));+        }+        out["drc"] = drc;+    } else {+        out["_hint"] = json!(format!("Rules staged as {dru_name}. Run kicad_lint_board or kicad_run_drc on the board to apply them."));+    }+    out+}
rust/crates/kicad-bridge/src/verbs_lint.rsadded+176
@@ -0,0 +1,176 @@+//! Verb group "lint": kicad_lint_board, kicad_lint_schematic, kicad_lint_library,+//! kicad_format_upgrade. Ported from `handlers/kicad_cli_lint.py`; the logic lives in+//! `kicad_core::lint`, this file is arg validation, install lookup and the catalog text.++use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::lint;++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_lint_board",+        summary: "Lint a .kicad_pcb (DRC pre-check) via kicad-cli.",+        mechanism: Mechanism::Cli, risk: "read", timeout_sec: 130,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"schematicParity\"?: bool (default true)}",+        example: "kicad_lint_board {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "Fast DRC pre-check on a board file; use before opening in the GUI. Magic-byte sniff, then `pcb drc --severity-all --all-track-errors --schematic-parity`; _hint says proceed, fix, or upgrade.",+        related: &["kicad_run_drc", "kicad_open_board"],+        pitfalls: &[+            "needs kicad-cli (KiCad >= 7)",+            "schematic parity needs the matching .kicad_sch in the project dir; if kicad-cli fails on that, retry with schematicParity:false",+            "a board saved by a newer KiCad than kicad-cli refuses to load; stderr names both versions",+        ],+    },+    Verb {+        name: "kicad_lint_schematic",+        summary: "Lint a .kicad_sch (ERC pre-check) via kicad-cli.",+        mechanism: Mechanism::Cli, risk: "read", timeout_sec: 130,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\"}",+        example: "kicad_lint_schematic {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\"}",+        hint: "Fast ERC pre-check on a schematic file. Magic-byte sniff, then `sch erc --severity-all`; _hint says proceed or fix. Pass the root sheet.",+        related: &["kicad_run_erc", "kicad_open_schematic"],+        pitfalls: &["needs kicad-cli (KiCad >= 7)", "a .kicad_pcb passed here is refused by the sniff with the verb to use instead"],+    },+    Verb {+        name: "kicad_lint_library",+        summary: "Lint a symbol/footprint library via kicad-cli.",+        mechanism: Mechanism::Cli, risk: "read", timeout_sec: 130,+        input: "{\"libraryPath\": \"C:/.../x.kicad_sym | x.kicad_mod | Lib.pretty | dir\"} (filePath accepted too)",+        example: "kicad_lint_library {\"libraryPath\":\"C:/Users/john/libs/Adom.kicad_sym\"}",+        hint: "Checks a library for structural issues before you ship it. Structural check plus inventory by the bridge's reader, then KiCad's OWN verdict on every .kicad_sym (`kicad-cli sym export svg`, read-only) in kicadParser; ok:false when KiCad refuses a file the structural check passed.",+        related: &["kicad_install_library", "kicad_format_upgrade", "kicad_adom_library_heal"],+        pitfalls: &[+            "needs kicad-cli (KiCad >= 7); without it the hint says KiCad was NOT consulted and only the structural check stands",+            "kicadParser.symbols is what the editor can show; symbols is the bridge's reader",+            "one malformed symbol makes KiCad list the WHOLE library empty; do not install a file KiCad refuses",+        ],+    },+    Verb {+        name: "kicad_format_upgrade",+        summary: "Upgrade a file to the current KiCad file format.",+        mechanism: Mechanism::Cli, risk: "write", timeout_sec: 180,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb | .kicad_sch | .kicad_sym | .kicad_mod | Lib.pretty\", \"kind\"?: \"pcb|sch|sym|fp\", \"force\"?: bool}",+        example: "kicad_format_upgrade {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "Rewrites an older .kicad_pcb/.kicad_sch to the installed KiCad's format. kind is auto-detected from the extension or directory contents; force:true resaves a file that is already current.",+        related: &["kicad_open_board", "kicad_open_schematic", "kicad_lint_library"],+        pitfalls: &[+            "it MODIFIES the file: back it up first; the change is not reversible in-place",+            "kicad-cli has no --dry-run; call kicad_lint_library or kicad_lint_board first for inspection without a write",+            "any open KiCad window with the file loaded keeps the OLD in-memory state; close and reopen it",+        ],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_lint_board" => lint_document(state, args, lint::PCB),+        "kicad_lint_schematic" => lint_document(state, args, lint::SCH),+        "kicad_lint_library" => lint_library(state, args),+        "kicad_format_upgrade" => format_upgrade(state, args),+        _ => return None,+    })+}++fn not_installed() -> Value {+    let mut v = fail("not_installed", "KiCad not installed", "Ask the user to install KiCad from https://www.kicad.org/download/.");+    v["alternatives"] = alternatives();+    v+}++/// The Python `_cli_exe_or_error`: kicad-cli must exist on disk, not just be recorded.+fn cli_or_error(state: &mut State) -> Result<PathBuf, Value> {+    let info = state.kicad_info();+    if !info.installed {+        return Err(not_installed());+    }+    let inst = info.primary().expect("installed implies an install");+    lint::cli_exe(&inst.kicad_cli, &inst.bin_dir).ok_or_else(|| {+        fail(+            "kicad_cli_missing",+            "kicad-cli not found",+            "Run kicad_list_versions to check the install. If kicad_cli_exe is missing, the user should reinstall KiCad; kicad-cli ships in the same Bin dir as kicad.exe.",+        )+    })+}++/// kicad_lint_board and kicad_lint_schematic: installed, filePath, sniff, kicad-cli, run.+fn lint_document(state: &mut State, args: &Value, expected: &str) -> Value {+    let is_pcb = expected == lint::PCB;+    if !state.kicad_info().installed {+        return not_installed();+    }+    let keys: &[&str] = if is_pcb { &["filePath", "boardPath"] } else { &["filePath", "schematicPath"] };+    let Some(file_path) = arg_any(args, keys) else {+        return fail(+            "missing_arg",+            "filePath is required",+            format!("Pass {{\"filePath\":\"<absolute path to a .{expected}>\"}}."),+        );+    };+    let path = Path::new(file_path);+    let sniff = lint::file_format_sniff(path, expected);+    if sniff.get("ok") != Some(&json!(true)) {+        // Python: {"success": False, **sniff}. `error` mirrors `_error` for callers that read the usual key.+        let mut v = sniff;+        v["success"] = json!(false);+        v["errorCode"] = json!(if v.get("actual").is_some() { "wrong_file_type" } else { "file_not_found" });+        v["error"] = v.get("_error").cloned().unwrap_or(json!("file check failed"));+        return v;+    }+    let cli = match cli_or_error(state) {+        Ok(c) => c,+        Err(e) => return e,+    };+    let file_version = sniff.get("fileVersion").and_then(Value::as_u64);+    let schematic_parity = args.get("schematicParity").and_then(Value::as_bool).unwrap_or(true);+    lint::lint_document(&cli, path, expected, file_version, schematic_parity, lint::CLI_TIMEOUT)+}++/// kicad_lint_library: does not require KiCad; the structural check runs regardless and+/// KiCad's verdict is added when kicad-cli is on this box.+fn lint_library(state: &mut State, args: &Value) -> Value {+    let Some(file_path) = arg_any(args, &["libraryPath", "filePath"]) else {+        return fail(+            "missing_arg",+            "libraryPath is required",+            "Pass {\"libraryPath\":\"<absolute path to .kicad_sym / .kicad_mod / .pretty dir>\"}.",+        );+    };+    let cli = state.kicad_info().primary().and_then(|i| lint::cli_exe(&i.kicad_cli, &i.bin_dir));+    lint::lint_library(Path::new(file_path), cli.as_deref(), lint::VERDICT_TIMEOUT)+}++/// kicad_format_upgrade: installed, filePath, kind (given or detected), kicad-cli, run.+fn format_upgrade(state: &mut State, args: &Value) -> Value {+    if !state.kicad_info().installed {+        return fail("not_installed", "KiCad not installed", "Ask the user to install KiCad.");+    }+    let Some(file_path) = arg_str(args, "filePath") else {+        return fail("missing_arg", "filePath is required", "Pass {\"filePath\":\"<path>\", \"kind\":\"pcb|sch|sym|fp\"}.");+    };+    let path = Path::new(file_path);+    if !path.exists() {+        return fail("file_not_found", format!("path not found: {file_path}"), "Verify the path; use push_file from Docker if needed.");+    }+    let kind = match arg_str(args, "kind") {+        Some(k) => lint::normalize_kind(k),+        None => lint::detect_kind(path),+    };+    let Some(kind) = kind else {+        return fail(+            "bad_kind",+            format!("could not auto-detect kind for {file_path}"),+            "Pass kind explicitly: \"pcb\" / \"sch\" / \"sym\" / \"fp\".",+        );+    };+    let cli = match cli_or_error(state) {+        Ok(c) => c,+        Err(e) => return e,+    };+    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);+    lint::format_upgrade(&cli, path, kind, force, lint::CLI_TIMEOUT)+}
rust/crates/kicad-bridge/src/verbs_maint.rsadded+100
@@ -0,0 +1,100 @@+//! Verb group "maint": kicad_model_check, kicad_pcm_list, kicad_pcm_install,+//! kicad_pcm_uninstall, kicad_check_for_updates. Ported from handlers/model_check.py,+//! handlers/pcm.py and the read-only half of handlers/upgrade.py; the installer itself+//! (kicad_upgrade) is a later phase.+use std::time::Duration;++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::State;+use kicad_core::model_check::{self, Ctx};+use kicad_core::{pcm, updates};++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_model_check",+        summary: "Read-only 3D model diagnostics for a footprint or a whole board: which (model ...) references exist, where KiCad looks, whether each file exists and holds geometry.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"library\": \"Adom\", \"footprintName\": \"TMF8829-1A\", \"projectDir\"?: \"C:/...\"} or {\"boardPath\": \"C:/x/y.kicad_pcb\", \"limit\"?: 50}",+        example: "kicad_model_check {\"library\":\"Adom\",\"footprintName\":\"TMF8829-1A\"}",+        hint: "kicad_model_check {\"library\":\"Adom\",\"footprintName\":\"TMF8829-1A\"} or {\"boardPath\":\"C:/x/y.kicad_pcb\"}. Warnings carry codes (no_model_reference, not_found_relative, unresolved_variable, file_missing, unreadable, not_step_or_vrml, empty_geometry, hidden, implausible_scale). renderVerified is always false: the viewer frame is the render proof.",+        related: &["kicad_show_3d_chip", "kicad_show_3d_board", "kicad_install_footprint"],+        pitfalls: &[+            "a bare relative model path is never looked up next to the .kicad_mod: KiCad tries the project dir and the 3D search paths",+            "a resolvable file is not a rendered body; read the 3D frame",+        ],+    },+    Verb {+        name: "kicad_pcm_list",+        summary: "Packages KiCad's PCM considers installed on this machine (read-only).",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 30,+        input: "{}",+        example: "kicad_pcm_list {}",+        hint: "Reads installed_packages.json and checks the 3rdparty directories exist.",+        related: &["kicad_pcm_install"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_pcm_install",+        summary: "Install a package from a KiCad PCM repository on disk, exactly as the Plugin and Content Manager would (no dialog).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 900,+        input: "{\"repositoryUrl\": \"https://.../repository.json\", \"identifier\": \"inc.adom.kicad.basic-parts\", \"version\"?: \"1.0.0\", \"libPrefix\"?: \"PCM_\", \"force\"?: bool}",+        example: "kicad_pcm_install {\"repositoryUrl\":\"https://wiki.adom.inc/adom/kicad-pcm/repository.json\",\"identifier\":\"inc.adom.kicad.basic-parts\"}",+        hint: "repositoryUrl (a repository.json) + identifier; optional version. Extracts to $KICAD_3RD_PARTY/<folder>/<pkgid>/ and records installed_packages.json. KiCad registers PCM_<lib> rows itself on next load, so a running KiCad must restart (reported, never done).",+        related: &["kicad_pcm_list", "kicad_pcm_uninstall", "kicad_install_library_bundle"],+        pitfalls: &[+            "a RUNNING KiCad does not see the new libraries until restarted",+            "download is sha256-verified against the repository; a mismatch refuses",+            "the native build does not detect a running KiCad yet: kicadRunning is null and restartNeeded is always true",+        ],+    },+    Verb {+        name: "kicad_pcm_uninstall",+        summary: "Remove a PCM-installed package: its 3rdparty directories and its record.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"identifier\": \"inc.adom.kicad.basic-parts\"}",+        example: "kicad_pcm_uninstall {\"identifier\":\"inc.adom.kicad.basic-parts\"}",+        hint: "KiCad drops the PCM_ library rows on next load when pcm.lib_auto_remove is on.",+        related: &["kicad_pcm_install", "kicad_pcm_list"],+        pitfalls: &["restart a running KiCad afterwards"],+    },+    Verb {+        name: "kicad_check_for_updates",+        summary: "Read-only: installed KiCad vs latest stable.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 45,+        input: "{\"probeInstaller\"?: bool}",+        example: "kicad_check_for_updates {}",+        hint: "Compares the installed KiCad to the latest stable on downloads.kicad.org; flags pre-IPC installs. probeInstaller:true adds downloadBytes (a HEAD on the installer URL).",+        related: &["kicad_upgrade", "kicad_list_versions"],+        pitfalls: &["read-only: it does NOT install; run kicad_upgrade to apply"],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    let out = match command {+        "kicad_model_check" => {+            let ctx = Ctx::from_info(&state.kicad_info());+            model_check::handle(&ctx, args)+        }+        "kicad_pcm_list" => pcm::list(&Ctx::from_info(&state.kicad_info())),+        "kicad_pcm_install" => {+            let ctx = Ctx::from_info(&state.kicad_info());+            pcm::install(&ctx, args, &pcm::http_fetch)+        }+        "kicad_pcm_uninstall" => pcm::uninstall(&Ctx::from_info(&state.kicad_info()), args),+        "kicad_check_for_updates" => {+            let info = state.kicad_info();+            let current = info.primary().map(|i| if i.cli_version.is_empty() { i.version.clone() } else { i.cli_version.clone() });+            let probe = args.get("probeInstaller").and_then(Value::as_bool).unwrap_or(false);+            let mut out = updates::check_for_updates(current.as_deref(), &updates::http_fetch, None);+            if probe {+                let url = out.get("downloadUrl").and_then(Value::as_str).unwrap_or("").to_string();+                out["downloadBytes"] = json!(updates::remote_installer_size(&url, Duration::from_secs(15)));+            }+            out+        }+        _ => return None,+    };+    Some(out)+}
rust/crates/kicad-bridge/src/verbs_netlist.rsadded+236
@@ -0,0 +1,236 @@+//! Verb group "netlist": kicad_extract_netlist, kicad_trace_net, kicad_find_connections,+//! kicad_analyze_connections. Port of `handlers/netlist.py`: one kicad-cli kicadxml+//! export, parsed once, queried by pure functions in `kicad_core::netlist`.+//! Response keys match the Python handlers exactly (flat, no data{} wrapper there).++use std::path::PathBuf;++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::netlist::{self, FindResult, Netlist, NetlistError, TraceResult};++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_extract_netlist",+        summary: "Extract the full netlist from a schematic: every component and every net with its nodes (read-only, via kicad-cli).",+        mechanism: Mechanism::Cli, risk: "read", timeout_sec: 150,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"includeNodes\"?: bool, \"rawFormat\"?: \"spice|orcadpcb2|kicadsexpr|kicadxml|cadstar|allegro|pads|spicemodel\"}",+        example: "kicad_extract_netlist {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"includeNodes\":false}",+        hint: "Absolute .kicad_sch path. includeNodes:false returns net names/sizes only, use it first on big schematics. rawFormat returns the netlist FILE text in another tool's format instead of the parsed model.",+        related: &["kicad_trace_net", "kicad_find_connections", "kicad_analyze_connections"],+        pitfalls: &["needs kicad-cli (KiCad >= 7)", "hierarchical designs: pass the ROOT sheet"],+    },+    Verb {+        name: "kicad_trace_net",+        summary: "Every pin on one named net (read-only).",+        mechanism: Mechanism::Cli, risk: "read", timeout_sec: 150,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"netName\": \"GND\"}",+        example: "kicad_trace_net {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"netName\":\"GND\"}",+        hint: "filePath + netName. A miss returns candidates instead of an empty success; unlabeled nets are auto-named like /Net-(R1-Pad1).",+        related: &["kicad_extract_netlist", "kicad_find_connections"],+        pitfalls: &["needs kicad-cli (KiCad >= 7)"],+    },+    Verb {+        name: "kicad_find_connections",+        summary: "Everything one component connects to, pin by pin, with peer pins per net (read-only).",+        mechanism: Mechanism::Cli, risk: "read", timeout_sec: 150,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"reference\": \"U1\"}",+        example: "kicad_find_connections {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"reference\":\"U1\"}",+        hint: "filePath + reference (U1, R5). Pins absent from the answer are in no net.",+        related: &["kicad_trace_net", "kicad_analyze_connections"],+        pitfalls: &["needs kicad-cli (KiCad >= 7)"],+    },+    Verb {+        name: "kicad_analyze_connections",+        summary: "Connectivity overview: suspected-unconnected pins, power nets, highest-fanout nets/components (read-only).",+        mechanism: Mechanism::Cli, risk: "read", timeout_sec: 150,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\"}",+        example: "kicad_analyze_connections {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\"}",+        hint: "Heuristic from the netlist alone; kicad_run_erc is the authoritative check and knows no-connect flags.",+        related: &["kicad_extract_netlist", "kicad_run_erc"],+        pitfalls: &["needs kicad-cli (KiCad >= 7)", "single-node nets can be intentional test points"],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_extract_netlist" => extract_netlist(state, args),+        "kicad_trace_net" => trace_net(state, args),+        "kicad_find_connections" => find_connections(state, args),+        "kicad_analyze_connections" => analyze_connections(state, args),+        _ => return None,+    })+}++fn err_value(e: NetlistError) -> Value {+    let mut v = fail(e.code, e.error, e.hint);+    if let Some(s) = e.stderr {+        v["stderr"] = json!(s);+    }+    if e.code == "not_installed" {+        v["alternatives"] = alternatives();+    }+    v+}++/// Python `_cli_exe_or_error`: the primary install's kicad-cli, or the not-found error.+fn cli_exe(state: &mut State) -> Result<PathBuf, Value> {+    let info = state.kicad_info();+    let cli = info.primary().map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file());+    match cli {+        Some(p) => Ok(p),+        None => {+            let mut v = fail(+                "not_installed",+                "kicad-cli not found",+                "Run kicad_list_versions to check the install. If kicad_cli_exe is missing, the user should reinstall KiCad: kicad-cli ships in the same Bin dir as kicad.exe.",+            );+            v["alternatives"] = alternatives();+            Err(v)+        }+    }+}++/// Python `_model_for`: validate filePath, sniff, locate kicad-cli, export, parse.+fn model_for(state: &mut State, args: &Value) -> Result<(Netlist, String), Value> {+    let Some(file_path) = arg_str(args, "filePath").map(str::trim) else {+        return Err(fail("missing_arg", "filePath is required", "Absolute Windows path to a .kicad_sch."));+    };+    let sch = PathBuf::from(file_path);+    netlist::sniff_schematic(&sch).map_err(err_value)?;+    let cli = cli_exe(state)?;+    let model = netlist::load(&cli, &sch).map_err(err_value)?;+    Ok((model, norm(&sch)))+}++fn components_json(model: &Netlist) -> Value {+    let mut m = serde_json::Map::new();+    for c in &model.components {+        m.insert(c.reference.clone(), json!(c));+    }+    Value::Object(m)+}++fn extract_netlist(state: &mut State, args: &Value) -> Value {+    // Raw-export mode: the netlist FILE in another tool's format, text returned as is.+    if let Some(raw_fmt) = arg_str(args, "rawFormat").map(str::trim) {+        if !netlist::RAW_FORMATS.contains(&raw_fmt) {+            let mut v = fail("bad_arg", format!("unsupported rawFormat: {raw_fmt}"), "Pick one of `allowed`.");+            v["allowed"] = json!(netlist::RAW_FORMATS);+            return v;+        }+        let Some(file_path) = arg_str(args, "filePath").map(str::trim) else {+            return fail("missing_arg", "filePath is required", "Absolute Windows path to a .kicad_sch.");+        };+        let sch = PathBuf::from(file_path);+        if let Err(e) = netlist::sniff_schematic(&sch) {+            return err_value(e);+        }+        let cli = match cli_exe(state) {+            Ok(c) => c,+            Err(v) => return v,+        };+        return match netlist::export_raw(&cli, &sch, raw_fmt) {+            Ok(raw) => json!({+                "success": true,+                "rawFormat": raw.format,+                "bytes": raw.chars,+                "truncated": raw.truncated,+                "netlist": raw.text,+                "_hint": "Raw netlist text in the requested format; parsed queries ignore rawFormat.",+            }),+            Err(e) => err_value(e),+        };+    }++    let (model, path) = match model_for(state, args) {+        Ok(m) => m,+        Err(v) => return v,+    };+    let include_nodes = args.get("includeNodes").and_then(Value::as_bool).unwrap_or(true);+    let nets: Vec<Value> = if include_nodes {+        model.nets.iter().map(|n| json!(n)).collect()+    } else {+        model.nets.iter().map(|n| json!({"name": n.name, "code": n.code, "nodeCount": n.nodes.len()})).collect()+    };+    json!({+        "success": true,+        "componentCount": model.components.len(),+        "netCount": model.nets.len(),+        "components": components_json(&model),+        "nets": nets,+        "path": path,+        "_hint": "Read-only. For one net use kicad_trace_net; for one component use kicad_find_connections. Both re-derive from the same export.",+    })+}++fn trace_net(state: &mut State, args: &Value) -> Value {+    let Some(net_name) = arg_str(args, "netName").map(str::trim) else {+        return fail("missing_arg", "netName is required", "kicad_extract_netlist {includeNodes:false} lists every net name.");+    };+    let (model, path) = match model_for(state, args) {+        Ok(m) => m,+        Err(v) => return v,+    };+    match netlist::trace_net(&model, net_name) {+        TraceResult::Found(n) => json!({+            "success": true,+            "net": n.name,+            "code": n.code,+            "nodeCount": n.nodes.len(),+            "nodes": n.nodes,+            "path": path,+            "_hint": "Read-only trace from the schematic netlist.",+        }),+        TraceResult::Miss { candidates } => {+            let mut v = fail(+                "net_not_found",+                format!("net not found: {net_name}"),+                "No net has that exact name. `candidates` holds substring matches; KiCad auto-names unlabeled nets like '/Net-(R1-Pad1)'.",+            );+            v["candidates"] = json!(candidates);+            v["netCount"] = json!(model.nets.len());+            v+        }+    }+}++fn find_connections(state: &mut State, args: &Value) -> Value {+    let Some(reference) = arg_str(args, "reference").map(str::trim) else {+        return fail("missing_arg", "reference is required", "A component reference like U1, R5, J2.");+    };+    let (model, path) = match model_for(state, args) {+        Ok(m) => m,+        Err(v) => return v,+    };+    match netlist::find_connections(&model, reference) {+        FindResult::Found { component, pins } => json!({+            "success": true,+            "reference": reference,+            "component": component,+            "pinCount": pins.len(),+            "pins": pins,+            "path": path,+            "_hint": "Read-only. Pins absent here are not in any net (check analyze_connections.unconnected).",+        }),+        FindResult::Miss { candidates } => {+            let mut v = fail("component_not_found", format!("component not found: {reference}"), "kicad_extract_netlist lists every reference.");+            v["candidates"] = json!(candidates);+            v+        }+    }+}++fn analyze_connections(state: &mut State, args: &Value) -> Value {+    let (model, path) = match model_for(state, args) {+        Ok(m) => m,+        Err(v) => return v,+    };+    let mut v = json!(netlist::analyze(&model));+    v["success"] = json!(true);+    v["path"] = json!(path);+    v["_hint"] = json!("Heuristic overview from the netlist alone. suspectedUnconnected lists nets with a single node: usually a missed wire, sometimes an intentional test point or no-connect; kicad_run_erc is the authoritative check and knows about no-connect flags.");+    v+}
rust/crates/kicad-core/Cargo.toml+10
@@ -10,3 +10,13 @@ publish.workspace = true serde = { workspace = true } serde_json = { workspace = true } kicad-platform = { path = "../kicad-platform" }+# XML netlists from kicad-cli, HTTP for wiki fetches (rustls: no OS CA store needed).+quick-xml = "0.37"+ureq = { version = "3", default-features = false, features = ["rustls", "gzip"], optional = true }++[features]+# net: HTTPS fetches (wiki design rules, PCM, KiCad update check) through ureq + rustls.+# rustls pulls in ring, whose C build needs a target C compiler, so a cross type-check+# from a box without one runs with --no-default-features.+default = ["net"]+net = ["dep:ureq"]
rust/crates/kicad-core/src/design_rules.rsadded+436
@@ -0,0 +1,436 @@+//! Design rules by fab (wiki adom/kicad-bridge#73), ported from `handlers/design_rules.py`.+//!+//! The canonical per-vendor rules live on the wiki page adom/pcb-design-rules, which+//! renders one KiCad custom-rules file per (vendor, process, layer count) plus a+//! machine-readable index at `.../render/rules/index.json`. A rule change is a wiki+//! publish, not a bridge release: this module never carries rule values. It resolves+//! (fab, layers) to the page's file, stages it next to the board as+//! `<board stem>.kicad_dru` (the name KiCad loads automatically) and optionally+//! runs DRC so the caller sees the fab's limits applied.+//!+//! InstaPCB is the house baseline and is listed first.++use std::path::{Path, PathBuf};+use std::time::Duration;++use serde_json::{json, Value};++pub const PAGE: &str = "https://wiki.adom.inc/adom/pcb-design-rules/render/";+pub const INDEX_URL: &str = "https://wiki.adom.inc/adom/pcb-design-rules/render/rules/index.json";+pub const HOUSE_SLUG: &str = "adom-instapcb";+const TIMEOUT: Duration = Duration::from_secs(20);+const USER_AGENT: &str = "adom-bridge-kicad design_rules";++/// GET a URL from the wiki (rustls with bundled roots: no OS CA store involved).+#[cfg(not(feature = "net"))]+pub fn fetch(_url: &str) -> Result<Vec<u8>, String> {+    Err("the net feature is off in this build: no HTTPS client".into())+}++#[cfg(feature = "net")]+pub fn fetch(url: &str) -> Result<Vec<u8>, String> {+    let config = ureq::Agent::config_builder().timeout_global(Some(TIMEOUT)).http_status_as_error(true).build();+    let agent = ureq::Agent::new_with_config(config);+    let mut resp = agent.get(url).header("User-Agent", USER_AGENT).call().map_err(|e| e.to_string())?;+    resp.body_mut().with_config().limit(50 * 1024 * 1024).read_to_vec().map_err(|e| e.to_string())+}++/// The page's vendors, house baseline first, then the page's own order.+pub fn index() -> Result<Vec<Value>, String> {+    let bytes = fetch(INDEX_URL)?;+    let data: Value = serde_json::from_slice(&bytes).map_err(|e| format!("index is not JSON: {e}"))?;+    let mut vendors: Vec<Value> = data.get("vendors").and_then(Value::as_array).cloned().unwrap_or_default();+    vendors.sort_by_key(|v| if v.get("vendorSlug").and_then(Value::as_str) == Some(HOUSE_SLUG) { 0 } else { 1 });+    Ok(vendors)+}++/// Lowercase alphanumerics only, so "JLC PCB" and "jlcpcb" compare equal.+pub fn norm_key(s: &str) -> String {+    s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()+}++fn s(v: &Value, key: &str) -> String {+    v.get(key).and_then(Value::as_str).unwrap_or("").to_string()+}++/// Accept the slug, the display name, or a distinctive substring of either.+pub fn match_fab<'a>(vendors: &'a [Value], fab: &str) -> Vec<&'a Value> {+    let want = norm_key(fab);+    if want.is_empty() {+        return Vec::new();+    }+    let exact: Vec<&Value> = vendors.iter().filter(|v| norm_key(&s(v, "vendorSlug")) == want || norm_key(&s(v, "vendor")) == want).collect();+    if !exact.is_empty() {+        return exact;+    }+    vendors.iter().filter(|v| norm_key(&s(v, "vendorSlug")).contains(&want) || norm_key(&s(v, "vendor")).contains(&want)).collect()+}++/// One `rules` row of `kicad_list_design_rules`.+pub fn rule_row(v: &Value) -> Value {+    let files = v.get("files").cloned().unwrap_or(Value::Null);+    let file = |k: &str| files.get(k).and_then(Value::as_str).filter(|x| !x.is_empty()).map(|x| format!("{PAGE}{x}"));+    json!({+        "fab": v.get("vendor"),+        "slug": v.get("vendorSlug"),+        "process": v.get("process"),+        "layers": v.get("layerCount"),+        "kicadDruUrl": file("kicadDru"),+        "boardSetupUrl": file("kicadBoardSetup"),+        "house": v.get("vendorSlug").and_then(Value::as_str) == Some(HOUSE_SLUG),+    })+}++/// Python `handle_list_design_rules`.+pub fn list_design_rules(fab: Option<&str>) -> Value {+    let vendors = match index() {+        Ok(v) => v,+        Err(e) => return json!({"success": false, "error": format!("Could not read {INDEX_URL}: {e}"),+            "_hint": "The rules page is on wiki.adom.inc; check the desktop's network, then retry. Nothing is cached locally on purpose so a rule change is a wiki publish."}),+    };+    let picked: Vec<&Value> = match fab {+        Some(f) if !f.is_empty() => match_fab(&vendors, f),+        _ => vendors.iter().collect(),+    };+    let rows: Vec<Value> = picked.iter().map(|v| rule_row(v)).collect();+    json!({"success": true, "count": rows.len(), "rules": rows, "indexUrl": INDEX_URL,+           "_hint": "Pick a row and call kicad_set_design_rules {\"fab\": <slug>, \"layers\": <n>, \"boardPath\": <.kicad_pcb>}. adom-instapcb is the house baseline. Values come from the wiki page at call time, so do not copy them into code."})+}++/// What `set_design_rules` staged, before any DRC.+pub struct Staged {+    pub dru_path: PathBuf,+    pub backup: Option<PathBuf>,+    pub source: Value,+    pub rule_names: Vec<String>,+}++/// Resolve the rules file (local `druPath` or the wiki), validate it, back up and+/// replace `<board stem>.kicad_dru`. Returns the failure response on `Err`.+pub fn stage_rules(board_path: &Path, args: &Value) -> Result<Staged, Value> {+    let dru_target = board_path.with_extension("kicad_dru");+    let override_path = args.get("druPath").and_then(Value::as_str).map(str::trim).unwrap_or("");+    let (body, source): (Vec<u8>, Value) = if !override_path.is_empty() {+        let src = Path::new(override_path);+        if !src.exists() {+            return Err(json!({"success": false, "error": format!("druPath not found: {override_path}")}));+        }+        let body = std::fs::read(src).map_err(|e| json!({"success": false, "error": format!("cannot read druPath: {e}")}))?;+        (body, json!({"kind": "local", "path": crate::libraries::norm_path(src)}))+    } else {+        let vendors = index().map_err(|e| json!({"success": false, "error": format!("Could not read {INDEX_URL}: {e}"),+            "_hint": "Pass druPath with a local .kicad_dru to work offline, or retry when wiki.adom.inc is reachable."}))?;+        let fab = args.get("fab").and_then(Value::as_str).filter(|f| !f.is_empty()).unwrap_or(HOUSE_SLUG);+        let cands = match_fab(&vendors, fab);+        if cands.is_empty() {+            let mut known: Vec<String> = vendors.iter().map(|v| s(v, "vendorSlug")).collect();+            known.sort();+            known.dedup();+            return Err(json!({"success": false, "error": format!("No fab matches '{fab}'"), "errorCode": "fab_not_found",+                "knownFabs": known,+                "_hint": "Use one of knownFabs (kicad_list_design_rules shows names, processes and layer counts)."}));+        }+        let layers: i64 = match args.get("layers") {+            None | Some(Value::Null) => board_copper_layers(board_path).unwrap_or(2) as i64,+            Some(Value::Number(n)) => match n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)) {+                Some(i) => i,+                None => return Err(json!({"success": false, "error": format!("layers must be an integer, got {n}")})),+            },+            Some(Value::String(st)) => match st.trim().parse::<i64>() {+                Ok(i) => i,+                Err(_) => return Err(json!({"success": false, "error": format!("layers must be an integer, got '{st}'")})),+            },+            Some(other) => return Err(json!({"success": false, "error": format!("layers must be an integer, got {other}")})),+        };+        let process = args.get("process").and_then(Value::as_str).filter(|p| !p.is_empty());+        let pick: Vec<&Value> = cands+            .iter()+            .copied()+            .filter(|v| {+                let lc = v.get("layerCount").and_then(Value::as_i64).unwrap_or(0);+                lc == layers && process.map(|p| norm_key(&s(v, "process")) == norm_key(p)).unwrap_or(true)+            })+            .collect();+        let Some(v) = pick.first() else {+            let proc_txt = process.map(|p| format!(" {p}")).unwrap_or_default();+            return Err(json!({"success": false, "errorCode": "no_rules_for_layers",+                "error": format!("{} has no {layers}-layer{proc_txt} rules on the page", s(cands[0], "vendor")),+                "available": cands.iter().map(|c| json!({"process": c.get("process"), "layers": c.get("layerCount")})).collect::<Vec<_>>(),+                "_hint": "Pick one of `available`, or pass druPath with your own file."}));+        };+        let rel = v.get("files").and_then(|f| f.get("kicadDru")).and_then(Value::as_str).unwrap_or("");+        if rel.is_empty() {+            return Err(json!({"success": false, "error": format!("The page lists no KiCad file for {} {layers}L", s(v, "vendor"))}));+        }+        let url = format!("{PAGE}{rel}");+        let body = fetch(&url).map_err(|e| json!({"success": false, "error": format!("Could not download {url}: {e}")}))?;+        (body, json!({"kind": "wiki", "url": url, "fab": v.get("vendor"), "slug": v.get("vendorSlug"),+                      "process": v.get("process"), "layers": layers}))+    };++    let text = String::from_utf8_lossy(&body).to_string();+    let head: String = text.chars().take(200).collect();+    if !head.contains("(version") || !text.contains("(rule") {+        return Err(json!({"success": false, "errorCode": "not_a_kicad_dru",+            "error": "The rules file does not look like a KiCad custom-rules file ('(version N)' header and '(rule ...)' blocks)",+            "source": source, "head": text.chars().take(160).collect::<String>()}));+    }++    let mut backup = None;+    if dru_target.exists() {+        if args.get("keepExisting").map(truthy).unwrap_or(false) {+            return Err(json!({"success": false, "errorCode": "dru_exists",+                "error": format!("{} already exists and keepExisting is set", file_name(&dru_target)),+                "existing": crate::libraries::norm_path(&dru_target), "source": source,+                "_hint": "Drop keepExisting to replace it (the old file is backed up next to it)."}));+        }+        let bak = dru_target.with_file_name(format!("{}.bak-{}", file_name(&dru_target), timestamp()));+        std::fs::copy(&dru_target, &bak).map_err(|e| json!({"success": false, "error": format!("cannot back up {}: {e}", dru_target.display())}))?;+        backup = Some(bak);+    }+    std::fs::write(&dru_target, &body).map_err(|e| json!({"success": false, "error": format!("cannot write {}: {e}", dru_target.display())}))?;++    Ok(Staged { dru_path: dru_target, backup, source, rule_names: rule_names(&text) })+}++/// Names of the `(rule "NAME" ...)` blocks, in file order.+pub fn rule_names(text: &str) -> Vec<String> {+    text.lines()+        .filter_map(|ln| {+            let t = ln.trim();+            if t.starts_with("(rule ") && t.contains('"') {+                t.split('"').nth(1).map(str::to_string)+            } else {+                None+            }+        })+        .collect()+}++fn file_name(p: &Path) -> String {+    p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()+}++fn truthy(v: &Value) -> bool {+    match v {+        Value::Bool(b) => *b,+        Value::Null => false,+        Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),+        Value::String(s) => !s.is_empty(),+        Value::Array(a) => !a.is_empty(),+        Value::Object(o) => !o.is_empty(),+    }+}++/// Count copper layers from the board's `(layers ...)` block, cheaply (first 20000+/// chars). `(0 "F.Cu" signal)` rows with a signal/power/mixed/jumper kind count.+pub fn board_copper_layers(board_path: &Path) -> Option<u32> {+    let bytes = std::fs::read(board_path).ok()?;+    let text = String::from_utf8_lossy(&bytes);+    let head: String = text.chars().take(20000).collect();+    copper_layers_in_text(&head)+}++pub fn copper_layers_in_text(head: &str) -> Option<u32> {+    let i = head.find("(layers")?;+    let mut depth = 0i32;+    let mut end = head.len();+    for (j, ch) in head[i..].char_indices() {+        match ch {+            '(' => depth += 1,+            ')' => {+                depth -= 1;+                if depth == 0 {+                    end = i + j + 1;+                    break;+                }+            }+            _ => {}+        }+    }+    let block = &head[i..end];+    let parsed = crate::libraries::parse_sexp(block).ok()?;+    let mut n = 0u32;+    for row in parsed.as_list()?.iter().skip(1) {+        let Some(l) = row.as_list() else { continue };+        let num_ok = l.first().and_then(|a| a.as_atom()).map(|a| !a.is_empty() && a.bytes().all(|b| b.is_ascii_digit())).unwrap_or(false);+        let cu = l.get(1).and_then(|a| a.as_atom()).map(|a| a.ends_with(".Cu")).unwrap_or(false);+        let kind = l.get(2).and_then(|a| a.as_atom()).map(|k| matches!(k, "signal" | "power" | "mixed" | "jumper")).unwrap_or(false);+        if num_ok && cu && kind {+            n += 1;+        }+    }+    if n == 0 { None } else { Some(n) }+}++/// `YYYYmmdd-HHMMSS` in UTC (the Python used local time; the port has no timezone+/// database and the suffix only needs to be unique and sortable).+pub fn timestamp() -> String {+    let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;+    let days = secs.div_euclid(86400);+    let rem = secs.rem_euclid(86400);+    let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);+    // Civil-from-days (Howard Hinnant).+    let z = days + 719468;+    let era = z.div_euclid(146097);+    let doe = z.rem_euclid(146097);+    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;+    let y = yoe + era * 400;+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);+    let mp = (5 * doy + 2) / 153;+    let d = doy - (153 * mp + 2) / 5 + 1;+    let mo = if mp < 10 { mp + 3 } else { mp - 9 };+    let y = if mo <= 2 { y + 1 } else { y };+    format!("{y:04}{mo:02}{d:02}-{h:02}{m:02}{s:02}")+}++/// The DRC the Python runs after staging (`handle_lint_board`): `pcb drc --format json+/// --severity-all --all-track-errors [--schematic-parity]`, shaped as+/// `{success, output, data:{violations, summary}}` so the caller's post-processing+/// matches. `cli` is kicad-cli; None means KiCad is not installed.+pub fn drc_with_rules(cli: Option<&Path>, board_path: &Path, schematic_parity: bool) -> Value {+    let Some(cli) = cli else {+        return json!({"success": false, "error": "KiCad not installed",+                      "_hint": "Ask the user to install KiCad from https://www.kicad.org/download/."});+    };+    let report = std::env::temp_dir().join(format!("adom-kicad-drc-rules-{}.json", std::process::id()));+    let report_s = crate::libraries::norm_path(&report);+    let board_s = crate::libraries::norm_path(board_path);+    let mut argv: Vec<&str> = vec!["pcb", "drc", "--format", "json", "--severity-all", "--all-track-errors"];+    if schematic_parity {+        argv.push("--schematic-parity");+    }+    argv.extend(["--output", report_s.as_str(), board_s.as_str()]);+    let r = crate::cli::run(cli, &argv, Duration::from_secs(280));+    let parsed = std::fs::read_to_string(&report).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());+    let _ = std::fs::remove_file(&report);+    match parsed {+        Some(rep) => {+            let violations = crate::cli::flatten_violations(&rep);+            let errors = violations.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some("error")).count();+            let warnings = violations.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some("warning")).count();+            let info = violations.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some("info")).count();+            let total = violations.len();+            json!({+                "success": true,+                "output": format!("Lint complete: {errors} error(s), {warnings} warning(s), {total} total"),+                "data": {+                    "violations": violations,+                    "summary": {"total": total, "errors": errors, "warnings": warnings, "info": info},+                    "schematicParityChecked": schematic_parity,+                    "source_file": board_s,+                },+                "cliExitCode": r.exit_code,+                "cliElapsedMs": r.elapsed_ms,+            })+        }+        None => json!({+            "success": false,+            "error": if r.timed_out { "kicad-cli timed out".to_string() } else { format!("kicad-cli failed: {}", r.stderr.trim()) },+            "stdout": r.stdout,+            "stderr": r.stderr,+            "_hint": "If stderr mentions 'older format', the file needs upgrading (kicad-cli pcb upgrade, or open and save in KiCad). If schematic-parity failed because no matching .kicad_sch was found, retry with schematicParity:false.",+        }),+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    fn vendors() -> Vec<Value> {+        vec![+            json!({"vendor": "JLCPCB", "vendorSlug": "jlcpcb", "process": "standard", "layerCount": 2, "files": {"kicadDru": "kicad/jlcpcb-2L.kicad_dru", "kicadBoardSetup": "kicad/jlcpcb-2L.json"}}),+            json!({"vendor": "InstaPCB", "vendorSlug": "adom-instapcb", "process": "standard", "layerCount": 2, "files": {"kicadDru": "kicad/adom-instapcb-2L.kicad_dru"}}),+            json!({"vendor": "JLCPCB", "vendorSlug": "jlcpcb", "process": "advanced", "layerCount": 4, "files": {}}),+        ]+    }++    #[test]+    fn fab_matching() {+        let v = vendors();+        assert_eq!(match_fab(&v, "JLC PCB").len(), 2);+        assert_eq!(match_fab(&v, "jlcpcb").len(), 2);+        assert_eq!(match_fab(&v, "insta").len(), 1);+        assert_eq!(match_fab(&v, "Insta PCB")[0]["vendorSlug"], json!("adom-instapcb"));+        assert!(match_fab(&v, "").is_empty());+        assert!(match_fab(&v, "oshpark").is_empty());+    }++    #[test]+    fn rows_carry_urls_and_house_flag() {+        let v = vendors();+        let r = rule_row(&v[0]);+        assert_eq!(r["kicadDruUrl"], json!("https://wiki.adom.inc/adom/pcb-design-rules/render/kicad/jlcpcb-2L.kicad_dru"));+        assert_eq!(r["boardSetupUrl"], json!("https://wiki.adom.inc/adom/pcb-design-rules/render/kicad/jlcpcb-2L.json"));+        assert_eq!(r["house"], json!(false));+        let r = rule_row(&v[1]);+        assert_eq!(r["boardSetupUrl"], Value::Null);+        assert_eq!(r["house"], json!(true));+        assert_eq!(rule_row(&v[2])["kicadDruUrl"], Value::Null);+    }++    #[test]+    fn copper_layer_count() {+        let board = r#"(kicad_pcb (version 20240108) (generator "pcbnew")+  (general (thickness 1.6))+  (layers+    (0 "F.Cu" signal)+    (1 "In1.Cu" power)+    (2 "In2.Cu" mixed)+    (31 "B.Cu" signal)+    (32 "B.Adhes" user "B.Adhesive")+    (36 "B.SilkS" user "B.Silkscreen")+  )+  (setup (pad_to_mask_clearance 0)))"#;+        assert_eq!(copper_layers_in_text(board), Some(4));+        assert_eq!(copper_layers_in_text("(kicad_pcb (general))"), None);+        assert_eq!(copper_layers_in_text("(kicad_pcb (layers (32 \"B.Adhes\" user)))"), None);+    }++    #[test]+    fn rule_names_and_dru_sniff() {+        let dru = "(version 1)\n(rule \"min-track-width\"\n  (constraint track_width (min 0.1mm)))\n(rule \"via-drill\" (constraint hole_size (min 0.2mm)))\n";+        assert_eq!(rule_names(dru), vec!["min-track-width".to_string(), "via-drill".to_string()]);+    }++    #[test]+    fn stage_rules_from_local_file_with_backup() {+        let dir = std::env::temp_dir().join(format!("adom-dru-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&dir);+        std::fs::create_dir_all(&dir).unwrap();+        let board = dir.join("board.kicad_pcb");+        std::fs::write(&board, "(kicad_pcb (layers (0 \"F.Cu\" signal) (31 \"B.Cu\" signal)))").unwrap();+        let src = dir.join("rules.kicad_dru");+        std::fs::write(&src, "(version 1)\n(rule \"a\" (constraint track_width (min 0.1mm)))\n").unwrap();+        let existing = dir.join("board.kicad_dru");+        std::fs::write(&existing, "(version 1)\n(rule \"old\")\n").unwrap();+        let args = json!({"druPath": src.to_string_lossy(), "keepExisting": true});+        let e = stage_rules(&board, &args).err().unwrap();+        assert_eq!(e["errorCode"], json!("dru_exists"));+        let args = json!({"druPath": src.to_string_lossy()});+        let st = stage_rules(&board, &args).ok().unwrap();+        assert_eq!(st.dru_path, existing);+        assert!(st.backup.as_ref().unwrap().file_name().unwrap().to_string_lossy().starts_with("board.kicad_dru.bak-"));+        assert_eq!(st.rule_names, vec!["a".to_string()]);+        assert_eq!(st.source["kind"], json!("local"));+        assert_eq!(std::fs::read_to_string(&existing).unwrap(), std::fs::read_to_string(&src).unwrap());+        // Not a rules file.+        let bad = dir.join("bad.kicad_dru");+        std::fs::write(&bad, "hello").unwrap();+        let e = stage_rules(&board, &json!({"druPath": bad.to_string_lossy()})).err().unwrap();+        assert_eq!(e["errorCode"], json!("not_a_kicad_dru"));+        let e = stage_rules(&board, &json!({"druPath": dir.join("missing").to_string_lossy()})).err().unwrap();+        assert!(e["error"].as_str().unwrap().starts_with("druPath not found"));+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[test]+    fn timestamp_shape() {+        let t = timestamp();+        assert_eq!(t.len(), 15);+        assert_eq!(&t[8..9], "-");+        assert!(t[..8].bytes().all(|b| b.is_ascii_digit()));+        assert!(t.starts_with("20"));+    }+}
rust/crates/kicad-core/src/lib.rs+8
@@ -1,3 +1,11 @@ //! OS-neutral core. Talks to the OS only through `kicad_platform::native()`. pub mod cli; pub mod detect;+pub mod netlist;+pub mod lint;+pub mod libraries;+pub mod settings;+pub mod design_rules;+pub mod model_check;+pub mod pcm;+pub mod updates;
rust/crates/kicad-core/src/libraries.rsadded+1277
@@ -0,0 +1,1277 @@+//! KiCad library tables and symbol libraries, ported from the Python bridge's+//! `lib_table.py`, `adom_library.py`, `handlers/list_symbols.py`,+//! `handlers/install_footprint.py::handle_list_footprints` and+//! `handlers/kicad_parse_check.py`.+//!+//! Everything here is file work: a minimal s-expression tokenizer (lib tables are+//! tiny and rigid; symbol names need a quote-aware scan), KiCad path-variable+//! expansion (`${KICAD10_SYMBOL_DIR}` and friends, with the user's Configure Paths+//! overrides from `kicad_common.json` winning), the Adom library row-first rule+//! (wiki #71: the sym-lib-table row is the file KiCad reads, so that is where the+//! bridge reads and writes), the split report, and KiCad's own verdict on a+//! symbol library through `kicad-cli sym export svg`.++use std::collections::BTreeMap;+use std::path::{Path, PathBuf};+use std::time::Duration;++use serde_json::{json, Value};++pub const ADOM_LIB_NAME: &str = "Adom";+pub const ADOM_LIB_FILE: &str = "Adom.kicad_sym";+pub const ADOM_LIB_DESCRIPTION: &str = "Adom Bridge - custom symbols";+pub const ADOM_FP_LIB_NAME: &str = "Adom";+pub const ADOM_FP_DIR_NAME: &str = "Adom.pretty";++const EMPTY_LIB: &str = "(kicad_symbol_lib\n  (version 20231120)\n  (generator \"adom-desktop\")\n)\n";++// ---------------------------------------------------------------------------+// Minimal s-expression tokenizer+// ---------------------------------------------------------------------------++/// One node of a KiCad s-expression. Quoted strings and bare atoms both become+/// `Atom`; the reader does not need to tell them apart.+#[derive(Clone, Debug, PartialEq, Eq)]+pub enum Sexp {+    Atom(String),+    List(Vec<Sexp>),+}++impl Sexp {+    pub fn as_atom(&self) -> Option<&str> {+        match self {+            Sexp::Atom(s) => Some(s.as_str()),+            Sexp::List(_) => None,+        }+    }+    pub fn as_list(&self) -> Option<&[Sexp]> {+        match self {+            Sexp::List(v) => Some(v.as_slice()),+            Sexp::Atom(_) => None,+        }+    }+    /// The head atom of a list, e.g. `lib` for `(lib ...)`.+    pub fn head(&self) -> Option<&str> {+        self.as_list().and_then(|l| l.first()).and_then(Sexp::as_atom)+    }+    /// The first child list whose head is `name`.+    pub fn child(&self, name: &str) -> Option<&Sexp> {+        self.as_list()?.iter().find(|c| c.head() == Some(name))+    }+    /// The first atom after the head of the child list `name`: `(uri "x")` -> `x`.+    pub fn child_value(&self, name: &str) -> Option<&str> {+        self.child(name)?.as_list()?.get(1)?.as_atom()+    }+}++/// Parse the first complete s-expression in `text`. Errors name the offset so a+/// corrupt table is reported rather than guessed at.+pub fn parse_sexp(text: &str) -> Result<Sexp, String> {+    let bytes = text.as_bytes();+    let mut i = 0usize;+    skip_ws(bytes, &mut i);+    if i >= bytes.len() {+        return Err("no s-expression found".into());+    }+    let node = parse_node(text, bytes, &mut i)?;+    Ok(node)+}++fn skip_ws(b: &[u8], i: &mut usize) {+    while *i < b.len() && (b[*i] as char).is_ascii_whitespace() {+        *i += 1;+    }+}++fn parse_node(text: &str, b: &[u8], i: &mut usize) -> Result<Sexp, String> {+    skip_ws(b, i);+    if *i >= b.len() {+        return Err("unexpected end of input".into());+    }+    match b[*i] {+        b'(' => {+            let open = *i;+            *i += 1;+            let mut items = Vec::new();+            loop {+                skip_ws(b, i);+                if *i >= b.len() {+                    return Err(format!("unbalanced s-expression: the list opened at offset {open} never closes"));+                }+                if b[*i] == b')' {+                    *i += 1;+                    return Ok(Sexp::List(items));+                }+                items.push(parse_node(text, b, i)?);+            }+        }+        b')' => Err(format!("unbalanced s-expression: extra ')' at offset {}", *i)),+        b'"' => {+            *i += 1;+            let mut out = String::new();+            loop {+                if *i >= b.len() {+                    return Err("unterminated quoted string".into());+                }+                let c = b[*i];+                if c == b'\\' && *i + 1 < b.len() {+                    let n = b[*i + 1];+                    out.push(match n {+                        b'n' => '\n',+                        b't' => '\t',+                        b'r' => '\r',+                        _ => n as char,+                    });+                    *i += 2;+                    continue;+                }+                if c == b'"' {+                    *i += 1;+                    return Ok(Sexp::Atom(out));+                }+                // Copy one UTF-8 scalar at a time.+                let ch = text[*i..].chars().next().unwrap();+                out.push(ch);+                *i += ch.len_utf8();+            }+        }+        _ => {+            let start = *i;+            while *i < b.len() && !(b[*i] as char).is_ascii_whitespace() && b[*i] != b'(' && b[*i] != b')' && b[*i] != b'"' {+                *i += 1;+            }+            Ok(Sexp::Atom(text[start..*i].to_string()))+        }+    }+}++fn quote(s: &str) -> String {+    let mut out = String::with_capacity(s.len() + 2);+    out.push('"');+    for ch in s.chars() {+        match ch {+            '"' => out.push_str("\\\""),+            '\\' => out.push_str("\\\\"),+            '\n' => out.push_str("\\n"),+            _ => out.push(ch),+        }+    }+    out.push('"');+    out+}++// ---------------------------------------------------------------------------+// Library tables (sym-lib-table, fp-lib-table)+// ---------------------------------------------------------------------------++#[derive(Clone, Debug, Default, PartialEq, Eq)]+pub struct LibEntry {+    pub name: String,+    pub lib_type: String,+    pub uri: String,+    pub options: String,+    pub descr: String,+    /// `(disabled)` flag KiCad 8+ writes for a library the user switched off.+    pub disabled: bool,+    /// `(hidden)` flag (PCM-managed rows).+    pub hidden: bool,+}++impl LibEntry {+    pub fn new(name: &str, lib_type: &str, uri: &str, options: &str, descr: &str) -> Self {+        LibEntry { name: name.into(), lib_type: lib_type.into(), uri: uri.into(), options: options.into(), descr: descr.into(), disabled: false, hidden: false }+    }+    pub fn to_sexpr(&self) -> String {+        let mut s = format!(+            "  (lib (name {})(type {})(uri {})(options {})(descr {})",+            quote(&self.name), quote(&self.lib_type), quote(&self.uri), quote(&self.options), quote(&self.descr)+        );+        if self.disabled {+            s.push_str("(disabled)");+        }+        if self.hidden {+            s.push_str("(hidden)");+        }+        s.push(')');+        s+    }+}++#[derive(Clone, Debug, Default)]+pub struct LibTable {+    /// `sym_lib_table` or `fp_lib_table`.+    pub table_type: String,+    pub version: Option<u32>,+    pub entries: Vec<LibEntry>,+}++impl LibTable {+    pub fn empty(table_type: &str) -> Self {+        LibTable { table_type: table_type.into(), version: Some(7), entries: Vec::new() }+    }++    /// Parse a table file. A missing file yields an empty table (KiCad 9+ shape,+    /// version 7), exactly like the Python `LibTable.parse_file`.+    pub fn parse_file(path: &Path) -> Result<LibTable, String> {+        if !path.exists() {+            let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+            return Ok(LibTable::empty(if name.contains("sym") { "sym_lib_table" } else { "fp_lib_table" }));+        }+        let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;+        LibTable::parse_str(&text).map_err(|e| format!("{}: {e}", path.display()))+    }++    pub fn parse_str(text: &str) -> Result<LibTable, String> {+        let root = parse_sexp(text).map_err(|e| format!("Cannot parse lib table: {e}"))?;+        let head = root.head().ok_or_else(|| "Cannot parse lib table: no root expression found".to_string())?;+        let mut table = LibTable { table_type: head.to_string(), version: None, entries: Vec::new() };+        for node in root.as_list().unwrap_or(&[]).iter().skip(1) {+            match node.head() {+                Some("version") => {+                    table.version = node.as_list().and_then(|l| l.get(1)).and_then(Sexp::as_atom).and_then(|v| v.parse().ok());+                }+                Some("lib") => {+                    let get = |k: &str| node.child_value(k).unwrap_or("").to_string();+                    let flag = |k: &str| node.child(k).is_some();+                    table.entries.push(LibEntry {+                        name: get("name"),+                        lib_type: get("type"),+                        uri: get("uri"),+                        options: get("options"),+                        descr: get("descr"),+                        disabled: flag("disabled"),+                        hidden: flag("hidden"),+                    });+                }+                _ => {}+            }+        }+        Ok(table)+    }++    pub fn has_library(&self, name: &str) -> bool {+        self.entries.iter().any(|e| e.name == name)+    }++    pub fn get(&self, name: &str) -> Option<&LibEntry> {+        self.entries.iter().find(|e| e.name == name)+    }++    /// Add a row. Returns false (and adds nothing) when the name already exists.+    pub fn add_library(&mut self, entry: LibEntry) -> bool {+        if self.has_library(&entry.name) {+            return false;+        }+        self.entries.push(entry);+        true+    }++    pub fn remove_library(&mut self, name: &str) -> bool {+        let before = self.entries.len();+        self.entries.retain(|e| e.name != name);+        self.entries.len() < before+    }++    pub fn to_text(&self) -> String {+        let mut lines = vec![format!("({}", self.table_type)];+        if let Some(v) = self.version {+            lines.push(format!("  (version {v})"));+        }+        for e in &self.entries {+            lines.push(e.to_sexpr());+        }+        lines.push(")".into());+        lines.join("\n") + "\n"+    }++    /// Write the table, creating parent dirs. Atomic: a sibling temp file is+    /// renamed over the target so a crash never leaves KiCad a half-written table.+    pub fn write_file(&self, path: &Path) -> Result<(), String> {+        if let Some(parent) = path.parent() {+            std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;+        }+        write_atomic(path, self.to_text().as_bytes())+    }+}++/// Write bytes to `path` through a sibling temp file and a rename.+pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {+    let tmp = path.with_extension(format!("{}.adom-tmp", path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default()));+    std::fs::write(&tmp, bytes).map_err(|e| format!("cannot write {}: {e}", tmp.display()))?;+    if let Err(e) = std::fs::rename(&tmp, path) {+        // Windows refuses to rename over an open file; fall back to a plain write.+        let _ = std::fs::remove_file(&tmp);+        std::fs::write(path, bytes).map_err(|e2| format!("cannot write {}: {e2} (rename failed: {e})", path.display()))?;+    }+    Ok(())+}++// ---------------------------------------------------------------------------+// Path-variable expansion+// ---------------------------------------------------------------------------++/// What the expansion needs to know about the KiCad this bridge drives. Built by+/// the verb layer from detection plus the platform's config and user dirs.+#[derive(Clone, Debug, Default)]+pub struct LibCtx {+    /// major.minor, e.g. "10.0".+    pub version: String,+    /// KiCad install root (the dir above bin/), if KiCad is installed.+    pub base_dir: Option<String>,+    /// Per-user settings dir (sym-lib-table, fp-lib-table, kicad_common.json live here).+    pub config_dir: Option<PathBuf>,+    /// Documents/KiCad/<ver>.+    pub user_dir: Option<PathBuf>,+    /// kicad-cli path when KiCad is installed.+    pub kicad_cli: Option<PathBuf>,+    /// Preferences > Configure Paths overrides (kicad_common.json environment.vars).+    pub env_overrides: BTreeMap<String, String>,+}++impl LibCtx {+    pub fn major(&self) -> &str {+        self.version.split('.').next().unwrap_or("")+    }+    pub fn sym_lib_table(&self) -> Option<PathBuf> {+        self.config_dir.as_ref().map(|c| c.join("sym-lib-table"))+    }+    pub fn fp_lib_table(&self) -> Option<PathBuf> {+        self.config_dir.as_ref().map(|c| c.join("fp-lib-table"))+    }+    /// Load the Configure Paths overrides from `<config_dir>/kicad_common.json`.+    pub fn load_env_overrides(&mut self) {+        if let Some(c) = &self.config_dir {+            self.env_overrides = read_env_overrides(&c.join("kicad_common.json"));+        }+    }+}++/// `environment.vars` from a kicad_common.json: the variables the user overrode in+/// Preferences > Configure Paths. Only non-empty string values count.+pub fn read_env_overrides(kicad_common: &Path) -> BTreeMap<String, String> {+    let mut out = BTreeMap::new();+    let Ok(text) = std::fs::read_to_string(kicad_common) else { return out };+    let Ok(v) = serde_json::from_str::<Value>(&text) else { return out };+    if let Some(vars) = v.get("environment").and_then(|e| e.get("vars")).and_then(Value::as_object) {+        for (k, val) in vars {+            if let Some(s) = val.as_str() {+                if !s.is_empty() {+                    out.insert(k.clone(), s.to_string());+                }+            }+        }+    }+    out+}++/// Lexical normalisation the way `os.path.normpath` does it, with forward slashes:+/// collapse `//`, drop `.`, resolve `..` against the previous segment.+pub fn normpath(p: &str) -> String {+    let s = p.replace('\\', "/");+    let absolute = s.starts_with('/');+    let mut parts: Vec<&str> = Vec::new();+    for seg in s.split('/') {+        match seg {+            "" | "." => {}+            ".." => {+                match parts.last() {+                    Some(&last) if last != ".." && !(parts.len() == 1 && last.ends_with(':')) => {+                        parts.pop();+                    }+                    Some(_) if absolute => {}+                    _ => parts.push(".."),+                }+            }+            _ => parts.push(seg),+        }+    }+    let joined = parts.join("/");+    if absolute {+        format!("/{joined}")+    } else if joined.is_empty() {+        ".".into()+    } else if parts.len() == 1 && parts[0].ends_with(':') {+        format!("{joined}/")+    } else {+        joined+    }+}++/// Expand the path variables a sym-lib-table / fp-lib-table uri may carry.+///+/// `KICAD<n>_SYMBOL_DIR`, `_FOOTPRINT_DIR`, `_3DMODEL_DIR` and `_TEMPLATE_DIR` are+/// KiCad's stock libraries under `<install>/share/kicad` (issue #80: mapping them+/// to Documents made every stock library look missing). A Configure Paths override+/// wins. `KIPRJMOD` is project-relative and refused; an unknown variable is refused+/// rather than guessed. Returns the normalised, forward-slash path.+pub fn expand_kicad_uri(uri: &str, ctx: &LibCtx) -> Option<String> {+    let major = ctx.major();+    let mut subs: Vec<(String, Option<String>)> = Vec::new();+    if let Some(base) = &ctx.base_dir {+        let share = format!("{}/share/kicad", base.trim_end_matches(['/', '\\']));+        subs.push((format!("KICAD{major}_SYMBOL_DIR"), Some(format!("{share}/symbols"))));+        subs.push((format!("KICAD{major}_FOOTPRINT_DIR"), Some(format!("{share}/footprints"))));+        subs.push((format!("KICAD{major}_3DMODEL_DIR"), Some(format!("{share}/3dmodels"))));+        subs.push((format!("KICAD{major}_TEMPLATE_DIR"), Some(format!("{share}/template"))));+    }+    if let Some(docs) = &ctx.user_dir {+        subs.push(("KICAD_USER_TEMPLATE_DIR".into(), Some(norm_path(&docs.join("template")))));+    }+    if let Some(cfg) = &ctx.config_dir {+        if !ctx.version.is_empty() {+            subs.push(("KICAD_3RD_PARTY".into(), Some(norm_path(&cfg.join("3rdparty")))));+        }+    }+    for (k, v) in &ctx.env_overrides {+        subs.retain(|(name, _)| name != k);+        subs.push((k.clone(), Some(v.clone())));+    }+    subs.push(("KIPRJMOD".into(), None));+    let mut out = uri.replace('\\', "/");+    for (k, v) in &subs {+        let token = format!("${{{k}}}");+        if out.contains(&token) {+            match v {+                None => return None,+                Some(val) => out = out.replace(&token, &val.replace('\\', "/")),+            }+        }+    }+    if out.contains("${") {+        return None;+    }+    Some(normpath(&out))+}++pub fn norm_path(p: &Path) -> String {+    p.to_string_lossy().replace('\\', "/")+}++// ---------------------------------------------------------------------------+// Symbol libraries (.kicad_sym)+// ---------------------------------------------------------------------------++/// The names of the top-level symbols in a .kicad_sym, the way the Python+/// `adom_library.list_symbols` reads them: any line whose first token is+/// `(symbol "NAME"` at any indentation (including none, wiki #37), minus the+/// `_N_N` sub-unit names. Missing file: empty list.+pub fn list_symbols(lib_path: &Path) -> Vec<String> {+    match std::fs::read_to_string(lib_path) {+        Ok(text) => list_symbols_in_text(&text),+        Err(_) => Vec::new(),+    }+}++pub fn list_symbols_in_text(text: &str) -> Vec<String> {+    let mut names = Vec::new();+    for line in text.lines() {+        let t = line.trim_start_matches([' ', '\t']);+        let Some(rest) = t.strip_prefix("(symbol") else { continue };+        let rest = rest.trim_start();+        if rest.len() == t.len() - "(symbol".len() {+            // no whitespace after `(symbol`: a different token such as `(symbols`+            continue;+        }+        let Some(rest) = rest.strip_prefix('"') else { continue };+        let Some(end) = rest.find('"') else { continue };+        let name = &rest[..end];+        if !is_subunit_name(name) {+            names.push(name.to_string());+        }+    }+    names+}++/// `Foo_0_1` style sub-unit names: ends with `_<digits>_<digits>`.+pub fn is_subunit_name(name: &str) -> bool {+    let Some(i) = name.rfind('_') else { return false };+    let tail = &name[i + 1..];+    if tail.is_empty() || !tail.bytes().all(|b| b.is_ascii_digit()) {+        return false;+    }+    let head = &name[..i];+    let Some(j) = head.rfind('_') else { return false };+    let mid = &head[j + 1..];+    !mid.is_empty() && mid.bytes().all(|b| b.is_ascii_digit())+}++/// `(symbol "NAME"` at the start of a block: the name, unescaped.+fn symbol_block_name(block: &str) -> Option<String> {+    let t = block.trim_start().strip_prefix('(')?.trim_start();+    let t = t.strip_prefix("symbol")?;+    let t = t.trim_start();+    if t.len() == block.trim_start().len() {+        return None;+    }+    let t = t.strip_prefix('"')?;+    let mut out = String::new();+    let mut chars = t.chars();+    while let Some(c) = chars.next() {+        match c {+            '\\' => {+                if let Some(n) = chars.next() {+                    out.push(n);+                }+            }+            '"' => return Some(out),+            _ => out.push(c),+        }+    }+    None+}++/// Quote-aware scan of the FIRST top-level list in `text`. Returns+/// (root_open, root_close, direct-child spans). Errors on any imbalance, since a+/// library that does not balance must never be rebuilt or merged (wiki #54).+fn quote_aware_children(text: &str) -> Result<(usize, usize, Vec<(usize, usize)>), String> {+    let mut in_str = false;+    let mut esc = false;+    let mut depth: i32 = 0;+    let mut root_open = None;+    let mut root_close = None;+    let mut child_start = None;+    let mut children = Vec::new();+    for (i, ch) in text.char_indices() {+        if in_str {+            if esc {+                esc = false;+            } else if ch == '\\' {+                esc = true;+            } else if ch == '"' {+                in_str = false;+            }+            continue;+        }+        match ch {+            '"' => in_str = true,+            '(' => {+                depth += 1;+                if depth == 1 {+                    root_open = Some(i);+                } else if depth == 2 && child_start.is_none() {+                    child_start = Some(i);+                }+            }+            ')' => {+                depth -= 1;+                if depth == 1 {+                    if let Some(s) = child_start.take() {+                        children.push((s, i + 1));+                    }+                } else if depth == 0 {+                    root_close = Some(i);+                    break;+                } else if depth < 0 {+                    return Err(format!("unbalanced s-expression: extra ')' at offset {i}"));+                }+            }+            _ => {}+        }+    }+    if in_str {+        return Err("unterminated quoted string".into());+    }+    let open = root_open.ok_or_else(|| "no s-expression found".to_string())?;+    let close = root_close.ok_or_else(|| "unbalanced s-expression: the top-level list never closes".to_string())?;+    Ok((open, close, children))+}++/// The direct `(symbol "NAME" ...)` children of a `(kicad_symbol_lib ...)` root, by+/// position (a top-level part whose name ends in `_N_N` is still honoured).+/// Strict: trailing text after the root's closer, or a non-library root, is an error.+pub fn extract_top_level_symbols(text: &str) -> Result<Vec<(String, String)>, String> {+    let (open, close, children) = quote_aware_children(text)?;+    let head = text[open + 1..].split_whitespace().next().unwrap_or("").trim_end_matches(')');+    if head != "kicad_symbol_lib" {+        return Err(format!("not a symbol library (top-level list is '{head}')"));+    }+    if !text[close + 1..].trim().is_empty() {+        let tail: String = text[close + 1..].trim().chars().take(40).collect();+        return Err(format!("trailing content after the library's closing paren: {tail:?}"));+    }+    let mut out = Vec::new();+    for (a, b) in children {+        let blk = &text[a..b];+        if let Some(name) = symbol_block_name(blk) {+            out.push((name, blk.to_string()));+        }+    }+    Ok(out)+}++/// Where the top-level list closes (quote-aware), or None if it never does.+fn library_close_index(text: &str) -> Option<usize> {+    let mut depth: i32 = 0;+    let mut in_str = false;+    let mut esc = false;+    for (i, ch) in text.char_indices() {+        if in_str {+            if esc {+                esc = false;+            } else if ch == '\\' {+                esc = true;+            } else if ch == '"' {+                in_str = false;+            }+            continue;+        }+        match ch {+            '"' => in_str = true,+            '(' => depth += 1,+            ')' => {+                depth -= 1;+                if depth == 0 {+                    return Some(i);+                }+            }+            _ => {}+        }+    }+    None+}++/// (symbols, unparseable) from a .kicad_sym that may carry symbol blocks AFTER the+/// library's closing paren (the old appender bug). Each trailing block is+/// balance-checked on its own; duplicates keep the first (inside) copy.+pub fn extract_symbols_tolerant(text: &str) -> (Vec<(String, String)>, Vec<String>) {+    let close = library_close_index(text);+    let (inside, trail) = match close {+        Some(c) => (&text[..c + 1], &text[c + 1..]),+        None => (text, ""),+    };+    let mut out: Vec<(String, String)> = extract_top_level_symbols(inside).unwrap_or_default();+    let mut bad: Vec<String> = Vec::new();+    if !trail.trim().is_empty() {+        let mut pos = 0usize;+        while pos < trail.len() {+            let Some(rel) = trail[pos..].find('(') else { break };+            let start = pos + rel;+            match library_close_index(&trail[start..]) {+                None => {+                    bad.push(symbol_block_name(&trail[start..]).unwrap_or_else(|| "<unnamed block>".into()));+                    break;+                }+                Some(c) => {+                    let block = &trail[start..start + c + 1];+                    pos = start + c + 1;+                    let Some(name) = symbol_block_name(block) else { continue };+                    match extract_top_level_symbols(&format!("(kicad_symbol_lib\n{block}\n)")) {+                        Ok(_) => out.push((name, block.to_string())),+                        Err(_) => bad.push(name),+                    }+                }+            }+        }+    }+    let mut seen = std::collections::HashSet::new();+    let mut unique = Vec::new();+    let mut dupes = 0;+    for (name, blk) in out {+        if !seen.insert(name.to_lowercase()) {+            dupes += 1;+            continue;+        }+        unique.push((name, blk));+    }+    if dupes > 0 {+        bad.push(format!("<{dupes} duplicate copies skipped>"));+    }+    (unique, bad)+}++// ---------------------------------------------------------------------------+// Bootstrap of a never-launched KiCad's tables+// ---------------------------------------------------------------------------++/// Make sure a lib table file exists. A missing file is seeded from KiCad's own+/// template (`<base>/share/kicad/template/<name>`) so the stock libraries survive,+/// else written empty. An existing file is never touched.+pub fn ensure_lib_table(path: &Path, root: &str, base_dir: Option<&str>) -> Result<(), String> {+    if path.exists() {+        return Ok(());+    }+    if let Some(parent) = path.parent() {+        std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;+    }+    let file_name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+    if let Some(base) = base_dir {+        let tmpl = Path::new(base).join("share").join("kicad").join("template").join(&file_name);+        if tmpl.is_file() {+            if let Ok(bytes) = std::fs::read(&tmpl) {+                return write_atomic(path, &bytes);+            }+        }+    }+    write_atomic(path, format!("({root}\n  (version 7)\n)\n").as_bytes())+}++// ---------------------------------------------------------------------------+// The Adom symbol library+// ---------------------------------------------------------------------------++/// (sym-lib-table path, Adom.kicad_sym path). ROW FIRST (wiki #71): if a row named+/// Adom exists, its uri is the library; otherwise `<user_dir>/symbols/Adom.kicad_sym`.+pub fn adom_paths(ctx: &LibCtx) -> Option<(PathBuf, PathBuf)> {+    let table_path = ctx.sym_lib_table()?;+    let user_dir = ctx.user_dir.clone()?;+    if let Ok(table) = LibTable::parse_file(&table_path) {+        if let Some(row) = table.get(ADOM_LIB_NAME) {+            if !row.uri.is_empty() {+                if let Some(expanded) = expand_kicad_uri(&row.uri, ctx) {+                    let lib_path = PathBuf::from(expanded);+                    if let Some(parent) = lib_path.parent() {+                        let _ = std::fs::create_dir_all(parent);+                    }+                    return Some((table_path, lib_path));+                }+            }+        }+    }+    let sym_dir = user_dir.join("symbols");+    let _ = std::fs::create_dir_all(&sym_dir);+    Some((table_path, sym_dir.join(ADOM_LIB_FILE)))+}++/// Python `ensure_adom_library`: create Adom.kicad_sym if missing and register it.+/// The returned object carries `exists`, `registered`, `created`, `symbols` and,+/// on failure, `error` (never a `success` key; the verb adds that).+pub fn ensure_adom_library(ctx: &LibCtx, installed: bool) -> Value {+    let empty = |err: &str| json!({"exists": false, "registered": false, "created": false, "symbols": [], "error": err});+    if !installed {+        return empty("KiCad not installed");+    }+    if let (Some(t), Some(_)) = (ctx.sym_lib_table(), ctx.user_dir.as_ref()) {+        let _ = ensure_lib_table(&t, "sym_lib_table", ctx.base_dir.as_deref());+    }+    let Some((table_path, lib_path)) = adom_paths(ctx) else {+        return empty("KiCad config/user directory not found");+    };+    let existed = lib_path.exists();+    if !existed {+        if let Err(e) = write_atomic(&lib_path, EMPTY_LIB.as_bytes()) {+            return empty(&format!("cannot create {}: {e}", lib_path.display()));+        }+    }+    let mut table = match LibTable::parse_file(&table_path) {+        Ok(t) => t,+        Err(e) => return empty(&format!("Failed to parse sym-lib-table: {e}")),+    };+    let was_registered = table.has_library(ADOM_LIB_NAME);+    if !was_registered {+        table.add_library(LibEntry::new(ADOM_LIB_NAME, "KiCad", &norm_path(&lib_path), "", ADOM_LIB_DESCRIPTION));+        if let Err(e) = table.write_file(&table_path) {+            return empty(&format!("cannot write sym-lib-table: {e}"));+        }+    }+    json!({+        "exists": existed,+        "registered": was_registered,+        "created": !existed,+        "symbols": list_symbols(&lib_path),+    })+}++fn same_file(a: &Path, b: &Path) -> bool {+    match (a.canonicalize(), b.canonicalize()) {+        (Ok(x), Ok(y)) => x == y,+        _ => normpath(&norm_path(a)).to_lowercase() == normpath(&norm_path(b)).to_lowercase(),+    }+}++/// Python `library_split_report`: which Adom.kicad_sym KiCad READS (the row), which+/// the bridge would WRITE by its default rule, and whether they differ.+pub fn library_split_report(ctx: &LibCtx) -> Value {+    let Some((_table, registered)) = adom_paths(ctx) else {+        return json!({"split": null});+    };+    let default = ctx.user_dir.as_ref().map(|u| u.join("symbols").join(ADOM_LIB_FILE));+    let count = |p: &Path| -> Value { if p.exists() { json!(list_symbols(p).len()) } else { Value::Null } };+    let mut rep = json!({+        "registeredPath": norm_path(&registered),+        "registeredSymbols": count(&registered),+        "bridgeDefaultPath": default.as_ref().map(|p| norm_path(p)),+        "bridgeDefaultSymbols": default.as_ref().map(|p| count(p)).unwrap_or(Value::Null),+    });+    let split = match &default {+        Some(d) => !same_file(d, &registered),+        None => false,+    };+    rep["split"] = json!(split);+    if split {+        let default = default.unwrap();+        let reg_names: std::collections::HashSet<String> = match std::fs::read_to_string(&registered) {+            Ok(t) => extract_symbols_tolerant(&t).0.into_iter().map(|(n, _)| n.to_lowercase()).collect(),+            Err(_) => list_symbols(&registered).into_iter().map(|n| n.to_lowercase()).collect(),+        };+        let orphan_syms = if default.exists() {+            std::fs::read_to_string(&default).map(|t| extract_symbols_tolerant(&t).0).unwrap_or_default()+        } else {+            Vec::new()+        };+        let mut missing: Vec<String> = orphan_syms.into_iter().map(|(n, _)| n).filter(|n| !reg_names.contains(&n.to_lowercase())).collect();+        missing.sort();+        missing.dedup();+        rep["orphanMissingFromRegistered"] = json!(missing.len());+        if !missing.is_empty() {+            rep["orphanedLibrary"] = rep["bridgeDefaultPath"].clone();+            rep["orphanMissingSample"] = json!(missing.iter().take(10).collect::<Vec<_>>());+        }+    }+    rep+}++// ---------------------------------------------------------------------------+// KiCad's own verdict on a symbol library (kicad-cli sym export svg)+// ---------------------------------------------------------------------------++/// `{available, ok, symbols[], units, error, exitCode, durationMs}`. available=false+/// means kicad-cli was not found: no verdict either way, and callers must say so+/// rather than treat it as a pass (wiki #71).+pub fn kicad_parser_verdict(cli: Option<&Path>, lib_path: &Path, timeout: Duration) -> Value {+    let Some(cli) = cli.filter(|c| c.is_file()) else {+        return json!({"available": false, "ok": null, "symbols": [], "units": 0,+                      "error": "kicad-cli not found; KiCad's parser could not be asked"});+    };+    if !lib_path.exists() {+        return json!({"available": true, "ok": false, "symbols": [], "units": 0,+                      "error": format!("file not found: {}", norm_path(lib_path))});+    }+    let out_dir = std::env::temp_dir().join(format!(+        "adom-kicad-parse-{}-{}",+        std::process::id(),+        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)+    ));+    let _ = std::fs::create_dir_all(&out_dir);+    let out_s = norm_path(&out_dir);+    let lib_s = norm_path(lib_path);+    let r = crate::cli::run(cli, &["sym", "export", "svg", "-o", out_s.as_str(), lib_s.as_str()], timeout);+    let _ = std::fs::remove_dir_all(&out_dir);+    if r.timed_out {+        return json!({"available": true, "ok": false, "symbols": [], "units": 0,+                      "error": format!("kicad-cli did not answer within {}s", timeout.as_secs())});+    }+    let text = format!("{}\n{}", r.stdout, r.stderr);+    let mut names: Vec<String> = Vec::new();+    let mut units = 0u64;+    for line in text.lines() {+        if let Some(name) = plotted_symbol(line) {+            units += 1;+            if !names.iter().any(|n| n == &name) {+                names.push(name);+            }+        }+    }+    let ok = r.exit_code == 0;+    let mut err = String::new();+    if !ok {+        let lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty() && plotted_symbol(l).is_none()).take(3).collect();+        err = lines.join(" | ").chars().take(400).collect();+        if err.is_empty() {+            err = format!("kicad-cli exit {}", r.exit_code);+        }+    }+    json!({"available": true, "ok": ok, "symbols": names, "units": units, "error": err,+           "exitCode": r.exit_code, "durationMs": r.elapsed_ms})+}++/// `Plotting symbol 'NAME' unit N` -> NAME.+fn plotted_symbol(line: &str) -> Option<String> {+    let i = line.find("Plotting symbol '")?;+    let rest = &line[i + "Plotting symbol '".len()..];+    let j = rest.find("' unit ")?;+    let tail = &rest[j + "' unit ".len()..];+    if !tail.trim().chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {+        return None;+    }+    Some(rest[..j].to_string())+}++// ---------------------------------------------------------------------------+// Footprint libraries (.pretty)+// ---------------------------------------------------------------------------++/// Sorted stems of the `.kicad_mod` files in a `.pretty` dir.+pub fn list_pretty(pretty: &Path) -> Vec<String> {+    let mut names: Vec<String> = match std::fs::read_dir(pretty) {+        Ok(rd) => rd+            .flatten()+            .filter_map(|e| {+                let p = e.path();+                if p.extension().map(|x| x == "kicad_mod").unwrap_or(false) && p.is_file() {+                    p.file_stem().map(|s| s.to_string_lossy().to_string())+                } else {+                    None+                }+            })+            .collect(),+        Err(_) => Vec::new(),+    };+    names.sort();+    names+}++/// One row of `kicad_list_footprints.libraries`.+#[derive(Clone, Debug)]+pub struct PrettyInfo {+    pub path: String,+    pub footprints: Vec<String>,+}++/// Python `handle_list_footprints`: every library the user's fp-lib-table knows+/// (URIs expanded the way KiCad expands them, stock libraries included), then any+/// unregistered `.pretty` folders under `<user_dir>/footprints`. `want` filters by+/// library name, case-insensitive. Returns (libraries in discovery order, total).+pub fn list_footprints(ctx: &LibCtx, want: Option<&str>) -> (Vec<(String, PrettyInfo)>, usize) {+    let mut libs: Vec<(String, PrettyInfo)> = Vec::new();+    let mut total = 0usize;+    let mut add = |name: &str, pretty: &Path, libs: &mut Vec<(String, PrettyInfo)>| {+        if libs.iter().any(|(n, _)| n == name) || !pretty.is_dir() {+            return;+        }+        if let Some(w) = want {+            if name.to_lowercase() != w.to_lowercase() {+                return;+            }+        }+        let names = list_pretty(pretty);+        total += names.len();+        libs.push((name.to_string(), PrettyInfo { path: norm_path(pretty), footprints: names }));+    };+    if let Some(table) = ctx.fp_lib_table() {+        if table.is_file() {+            if let Ok(t) = LibTable::parse_file(&table) {+                for e in t.entries.iter().filter(|e| !e.disabled) {+                    if let Some(resolved) = expand_kicad_uri(&e.uri, ctx) {+                        add(&e.name, Path::new(&resolved), &mut libs);+                    }+                }+            }+        }+    }+    if let Some(user) = &ctx.user_dir {+        let root = user.join("footprints");+        if root.is_dir() {+            let mut dirs: Vec<PathBuf> = std::fs::read_dir(&root)+                .map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "pretty").unwrap_or(false)).collect())+                .unwrap_or_default();+            dirs.sort();+            for pretty in dirs {+                let stem = pretty.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+                add(&stem, &pretty, &mut libs);+            }+        }+    }+    (libs, total)+}++// ---------------------------------------------------------------------------+// Tests+// ---------------------------------------------------------------------------++#[cfg(test)]+mod tests {+    use super::*;++    const SYM_TABLE: &str = r#"(sym_lib_table+  (version 7)+  (lib (name "Device")(type "KiCad")(uri "${KICAD10_SYMBOL_DIR}/Device.kicad_sym")(options "")(descr "Generic devices"))+  (lib (name "Adom")(type "KiCad")(uri "C:/Users/john/Documents/KiCad/10.0/symbols/Adom.kicad_sym")(options "")(descr "Adom Bridge - custom symbols"))+  (lib (name "Old")(type "Legacy")(uri "${KIPRJMOD}/old.lib")(options "")(descr "quote \"inside\"")(disabled))+)+"#;++    const FP_TABLE: &str = "(fp_lib_table\n\t(version 7)\n\t(lib (name \"Resistor_SMD\")(type \"KiCad\")(uri \"${KICAD10_FOOTPRINT_DIR}/Resistor_SMD.pretty\")(options \"\")(descr \"Resistor footprints, SMD\"))\n\t(lib (name \"Mine\")(type \"KiCad\")(uri \"${MY_LIBS}/Mine.pretty\")(options \"\")(descr \"\"))\n)\n";++    const KICAD_SYM: &str = r#"(kicad_symbol_lib+	(version 20231120)+	(generator "kicad_symbol_editor")+	(symbol "R"+		(property "Reference" "R" (at 0 0 0))+		(symbol "R_0_1" (rectangle (start -1 -2) (end 1 2)))+		(symbol "R_1_1" (pin passive line (at 0 3 270) (length 1) (name "~") (number "1")))+	)+(symbol "STM32F103"+    (property "Value" "PA13(JTMS/SWDIO)" (at 0 0 0))+    (symbol "STM32F103_0_1" (rectangle (start -1 -2) (end 1 2)))+)+	(embedded_fonts no)+)+"#;++    fn ctx() -> LibCtx {+        LibCtx {+            version: "10.0".into(),+            base_dir: Some("C:/Program Files/KiCad/10.0".into()),+            config_dir: Some(PathBuf::from("C:/Users/john/AppData/Roaming/kicad/10.0")),+            user_dir: Some(PathBuf::from("C:/Users/john/Documents/KiCad/10.0")),+            kicad_cli: None,+            env_overrides: BTreeMap::new(),+        }+    }++    #[test]+    fn tokenizer_handles_quotes_and_nesting() {+        let s = parse_sexp(r#"(a "b c" (d "e\"f") g)"#).unwrap();+        let l = s.as_list().unwrap();+        assert_eq!(l[0].as_atom(), Some("a"));+        assert_eq!(l[1].as_atom(), Some("b c"));+        assert_eq!(l[2].child_value("e\"f"), None);+        assert_eq!(l[2].as_list().unwrap()[1].as_atom(), Some("e\"f"));+        assert_eq!(l[3].as_atom(), Some("g"));+        assert!(parse_sexp("(a (b)").is_err());+        assert!(parse_sexp("(a))").is_ok());+        assert!(parse_sexp("").is_err());+    }++    #[test]+    fn sym_lib_table_parses_and_round_trips() {+        let t = LibTable::parse_str(SYM_TABLE).unwrap();+        assert_eq!(t.table_type, "sym_lib_table");+        assert_eq!(t.version, Some(7));+        assert_eq!(t.entries.len(), 3);+        assert_eq!(t.entries[0].name, "Device");+        assert_eq!(t.entries[0].uri, "${KICAD10_SYMBOL_DIR}/Device.kicad_sym");+        assert_eq!(t.entries[0].descr, "Generic devices");+        assert!(t.has_library("Adom"));+        assert!(t.entries[2].disabled);+        assert_eq!(t.entries[2].descr, "quote \"inside\"");+        let text = t.to_text();+        assert!(text.starts_with("(sym_lib_table\n  (version 7)\n  (lib (name \"Device\")"));+        assert!(text.contains("(descr \"quote \\\"inside\\\"\")(disabled))"));+        let again = LibTable::parse_str(&text).unwrap();+        assert_eq!(again.entries, t.entries);+    }++    #[test]+    fn fp_lib_table_tabs_and_add() {+        let mut t = LibTable::parse_str(FP_TABLE).unwrap();+        assert_eq!(t.table_type, "fp_lib_table");+        assert_eq!(t.entries.len(), 2);+        assert!(t.add_library(LibEntry::new("Adom", "KiCad", "C:/x/Adom.pretty", "", "Adom Bridge - custom footprints")));+        assert!(!t.add_library(LibEntry::new("Adom", "KiCad", "C:/y", "", "")));+        assert!(t.remove_library("Mine"));+        assert_eq!(t.entries.len(), 2);+        assert!(t.to_text().ends_with("(descr \"Adom Bridge - custom footprints\"))\n)\n"));+    }++    #[test]+    fn missing_table_file_is_empty() {+        let t = LibTable::parse_file(Path::new("/nonexistent/dir/sym-lib-table")).unwrap();+        assert_eq!(t.table_type, "sym_lib_table");+        assert_eq!(t.version, Some(7));+        assert!(t.entries.is_empty());+        let t = LibTable::parse_file(Path::new("/nonexistent/dir/fp-lib-table")).unwrap();+        assert_eq!(t.table_type, "fp_lib_table");+    }++    #[test]+    fn uri_expansion_stock_dirs() {+        let c = ctx();+        assert_eq!(+            expand_kicad_uri("${KICAD10_SYMBOL_DIR}/Device.kicad_sym", &c).as_deref(),+            Some("C:/Program Files/KiCad/10.0/share/kicad/symbols/Device.kicad_sym")+        );+        assert_eq!(+            expand_kicad_uri("${KICAD10_FOOTPRINT_DIR}\\Resistor_SMD.pretty", &c).as_deref(),+            Some("C:/Program Files/KiCad/10.0/share/kicad/footprints/Resistor_SMD.pretty")+        );+        assert_eq!(+            expand_kicad_uri("${KICAD10_3DMODEL_DIR}/x.step", &c).as_deref(),+            Some("C:/Program Files/KiCad/10.0/share/kicad/3dmodels/x.step")+        );+        assert_eq!(+            expand_kicad_uri("${KICAD10_TEMPLATE_DIR}/t", &c).as_deref(),+            Some("C:/Program Files/KiCad/10.0/share/kicad/template/t")+        );+        assert_eq!(+            expand_kicad_uri("${KICAD_USER_TEMPLATE_DIR}/t", &c).as_deref(),+            Some("C:/Users/john/Documents/KiCad/10.0/template/t")+        );+        assert_eq!(+            expand_kicad_uri("${KICAD_3RD_PARTY}/footprints/x.pretty", &c).as_deref(),+            Some("C:/Users/john/AppData/Roaming/kicad/10.0/3rdparty/footprints/x.pretty")+        );+        // Plain paths pass through normalised.+        assert_eq!(expand_kicad_uri("C:\\a\\.\\b\\..\\c.kicad_sym", &c).as_deref(), Some("C:/a/c.kicad_sym"));+    }++    #[test]+    fn uri_expansion_refuses_unknown_and_project() {+        let c = ctx();+        assert_eq!(expand_kicad_uri("${KIPRJMOD}/x.pretty", &c), None);+        assert_eq!(expand_kicad_uri("${MY_LIBS}/x.pretty", &c), None);+        // A different major is not this KiCad's variable.+        assert_eq!(expand_kicad_uri("${KICAD9_SYMBOL_DIR}/Device.kicad_sym", &c), None);+        // No install: the stock variables are unknown.+        let mut nb = ctx();+        nb.base_dir = None;+        assert_eq!(expand_kicad_uri("${KICAD10_SYMBOL_DIR}/Device.kicad_sym", &nb), None);+    }++    #[test]+    fn uri_expansion_env_overrides_win() {+        let mut c = ctx();+        c.env_overrides.insert("KICAD10_SYMBOL_DIR".into(), "D:\\libs\\symbols".into());+        c.env_overrides.insert("MY_LIBS".into(), "D:/mine".into());+        assert_eq!(expand_kicad_uri("${KICAD10_SYMBOL_DIR}/Device.kicad_sym", &c).as_deref(), Some("D:/libs/symbols/Device.kicad_sym"));+        assert_eq!(expand_kicad_uri("${MY_LIBS}/Mine.pretty", &c).as_deref(), Some("D:/mine/Mine.pretty"));+        // The other stock variables keep their defaults.+        assert_eq!(+            expand_kicad_uri("${KICAD10_FOOTPRINT_DIR}/R.pretty", &c).as_deref(),+            Some("C:/Program Files/KiCad/10.0/share/kicad/footprints/R.pretty")+        );+    }++    #[test]+    fn env_overrides_from_kicad_common() {+        let dir = std::env::temp_dir().join(format!("adom-libtest-{}", std::process::id()));+        let _ = std::fs::create_dir_all(&dir);+        let f = dir.join("kicad_common.json");+        std::fs::write(&f, r#"{"environment": {"vars": {"KICAD10_SYMBOL_DIR": "E:/syms", "EMPTY": "", "NUM": 3}}}"#).unwrap();+        let o = read_env_overrides(&f);+        assert_eq!(o.get("KICAD10_SYMBOL_DIR").map(String::as_str), Some("E:/syms"));+        assert!(!o.contains_key("EMPTY"));+        assert!(!o.contains_key("NUM"));+        assert!(read_env_overrides(&dir.join("missing.json")).is_empty());+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[test]+    fn symbol_names_skip_subunits_any_indentation() {+        let names = list_symbols_in_text(KICAD_SYM);+        assert_eq!(names, vec!["R".to_string(), "STM32F103".to_string()]);+        assert!(is_subunit_name("R_0_1"));+        assert!(is_subunit_name("Foo_12_3"));+        assert!(!is_subunit_name("Foo_1"));+        assert!(!is_subunit_name("Foo_a_1"));+        assert!(!is_subunit_name("R"));+    }++    #[test]+    fn structural_symbols_are_quote_aware() {+        let syms = extract_top_level_symbols(KICAD_SYM).unwrap();+        let names: Vec<&str> = syms.iter().map(|(n, _)| n.as_str()).collect();+        assert_eq!(names, vec!["R", "STM32F103"]);+        assert!(syms[1].1.starts_with("(symbol \"STM32F103\""));+        assert!(extract_top_level_symbols("(kicad_symbol_lib (symbol \"A\")) (symbol \"B\")").is_err());+        assert!(extract_top_level_symbols("(kicad_pcb (symbol \"A\"))").is_err());+        assert!(extract_top_level_symbols("(kicad_symbol_lib (symbol \"A\"").is_err());+    }++    #[test]+    fn tolerant_extraction_handles_trailing_blocks() {+        let text = "(kicad_symbol_lib (version 1) (symbol \"A\" (pin (name \"x)\")))\n)\n(symbol \"B\" (x))\n(symbol \"a\" (y))\n(symbol \"C\" (broken";+        let (syms, bad) = extract_symbols_tolerant(text);+        let names: Vec<&str> = syms.iter().map(|(n, _)| n.as_str()).collect();+        assert_eq!(names, vec!["A", "B"]);+        assert!(bad.contains(&"C".to_string()));+        assert!(bad.iter().any(|b| b.contains("1 duplicate")));+    }++    #[test]+    fn pretty_listing_and_footprints() {+        let dir = std::env::temp_dir().join(format!("adom-fptest-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&dir);+        let base = dir.join("kicad");+        let stock = base.join("share/kicad/footprints/Resistor_SMD.pretty");+        std::fs::create_dir_all(&stock).unwrap();+        std::fs::write(stock.join("R_0603.kicad_mod"), "(footprint \"R_0603\")").unwrap();+        std::fs::write(stock.join("R_0402.kicad_mod"), "(footprint \"R_0402\")").unwrap();+        std::fs::write(stock.join("notes.txt"), "x").unwrap();+        let cfg = dir.join("cfg");+        std::fs::create_dir_all(&cfg).unwrap();+        std::fs::write(cfg.join("fp-lib-table"), FP_TABLE).unwrap();+        let user = dir.join("user");+        let adom = user.join("footprints/Adom.pretty");+        std::fs::create_dir_all(&adom).unwrap();+        std::fs::write(adom.join("BMI423.kicad_mod"), "(footprint \"x\")").unwrap();+        let c = LibCtx {+            version: "10.0".into(),+            base_dir: Some(norm_path(&base)),+            config_dir: Some(cfg.clone()),+            user_dir: Some(user.clone()),+            kicad_cli: None,+            env_overrides: BTreeMap::new(),+        };+        let (libs, total) = list_footprints(&c, None);+        assert_eq!(total, 3);+        assert_eq!(libs[0].0, "Resistor_SMD");+        assert_eq!(libs[0].1.footprints, vec!["R_0402".to_string(), "R_0603".to_string()]);+        assert_eq!(libs[1].0, "Adom");+        let (libs, total) = list_footprints(&c, Some("resistor_smd"));+        assert_eq!(total, 2);+        assert_eq!(libs.len(), 1);+        let (libs, _) = list_footprints(&c, Some("Nope"));+        assert!(libs.is_empty());+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[test]+    fn ensure_adom_library_creates_and_registers() {+        let dir = std::env::temp_dir().join(format!("adom-ensure-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&dir);+        let cfg = dir.join("cfg");+        let user = dir.join("user");+        let c = LibCtx { version: "10.0".into(), base_dir: Some("/nowhere".into()), config_dir: Some(cfg.clone()), user_dir: Some(user.clone()), kicad_cli: None, env_overrides: BTreeMap::new() };+        let r = ensure_adom_library(&c, true);+        assert_eq!(r["exists"], json!(false));+        assert_eq!(r["registered"], json!(false));+        assert_eq!(r["created"], json!(true));+        assert_eq!(r["symbols"], json!([]));+        let t = LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap();+        assert!(t.has_library("Adom"));+        assert!(user.join("symbols/Adom.kicad_sym").is_file());+        let r2 = ensure_adom_library(&c, true);+        assert_eq!(r2["exists"], json!(true));+        assert_eq!(r2["registered"], json!(true));+        let rep = library_split_report(&c);+        assert_eq!(rep["split"], json!(false));+        assert_eq!(rep["registeredSymbols"], json!(0));+        assert_eq!(ensure_adom_library(&c, false)["error"], json!("KiCad not installed"));+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[test]+    fn split_report_names_orphans() {+        let dir = std::env::temp_dir().join(format!("adom-split-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&dir);+        let cfg = dir.join("cfg");+        let user = dir.join("user");+        let other = dir.join("elsewhere");+        std::fs::create_dir_all(&cfg).unwrap();+        std::fs::create_dir_all(user.join("symbols")).unwrap();+        std::fs::create_dir_all(&other).unwrap();+        let reg = other.join("Adom.kicad_sym");+        std::fs::write(&reg, "(kicad_symbol_lib (version 1)\n  (symbol \"A\" (x))\n)\n").unwrap();+        std::fs::write(user.join("symbols/Adom.kicad_sym"), "(kicad_symbol_lib (version 1)\n\t(symbol \"A\" (x))\n\t(symbol \"B\" (y))\n)\n").unwrap();+        let mut t = LibTable::empty("sym_lib_table");+        t.add_library(LibEntry::new("Adom", "KiCad", &norm_path(&reg), "", ""));+        t.write_file(&cfg.join("sym-lib-table")).unwrap();+        let c = LibCtx { version: "10.0".into(), base_dir: None, config_dir: Some(cfg), user_dir: Some(user), kicad_cli: None, env_overrides: BTreeMap::new() };+        let rep = library_split_report(&c);+        assert_eq!(rep["split"], json!(true));+        assert_eq!(rep["registeredSymbols"], json!(1));+        assert_eq!(rep["bridgeDefaultSymbols"], json!(2));+        assert_eq!(rep["orphanMissingFromRegistered"], json!(1));+        assert_eq!(rep["orphanMissingSample"], json!(["B"]));+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[test]+    fn parser_verdict_without_cli_is_honest() {+        let v = kicad_parser_verdict(None, Path::new("/nope.kicad_sym"), Duration::from_secs(1));+        assert_eq!(v["available"], json!(false));+        assert_eq!(v["ok"], Value::Null);+        assert_eq!(plotted_symbol("Plotting symbol 'R' unit 1"), Some("R".into()));+        assert_eq!(plotted_symbol("Plotting symbol 'PA13(JTMS)' unit 12"), Some("PA13(JTMS)".into()));+        assert_eq!(plotted_symbol("Unable to load library"), None);+    }++    #[test]+    fn normpath_cases() {+        assert_eq!(normpath("C:/a//b/./c/../d"), "C:/a/b/d");+        assert_eq!(normpath("/x/y/../../z"), "/z");+        assert_eq!(normpath("C:/"), "C:/");+        assert_eq!(normpath("rel/../.."), "..");+    }+}
rust/crates/kicad-core/src/lint.rsadded+1452
@@ -0,0 +1,1452 @@+//! Lint group, ported from `handlers/kicad_cli_lint.py` and `handlers/kicad_parse_check.py`.+//!+//! Headless pre-checks that run before any GUI automation: a magic-byte sniff of the+//! file, DRC or ERC through `kicad-cli` with a JSON report, a structural check and+//! inventory of symbol and footprint libraries, KiCad's OWN verdict on a `.kicad_sym`+//! (`kicad-cli sym export svg`, the one read-only command that parses a symbol+//! library), and the `kicad-cli <kind> upgrade` runner.+//!+//! Everything here is OS-neutral. The only way out to the OS is `cli::run`, which+//! goes through `kicad_platform::native()` for the quiet child process. The Python+//! used regexes for the inventory; there is no regex crate in this workspace, so the+//! same patterns are small scanners below, each named after the pattern it replaces.++use std::collections::{BTreeMap, BTreeSet};+use std::path::{Path, PathBuf};+use std::time::{Duration, Instant};++use serde_json::{json, Value};++use crate::cli;++/// Expected root atom of a board file.+pub const PCB: &str = "kicad_pcb";+/// Expected root atom of a schematic file.+pub const SCH: &str = "kicad_sch";++/// Python `_run_kicad_cli_json` and `handle_format_upgrade` deadline.+pub const CLI_TIMEOUT: Duration = Duration::from_secs(120);+/// Python `kicad_parse_check.verdict_file` deadline, per file.+pub const VERDICT_TIMEOUT: Duration = Duration::from_secs(60);++// ───────────────────────── text helpers (the Python regexes) ─────────────────────────++/// `s` cut to at most `max` bytes, on a char boundary.+fn head_of(s: &str, max: usize) -> &str {+    if s.len() <= max {+        return s;+    }+    let mut i = max;+    while !s.is_char_boundary(i) {+        i -= 1;+    }+    &s[..i]+}++/// Python `f"{x!r}"` for a short str: single-quoted, backslashes and quotes escaped,+/// newlines as `\n`. Only used inside error messages.+fn py_repr(s: &str) -> String {+    let mut out = String::from("'");+    for c in s.chars() {+        match c {+            '\\' => out.push_str("\\\\"),+            '\'' => out.push_str("\\'"),+            '\n' => out.push_str("\\n"),+            '\r' => out.push_str("\\r"),+            '\t' => out.push_str("\\t"),+            c => out.push(c),+        }+    }+    out.push('\'');+    out+}++/// After `(kw` there must be at least one whitespace char; returns the text after it.+fn after_keyword<'a>(s: &'a str, kw: &str) -> Option<(&'a str, &'a str)> {+    let needle = format!("({kw}");+    let i = s.find(&needle)?;+    let after = &s[i + needle.len()..];+    let t = after.trim_start();+    if t.len() == after.len() {+        return None; // `(version)` or `(versionx`: no whitespace, not a match; caller advances+    }+    Some((t, after))+}++/// `\(version\s+(\d+)\)`: the first file-format version in `s`.+pub fn file_version(s: &str) -> Option<u64> {+    let mut rest = s;+    while let Some(i) = rest.find("(version") {+        let after = &rest[i + "(version".len()..];+        let t = after.trim_start();+        if t.len() < after.len() {+            let n = t.bytes().take_while(u8::is_ascii_digit).count();+            if n > 0 && t[n..].starts_with(')') {+                return t[..n].parse().ok();+            }+        }+        rest = after;+    }+    None+}++/// `\(<kw>\s+(?:"([^"]+)"|(\S+))\)`: every value of a quoted-or-bare keyword, in order.+/// Callers that only want the file head pass `head_of(text, 500)`.+fn keyword_values(s: &str, kw: &str) -> Vec<String> {+    let mut out = Vec::new();+    let mut rest = s;+    let needle = format!("({kw}");+    while let Some(i) = rest.find(&needle) {+        let after = &rest[i + needle.len()..];+        if let Some((t, _)) = after_keyword(&rest[i..], kw) {+            if let Some(q) = t.strip_prefix('"') {+                if let Some(end) = q.find('"') {+                    if end > 0 && q[end + 1..].starts_with(')') {+                        out.push(q[..end].to_string());+                    }+                }+            } else if let Some(end) = t.find(')') {+                let tok = &t[..end];+                if !tok.is_empty() && !tok.chars().any(char::is_whitespace) {+                    out.push(tok.to_string());+                }+            }+        }+        rest = after;+    }+    out+}++/// `\(generator\s+(?:"([^"]+)"|(\S+))\)` in the head of the file.+pub fn generator(s: &str) -> Option<String> {+    keyword_values(s, "generator").into_iter().next()+}++/// Count of `(<kw>` followed by whitespace (and, when `word` is set, a word char):+/// `\(pin\s+\w+` and `\(pad\s`.+fn count_keyword(s: &str, kw: &str, word: bool) -> usize {+    let needle = format!("({kw}");+    let mut n = 0;+    let mut rest = s;+    while let Some(i) = rest.find(&needle) {+        let after = &rest[i + needle.len()..];+        let mut it = after.chars();+        match it.next() {+            Some(c) if c.is_whitespace() => {+                if word {+                    let t = after.trim_start();+                    if t.chars().next().map(|c| c.is_alphanumeric() || c == '_').unwrap_or(false) {+                        n += 1;+                    }+                } else {+                    n += 1;+                }+            }+            _ => {}+        }+        rest = after;+    }+    n+}++/// `(symbol "NAME"` with `NAME` = `[^"]+`, starting at byte `at` of `s` (which must+/// point at the `(`). Returns the name.+fn symbol_name_at(s: &str, at: usize) -> Option<&str> {+    let t = s[at..].strip_prefix("(symbol")?;+    let t2 = t.trim_start();+    if t2.len() == t.len() {+        return None;+    }+    let q = t2.strip_prefix('"')?;+    let end = q.find('"')?;+    if end == 0 {+        return None;+    }+    Some(&q[..end])+}++/// `_\d+_\d+$`: nested unit variants like `R_0_1`.+fn ends_with_unit_suffix(name: &str) -> bool {+    let b = name.as_bytes();+    let mut i = b.len();+    let mut d2 = 0;+    while i > 0 && b[i - 1].is_ascii_digit() {+        i -= 1;+        d2 += 1;+    }+    if d2 == 0 || i == 0 || b[i - 1] != b'_' {+        return false;+    }+    i -= 1;+    let mut d1 = 0;+    while i > 0 && b[i - 1].is_ascii_digit() {+        i -= 1;+        d1 += 1;+    }+    d1 > 0 && i > 0 && b[i - 1] == b'_'+}++/// For `name` = `<parent>_<N>_<M>` returns `N` as text.+fn unit_of<'a>(name: &'a str, parent: &str) -> Option<&'a str> {+    let rest = name.strip_prefix(parent)?.strip_prefix('_')?;+    let n = rest.bytes().take_while(u8::is_ascii_digit).count();+    if n == 0 {+        return None;+    }+    let tail = rest[n..].strip_prefix('_')?;+    if tail.is_empty() || !tail.bytes().all(|c| c.is_ascii_digit()) {+        return None;+    }+    Some(&rest[..n])+}++// ───────────────────────── file format sniff ─────────────────────────++/// Python `_file_format_sniff` on the first bytes of the file. `expected` is `PCB` or+/// `SCH`. Returns the Python dict verbatim: `{ok, expected, magic, fileVersion, _note}`+/// or `{ok:false, expected?, actual?, _error, _hint}`.+pub fn sniff_head(head: &str, expected: &str) -> Value {+    let head_text = head.trim_start();+    let magic = format!("({expected}");+    if !head_text.starts_with(&magic) {+        let wrong_type = if expected == PCB && head_text.starts_with("(kicad_sch") {+            Some(SCH)+        } else if expected == SCH && head_text.starts_with("(kicad_pcb") {+            Some(PCB)+        } else {+            None+        };+        let hint = match wrong_type {+            Some(w) => format!(+                "You passed what looks like a {w} file to a verb expecting {expected}. Use the matching verb instead \+                 (kicad_open_board for .kicad_pcb, kicad_open_schematic / kicad_lint_schematic for .kicad_sch)."+            ),+            None => format!(+                "The file doesn't start with the expected {expected} S-expression header. \+                 It may be corrupted, truncated, or not a KiCad file at all."+            ),+        };+        return json!({+            "ok": false,+            "expected": expected,+            "actual": wrong_type.unwrap_or("non-kicad"),+            "_error": format!("file is not a {expected} (first bytes: {})", py_repr(head_of(head_text, 80))),+            "_hint": hint,+        });+    }+    json!({+        "ok": true,+        "expected": expected,+        "magic": "matched",+        "fileVersion": file_version(head_text),+        "_note": "magic + format sniff only; kicad-cli does the real validation",+    })+}++/// Python `_file_format_sniff(path, expected)`: existence, is-a-file, then `sniff_head`+/// on the first 512 bytes.+pub fn file_format_sniff(path: &Path, expected: &str) -> Value {+    if !path.exists() {+        return json!({+            "ok": false,+            "_error": format!("file not found: {}", path.display()),+            "_hint": "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 json!({"ok": false, "_error": format!("not a file: {}", path.display())});+    }+    let head = match read_head(path, 512) {+        Ok(h) => h,+        Err(e) => return json!({"ok": false, "_error": format!("read failed: {e}")}),+    };+    sniff_head(&head, expected)+}++fn read_head(path: &Path, n: usize) -> std::io::Result<String> {+    use std::io::Read;+    let mut f = std::fs::File::open(path)?;+    let mut buf = vec![0u8; n];+    let mut got = 0;+    while got < n {+        let k = f.read(&mut buf[got..])?;+        if k == 0 {+            break;+        }+        got += k;+    }+    buf.truncate(got);+    Ok(String::from_utf8_lossy(&buf).to_string())+}++// ───────────────────────── kicad-cli with a JSON report ─────────────────────────++/// Python `_run_kicad_cli_json`: `{ok, exitCode, stdout, stderr, data?, _error?, _hint?}`.+pub struct CliJson {+    pub ok: bool,+    pub exit_code: i32,+    pub stdout: String,+    pub stderr: String,+    pub elapsed_ms: u64,+    pub timed_out: bool,+    /// The parsed `--output` report, when kicad-cli wrote one.+    pub data: Option<Value>,+    pub error: Option<String>,+    pub hint: Option<String>,+}++fn scratch_path(prefix: &str, suffix: &str) -> PathBuf {+    let nanos = std::time::SystemTime::now()+        .duration_since(std::time::UNIX_EPOCH)+        .map(|d| d.subsec_nanos())+        .unwrap_or(0);+    std::env::temp_dir().join(format!("{prefix}-{}-{nanos}{suffix}", std::process::id()))+}++/// Run kicad-cli with `--output <tempfile>` appended and read the JSON it wrote there.+/// `pcb drc` and `sch erc` put the structured report in that file; stdout and stderr+/// carry status text only.+///+/// Deviation from the Python: a non-zero exit that wrote no report is `ok: false`+/// (the Python returned `ok: true, data: None`, which the lint verbs then reported as a+/// clean file with zero violations).+pub fn run_cli_json(cli: &Path, args: &[&str], timeout: Duration) -> CliJson {+    let report = scratch_path("adom-kicad-lint", ".json");+    let report_s = report.to_string_lossy().to_string();+    let mut argv: Vec<&str> = args.to_vec();+    argv.push("--output");+    argv.push(&report_s);+    let r = cli::run(cli, &argv, timeout);+    let raw = std::fs::read(&report).ok().filter(|b| !b.is_empty());+    let _ = std::fs::remove_file(&report);+    let mut out = CliJson {+        ok: true,+        exit_code: r.exit_code,+        stdout: r.stdout,+        stderr: r.stderr,+        elapsed_ms: r.elapsed_ms,+        timed_out: r.timed_out,+        data: None,+        error: None,+        hint: None,+    };+    if r.timed_out {+        out.ok = false;+        out.error = Some(format!("kicad-cli timed out after {}s", timeout.as_secs()));+        out.hint = Some("Bump the timeout, or the board/schematic may be pathologically large.".into());+        return out;+    }+    if out.exit_code == -1 {+        out.ok = false;+        out.error = Some(format!("kicad-cli invocation failed: {}", out.stderr.trim()));+        return out;+    }+    match raw {+        Some(bytes) => match serde_json::from_slice::<Value>(&bytes) {+            Ok(v) => out.data = Some(v),+            Err(e) => {+                out.ok = false;+                out.error = Some(format!("could not parse kicad-cli JSON output: {e}"));+            }+        },+        None if out.exit_code != 0 => {+            out.ok = false;+            let first = out.stderr.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");+            out.error = Some(format!("kicad-cli exited {} and wrote no report: {first}", out.exit_code));+        }+        None => {}+    }+    out+}++// ───────────────────────── violation classification ─────────────────────────++/// Python `_parse_violations`: one row per violation, `{type, severity, description,+/// items:[{description, pos}]}`, plus counts by severity (always carrying the four+/// keys error, warning, info, exclusion; other severities are added as seen).+///+/// Buckets walked: `violations`, `unconnected_items` (DRC), and, beyond the Python,+/// `schematic_parity` (DRC with `--schematic-parity`) and `sheets[].violations` (the+/// ERC report of KiCad 8 and later nests everything under sheets; the Python saw none+/// of it and reported every schematic clean).+pub fn parse_violations(data: Option<&Value>) -> (Vec<Value>, BTreeMap<String, u64>) {+    let mut counts: BTreeMap<String, u64> = BTreeMap::new();+    for k in ["error", "warning", "info", "exclusion"] {+        counts.insert(k.into(), 0);+    }+    let mut out = Vec::new();+    let Some(data) = data.filter(|d| d.is_object()) else {+        return (out, counts);+    };+    let mut walk = |bucket: Option<&Value>, label: Option<&str>| {+        let Some(items) = bucket.and_then(Value::as_array) else { return };+        for v in items {+            let sev = v.get("severity").and_then(Value::as_str).unwrap_or("warning").to_string();+            let items: Vec<Value> = v+                .get("items")+                .and_then(Value::as_array)+                .map(|a| {+                    a.iter()+                        .map(|i| {+                            json!({+                                "description": i.get("description").cloned().unwrap_or(json!("")),+                                "pos": i.get("pos").cloned().unwrap_or(json!({})),+                            })+                        })+                        .collect()+                })+                .unwrap_or_default();+            out.push(json!({+                "type": v.get("type").cloned().unwrap_or_else(|| json!(label.unwrap_or("unknown"))),+                "severity": sev,+                "description": v.get("description").cloned().unwrap_or(json!("")),+                "items": items,+            }));+            *counts.entry(sev).or_insert(0) += 1;+        }+    };+    walk(data.get("violations"), None);+    walk(data.get("unconnected_items"), Some("unconnected_items"));+    walk(data.get("schematic_parity"), Some("schematic_parity"));+    if let Some(sheets) = data.get("sheets").and_then(Value::as_array) {+        for s in sheets {+            walk(s.get("violations"), None);+        }+    }+    (out, counts)+}++/// Sorted, de-duplicated `type` of every error-severity violation, first five.+pub fn top_error_types(violations: &[Value]) -> Vec<String> {+    let set: BTreeSet<String> = violations+        .iter()+        .filter(|v| v.get("severity").and_then(Value::as_str) == Some("error"))+        .filter_map(|v| v.get("type").and_then(Value::as_str).map(str::to_string))+        .collect();+    set.into_iter().take(5).collect()+}++fn types_str(violations: &[Value]) -> String {+    let t = top_error_types(violations);+    if t.is_empty() {+        "various".into()+    } else {+        t.join(", ")+    }+}++/// The tiered `_hint` of `kicad_lint_board`.+pub fn board_hint(violations: &[Value], errors: u64, warnings: u64) -> String {+    if errors == 0 && warnings == 0 {+        "✅ DRC clean. Safe to open kicad_open_board, generate gerbers via export, or hand off to fabrication. No human intervention needed.".into()+    } else if errors == 0 {+        format!(+            "⚠️  {warnings} DRC warning(s), 0 errors. The board is fabricable as-is but the warnings may \+             indicate sloppy design (silkscreen overlapping pads, courtyard violations, etc.). \+             Review violations[].description and decide if any need fixing. Safe to proceed with kicad_open_board."+        )+    } else {+        format!(+            "❌ {errors} DRC error(s) + {warnings} warning(s). Top error types: {}. \+             DO NOT proceed to fabrication. Open kicad_open_board and resolve these in the PCB Editor, \+             OR fix the underlying schematic if these are schematic-parity errors (e.g. nets connected on the schematic \+             but unrouted on the board). Re-run kicad_lint_board after fixing to confirm clean.",+            types_str(violations)+        )+    }+}++/// The tiered `_hint` of `kicad_lint_schematic`.+pub fn schematic_hint(violations: &[Value], errors: u64, warnings: u64) -> String {+    if errors == 0 && warnings == 0 {+        "✅ ERC clean. Safe to open kicad_open_schematic or move on to PCB layout.".into()+    } else if errors == 0 {+        format!(+            "⚠️  {warnings} ERC warning(s), 0 errors. Common warnings: pin-type mismatch on connections, \+             missing power flags. Inspect violations[].description and decide. Schematic is functionally OK."+        )+    } else {+        format!(+            "❌ {errors} ERC error(s) + {warnings} warning(s). Top error types: {}. \+             Common causes: unconnected pins (add NoConnect flag), duplicate references (run Annotate Schematic), \+             hierarchical-label mismatches, no-driver on a net (e.g. an input pin with no source). \+             Fix in eeschema via kicad_open_schematic, then re-run kicad_lint_schematic to confirm.",+            types_str(violations)+        )+    }+}++/// `handle_lint_board` / `handle_lint_schematic` after the arg checks: run kicad-cli,+/// classify, build the Python response. `expected` is `PCB` or `SCH`; `file_version`+/// comes from the sniff; `schematic_parity` only matters for boards.+pub fn lint_document(cli: &Path, path: &Path, expected: &str, file_version: Option<u64>, schematic_parity: bool, timeout: Duration) -> Value {+    let is_pcb = expected == PCB;+    let path_s = path.to_string_lossy().to_string();+    let mut argv: Vec<&str> = if is_pcb {+        vec!["pcb", "drc", "--format", "json", "--severity-all", "--all-track-errors"]+    } else {+        vec!["sch", "erc", "--format", "json", "--severity-all"]+    };+    if is_pcb && schematic_parity {+        // Only meaningful if the matching .kicad_sch exists in the project dir.+        argv.push("--schematic-parity");+    }+    argv.push(&path_s);+    let r = run_cli_json(cli, &argv, timeout);+    let file_format = json!({"expected": expected, "fileVersion": file_version});+    if !r.ok {+        let hint = if is_pcb {+            "If exitCode is nonzero and stderr mentions 'older format', the file needs upgrading: \+             `kicad-cli pcb upgrade` (or just opening it in KiCad and saving) does this. \+             If schematic-parity failed because no matching .kicad_sch was found in the project dir, \+             retry with schematicParity:false."+        } else {+            "If exitCode is nonzero and stderr mentions an older format, run `kicad-cli sch upgrade` (or open + save in eeschema)."+        };+        return json!({+            "success": false,+            "errorCode": if r.timed_out { "cli_timeout" } else { "cli_failed" },+            "error": r.error.clone().unwrap_or_else(|| "kicad-cli failed".into()),+            "exitCode": r.exit_code,+            "stdout": r.stdout,+            "stderr": r.stderr,+            "fileFormat": file_format,+            "cliElapsedMs": r.elapsed_ms,+            "_hint": r.hint.unwrap_or_else(|| hint.into()),+        });+    }+    let (violations, counts) = parse_violations(r.data.as_ref());+    let total = violations.len();+    let errors = counts.get("error").copied().unwrap_or(0);+    let warnings = counts.get("warning").copied().unwrap_or(0);+    let info = counts.get("info").copied().unwrap_or(0);+    let hint = if is_pcb { board_hint(&violations, errors, warnings) } else { schematic_hint(&violations, errors, warnings) };+    let mut data = json!({+        "violations": violations,+        "summary": {"total": total, "errors": errors, "warnings": warnings, "info": info},+        "fileFormat": file_format,+        "source_file": path_s,+    });+    if is_pcb {+        data["schematicParityChecked"] = json!(schematic_parity);+    }+    json!({+        "success": true,+        "output": format!("Lint complete: {errors} error(s), {warnings} warning(s), {total} total"),+        "data": data,+        "exitCode": r.exit_code,+        "cliElapsedMs": r.elapsed_ms,+        "_hint": hint,+    })+}++// ───────────────────────── library structural check and inventory ─────────────────────────++/// Python `_LIB_MAGIC`.+pub fn sym_magic_ok(head: &str) -> bool {+    head.trim_start().starts_with("(kicad_symbol_lib")+}+pub fn mod_magic_ok(head: &str) -> bool {+    let h = head.trim_start();+    h.starts_with("(footprint") || h.starts_with("(module") // KiCad <= 6 used "module"+}++/// Python `adom_library._quote_aware_children`: the FIRST top-level s-expression list+/// in `text`. Returns `(root_open, root_close, children)` where children are the byte+/// spans of the root's direct child lists. Errs on any imbalance (an extra `)` names+/// its offset, a never-closed list says so) and on an unterminated string.+pub fn quote_aware_children(text: &str) -> Result<(usize, usize, Vec<(usize, usize)>), String> {+    let (mut in_str, mut esc) = (false, false);+    let mut depth: i64 = 0;+    let (mut root_open, mut root_close) = (None, None);+    let mut child_start: Option<usize> = None;+    let mut children = Vec::new();+    for (i, ch) in text.bytes().enumerate() {+        if in_str {+            if esc {+                esc = false;+            } else if ch == b'\\' {+                esc = true;+            } else if ch == b'"' {+                in_str = false;+            }+            continue;+        }+        match ch {+            b'"' => in_str = true,+            b'(' => {+                depth += 1;+                if depth == 1 {+                    root_open = Some(i);+                } else if depth == 2 && child_start.is_none() {+                    child_start = Some(i);+                }+            }+            b')' => {+                depth -= 1;+                if depth == 1 {+                    if let Some(cs) = child_start.take() {+                        children.push((cs, i + 1));+                    }+                } else if depth == 0 {+                    root_close = Some(i);+                    break;+                } else if depth < 0 {+                    return Err(format!("unbalanced s-expression: extra ')' at offset {i}"));+                }+            }+            _ => {}+        }+    }+    if in_str {+        return Err("unterminated quoted string".into());+    }+    let Some(ro) = root_open else { return Err("no s-expression found".into()) };+    let Some(rc) = root_close else { return Err("unbalanced s-expression: the top-level list never closes".into()) };+    Ok((ro, rc, children))+}++/// Python `adom_library._parse_library_nodes` minus the node list: balanced, root is+/// `kicad_symbol_lib`, nothing after the root's closer.+pub fn check_symbol_lib_structure(text: &str) -> Result<(), String> {+    let (ro, rc, _) = quote_aware_children(text)?;+    let head = if text[ro + 1..].trim().is_empty() {+        ""+    } else {+        head_of(&text[ro + 1..], 39).split_whitespace().next().unwrap_or("")+    };+    if head != "kicad_symbol_lib" {+        return Err(format!("not a symbol library (top-level list is '{head}')"));+    }+    let tail = text[rc + 1..].trim();+    if !tail.is_empty() {+        return Err(format!("trailing content after the library's closing paren: {}", py_repr(head_of(tail, 40))));+    }+    Ok(())+}++/// The footprint structural check `handle_lint_library` runs: balanced, and nothing+/// after the footprint's closer.+pub fn check_footprint_structure(text: &str) -> Result<(), String> {+    let (_, rc, _) = quote_aware_children(text)?;+    let tail = text[rc + 1..].trim();+    if !tail.is_empty() {+        return Err(format!("trailing content after the footprint's closing paren: {}", py_repr(head_of(tail, 40))));+    }+    Ok(())+}++/// Python `_parse_kicad_sym` on the file text: `{symbolCount, symbols:[{name, pinCount,+/// unitCount}], fileVersion, generator, _warnings}`. Regex-style inventory, same+/// heuristics as the Python (a 5000-char window per symbol for the pin count).+pub fn parse_kicad_sym(text: &str) -> Value {+    let head = head_of(text, 500);+    // Every `(symbol "NAME"` anywhere, for the unit lookup.+    let mut all_names: Vec<&str> = Vec::new();+    let mut rest = 0;+    while let Some(i) = text[rest..].find("(symbol") {+        let at = rest + i;+        if let Some(n) = symbol_name_at(text, at) {+            all_names.push(n);+        }+        rest = at + "(symbol".len();+    }+    let mut symbols = Vec::new();+    let mut offset = 0;+    for line in text.split_inclusive('\n') {+        let lead = line.len() - line.trim_start().len();+        let at = offset + lead;+        offset += line.len();+        if !line[lead..].starts_with("(symbol") {+            continue;+        }+        let Some(name) = symbol_name_at(text, at) else { continue };+        if ends_with_unit_suffix(name) {+            continue; // nested unit variant like "R_0_0"+        }+        let window = head_of(&text[at..], 5000);+        let pin_count = count_keyword(window, "pin", true);+        let units: BTreeSet<&str> = all_names.iter().filter_map(|n| unit_of(n, name)).collect();+        symbols.push(json!({"name": name, "pinCount": pin_count, "unitCount": units.len().max(1)}));+    }+    json!({+        "symbolCount": symbols.len(),+        "symbols": symbols,+        "fileVersion": file_version(head),+        "generator": generator(head),+        "_warnings": [],+    })+}++/// Python `_parse_kicad_mod`: `{name, padCount, layers, fileVersion, generator, _warnings}`.+pub fn parse_kicad_mod(text: &str) -> Value {+    let head = head_of(text, 500);+    let name: Option<String> = match after_keyword(head, "footprint") {+        Some((t, _)) if t.starts_with('"') => t[1..].find('"').filter(|&e| e > 0).map(|e| t[1..1 + e].to_string()),+        _ => after_keyword(head, "module").and_then(|(t, _)| {+            let tok: String = t.chars().take_while(|c| !c.is_whitespace()).collect();+            if tok.is_empty() { None } else { Some(tok) }+        }),+    };+    let layers: BTreeSet<String> = keyword_values(text, "layer").into_iter().collect();+    let layers: Vec<String> = layers.into_iter().take(10).collect();+    json!({+        "name": name,+        "padCount": count_keyword(text, "pad", false),+        "layers": layers,+        "fileVersion": file_version(head),+        "generator": generator(head),+        "_warnings": [],+    })+}++// ───────────────────────── KiCad's own verdict (kicad_parse_check.py) ─────────────────────────++/// `Plotting symbol 'NAME' unit N` lines from `kicad-cli sym export svg`: unique symbol+/// names in order, and the number of units plotted.+pub fn parse_plot_lines(text: &str) -> (Vec<String>, usize) {+    let mut names: Vec<String> = Vec::new();+    let mut units = 0;+    for line in text.lines() {+        if let Some(name) = plot_line_name(line) {+            units += 1;+            if !names.iter().any(|n| n == name) {+                names.push(name.to_string());+            }+        }+    }+    (names, units)+}++fn plot_line_name(line: &str) -> Option<&str> {+    const P: &str = "Plotting symbol '";+    let i = line.find(P)?;+    let rest = &line[i + P.len()..];+    let end = rest.find('\'')?;+    if end == 0 {+        return None;+    }+    let after = rest[end + 1..].strip_prefix(" unit ")?;+    if after.bytes().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {+        Some(&rest[..end])+    } else {+        None+    }+}++/// `kicad_parse_check.cli_exe`: the recorded kicad-cli if it exists, else the two+/// spellings next to it in the bin dir.+pub fn cli_exe(kicad_cli: &str, bin_dir: &str) -> Option<PathBuf> {+    if !kicad_cli.is_empty() && Path::new(kicad_cli).exists() {+        return Some(PathBuf::from(kicad_cli));+    }+    if !bin_dir.is_empty() {+        for name in ["kicad-cli.exe", "kicad-cli"] {+            let c = Path::new(bin_dir).join(name);+            if c.exists() {+                return Some(c);+            }+        }+    }+    None+}++/// `kicad_parse_check.verdict_file`: KiCad's verdict on an existing `.kicad_sym`.+/// `{available, ok, symbols[], units, error, exitCode, durationMs}`. `available: false`+/// means kicad-cli was not found: no verdict either way, and callers must SAY that+/// rather than treat it as a pass.+pub fn verdict_file(cli: Option<&Path>, lib_path: &Path, timeout: Duration) -> Value {+    let Some(cli) = cli else {+        return json!({"available": false, "ok": null, "symbols": [], "units": 0,+                      "error": "kicad-cli not found; KiCad's parser could not be asked"});+    };+    if !lib_path.exists() {+        return json!({"available": true, "ok": false, "symbols": [], "units": 0,+                      "error": format!("file not found: {}", lib_path.display())});+    }+    let out_dir = scratch_path("adom-kicad-parse", "");+    if let Err(e) = std::fs::create_dir_all(&out_dir) {+        return json!({"available": true, "ok": false, "symbols": [], "units": 0,+                      "error": head_of(&format!("scratch dir failed: {e}"), 300)});+    }+    let t0 = Instant::now();+    let out_s = out_dir.to_string_lossy().to_string();+    let lib_s = lib_path.to_string_lossy().to_string();+    let r = cli::run(cli, &["sym", "export", "svg", "-o", &out_s, &lib_s], timeout);+    let _ = std::fs::remove_dir_all(&out_dir);+    if r.timed_out {+        return json!({"available": true, "ok": false, "symbols": [], "units": 0,+                      "error": format!("kicad-cli did not answer within {}s", timeout.as_secs())});+    }+    if r.exit_code == -1 {+        return json!({"available": true, "ok": false, "symbols": [], "units": 0,+                      "error": head_of(&r.stderr, 300)});+    }+    let text = format!("{}\n{}", r.stdout, r.stderr);+    let (names, units) = parse_plot_lines(&text);+    let ok = r.exit_code == 0;+    let err = if ok {+        String::new()+    } else {+        let lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty() && plot_line_name(l).is_none()).take(3).collect();+        let joined = head_of(&lines.join(" | "), 400).to_string();+        if joined.is_empty() { format!("kicad-cli exit {}", r.exit_code) } else { joined }+    };+    json!({+        "available": true, "ok": ok, "symbols": names, "units": units, "error": err,+        "exitCode": r.exit_code, "durationMs": t0.elapsed().as_millis() as u64,+    })+}++// ───────────────────────── kicad_lint_library ─────────────────────────++fn ext_lower(p: &Path) -> String {+    p.extension().map(|e| format!(".{}", e.to_string_lossy().to_lowercase())).unwrap_or_default()+}++/// Files in `dir` with the given (lower-case, dotted) extension, sorted by path.+fn glob_ext(dir: &Path, ext: &str) -> Vec<PathBuf> {+    let mut v: Vec<PathBuf> = std::fs::read_dir(dir)+        .map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.is_file() && ext_lower(p) == ext).collect())+        .unwrap_or_default();+    v.sort();+    v+}++/// What `handle_lint_library` found on disk.+#[derive(Debug)]+pub struct LibraryInput {+    pub kind: &'static str,+    pub sym_files: Vec<PathBuf>,+    pub mod_files: Vec<PathBuf>,+}++/// Discover what kind of input a library path is. Errors are the Python's+/// `(error, _hint)` pairs.+pub fn discover_library(path: &Path) -> Result<LibraryInput, (String, String)> {+    if !path.exists() {+        return Err((+            format!("path not found: {}", path.display()),+            "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first.".into(),+        ));+    }+    let mut li = LibraryInput { kind: "", sym_files: Vec::new(), mod_files: Vec::new() };+    if path.is_file() {+        match ext_lower(path).as_str() {+            ".kicad_sym" => {+                li.sym_files.push(path.to_path_buf());+                li.kind = "symbol_lib_file";+            }+            ".kicad_mod" => {+                li.mod_files.push(path.to_path_buf());+                li.kind = "footprint_file";+            }+            ext => {+                return Err((+                    format!("unsupported file extension: {ext}"),+                    "Pass a .kicad_sym, .kicad_mod, or a directory containing them (.pretty for footprint libraries).".into(),+                ))+            }+        }+    } else if path.is_dir() {+        let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+        if ext_lower(path) == ".pretty" || name.ends_with(".pretty") {+            li.kind = "footprint_lib_dir";+            li.mod_files = glob_ext(path, ".kicad_mod");+        } else {+            li.kind = "mixed_dir";+            li.sym_files = glob_ext(path, ".kicad_sym");+            li.mod_files = glob_ext(path, ".kicad_mod");+        }+        if li.sym_files.is_empty() && li.mod_files.is_empty() {+            return Err((+                format!("directory contains no .kicad_sym or .kicad_mod files: {}", path.display()),+                "Symbol libraries are single .kicad_sym files. Footprint libraries are .pretty/ directories containing .kicad_mod files.".into(),+            ));+        }+    } else {+        return Err((format!("not a file or directory: {}", path.display()), "Pass a .kicad_sym, .kicad_mod or .pretty directory.".into()));+    }+    Ok(li)+}++/// Python list repr of sorted versions, e.g. `[20211014, 20231120]`.+fn versions_repr(v: &[u64]) -> String {+    format!("[{}]", v.iter().map(u64::to_string).collect::<Vec<_>>().join(", "))+}++fn file_name(p: &str) -> String {+    Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.to_string())+}++/// The tiered `_hint` of `kicad_lint_library`. Pure, so it is testable without files.+pub fn library_hint(+    kicad_refused: &[Value],+    sym_file_errors: usize,+    mod_file_errors: usize,+    symbol_count: u64,+    footprint_count: u64,+    versions: &[u64],+    kicad_verdicts: &[Value],+) -> String {+    if !kicad_refused.is_empty() {+        let names: Vec<String> = kicad_refused+            .iter()+            .take(3)+            .map(|v| format!("{}: {}", file_name(v.get("path").and_then(Value::as_str).unwrap_or("")), v.get("error").and_then(Value::as_str).unwrap_or("")))+            .collect();+        return format!(+            "❌ KICAD REFUSES {} symbol file(s) that the bridge's structural check passed: {}. \+             KiCad lists such a library EMPTY, and one malformed symbol does that to the whole file. \+             DO NOT install. Fix the file until `kicad-cli sym export svg` accepts it \+             (common: bare `hide yes` inside (effects ...) instead of `(hide yes)`).",+            kicad_refused.len(),+            names.join("; ")+        );+    }+    if sym_file_errors > 0 || mod_file_errors > 0 {+        return format!(+            "❌ {sym_file_errors} symbol file(s) + {mod_file_errors} footprint file(s) \+             failed to parse. Inspect _errors[] for specifics. Common causes: file truncated mid-write, \+             wrong file type renamed with .kicad_sym extension, file from a much newer KiCad than installed. \+             DO NOT install via kicad_install_library / kicad_install_symbol / kicad_install_footprint: \+             the install will land broken files."+        );+    }+    // Outdated format. KiCad 10 uses YYYYMMDD versions like 20240108. Anything below+    // 20200101 is KiCad 5-era; 2023xxxx is KiCad 7-era and warrants a kicad_format_upgrade first.+    let outdated: Vec<u64> = versions.iter().copied().filter(|&v| v < 20231101).collect();+    if !outdated.is_empty() {+        return format!(+            "⚠️  {symbol_count} symbol(s) + {footprint_count} footprint(s) parsed cleanly, \+             BUT file-format version(s) {} are pre-KiCad-7. KiCad 10 can read these but \+             will silently upgrade them on first save. To migrate proactively, call \+             kicad_format_upgrade (or just run `kicad-cli sym/fp upgrade` manually). \+             OK to proceed with install but expect a one-time format change.",+            versions_repr(&outdated)+        );+    }+    let versions_s = if versions.is_empty() { "unspecified".to_string() } else { versions_repr(versions) };+    let available = |v: &Value| v.get("available").and_then(Value::as_bool).unwrap_or(false);+    let kp_ok: Vec<&Value> = kicad_verdicts.iter().filter(|v| available(v) && v.get("ok").and_then(Value::as_bool).unwrap_or(false)).collect();+    let kp_syms: usize = kp_ok.iter().map(|v| v.get("symbols").and_then(Value::as_array).map(|a| a.len()).unwrap_or(0)).sum();+    if !kicad_verdicts.is_empty() && kicad_verdicts.iter().all(available) {+        format!(+            "✅ KiCad's own parser loads {kp_syms} symbol(s) from {} file(s) (kicadParser); \+             the bridge's structural check counts {symbol_count} symbol(s) + {footprint_count} footprint(s), \+             format version(s) {versions_s}. Safe to install via \+             kicad_install_library / kicad_install_symbol / kicad_install_footprint.",+            kp_ok.len()+        )+    } else {+        format!(+            "✅ {symbol_count} symbol(s) + {footprint_count} footprint(s) balanced by the BRIDGE's reader, \+             format version(s) {versions_s}. KiCad's own parser was \+             NOT consulted (kicad-cli not found), so this is a structural check only, not KiCad's verdict."+        )+    }+}++/// `handle_lint_library` after the arg check. `cli` is None when kicad-cli is not on+/// this box; the structural check still runs and the hint says KiCad was not asked.+pub fn lint_library(path: &Path, cli: Option<&Path>, verdict_timeout: Duration) -> Value {+    let li = match discover_library(path) {+        Ok(li) => li,+        Err((error, hint)) => return json!({"success": false, "errorCode": "bad_input", "error": error, "_hint": hint}),+    };++    let mut parsed_sym: Vec<Value> = Vec::new();+    for f in &li.sym_files {+        let p = f.to_string_lossy().to_string();+        let text = match std::fs::read(f) {+            Ok(b) => String::from_utf8_lossy(&b).to_string(),+            Err(e) => {+                parsed_sym.push(json!({"path": p, "_error": format!("read failed: {e}")}));+                continue;+            }+        };+        if !sym_magic_ok(head_of(&text, 200)) {+            parsed_sym.push(json!({"path": p, "_error": "not a kicad_symbol_lib (bad magic)"}));+            continue;+        }+        // Structural check (#54): unbalanced parens, trailing garbage after the root+        // closer, or a non-library root are hard errors; a regex inventory cannot see them.+        if let Err(e) = check_symbol_lib_structure(&text) {+            parsed_sym.push(json!({"path": p, "_error": format!("structural: {e}")}));+            continue;+        }+        let mut info = parse_kicad_sym(&text);+        info["path"] = json!(p);+        info["structuralCheck"] = json!("balanced");+        parsed_sym.push(info);+    }++    let mut parsed_mod: Vec<Value> = Vec::new();+    for f in &li.mod_files {+        let p = f.to_string_lossy().to_string();+        let text = match std::fs::read(f) {+            Ok(b) => String::from_utf8_lossy(&b).to_string(),+            Err(e) => {+                parsed_mod.push(json!({"path": p, "_error": format!("read failed: {e}")}));+                continue;+            }+        };+        if !mod_magic_ok(head_of(&text, 200)) {+            parsed_mod.push(json!({"path": p, "_error": "not a kicad_mod (bad magic)"}));+            continue;+        }+        if let Err(e) = check_footprint_structure(&text) {+            parsed_mod.push(json!({"path": p, "_error": format!("structural: {e}")}));+            continue;+        }+        let mut info = parse_kicad_mod(&text);+        info["path"] = json!(p);+        parsed_mod.push(info);+    }++    let has_err = |v: &Value| v.get("_error").is_some();+    let symbol_count: u64 = parsed_sym.iter().filter(|s| !has_err(s)).map(|s| s.get("symbolCount").and_then(Value::as_u64).unwrap_or(0)).sum();+    let footprint_count: u64 = parsed_mod.iter().filter(|m| !has_err(m)).count() as u64;+    let sym_file_errors = parsed_sym.iter().filter(|s| has_err(s)).count();+    let mod_file_errors = parsed_mod.iter().filter(|m| has_err(m)).count();+    let versions: Vec<u64> = parsed_sym+        .iter()+        .chain(parsed_mod.iter())+        .filter_map(|s| s.get("fileVersion").and_then(Value::as_u64))+        .filter(|&v| v != 0)+        .collect::<BTreeSet<u64>>()+        .into_iter()+        .collect();++    // wiki #71: the parse above is the BRIDGE's reader. Ask KiCad for every .kicad_sym+    // (read-only export) and let its verdict override the checkmark.+    let mut kicad_verdicts: Vec<Value> = Vec::new();+    let mut kicad_refused: Vec<Value> = Vec::new();+    for f in &li.sym_files {+        let mut v = verdict_file(cli, f, verdict_timeout);+        v["path"] = json!(f.to_string_lossy().to_string());+        let available = v.get("available").and_then(Value::as_bool).unwrap_or(false);+        let ok = v.get("ok").and_then(Value::as_bool).unwrap_or(false);+        if available && !ok {+            kicad_refused.push(v.clone());+        }+        kicad_verdicts.push(v);+    }++    let hint = library_hint(&kicad_refused, sym_file_errors, mod_file_errors, symbol_count, footprint_count, &versions, &kicad_verdicts);+    let ok = kicad_refused.is_empty() && sym_file_errors == 0 && mod_file_errors == 0;+    let kicad_parser = match kicad_verdicts.len() {+        0 => Value::Null,+        1 => kicad_verdicts[0].clone(),+        _ => json!(kicad_verdicts),+    };+    let mut output = format!(+        "Lint: {symbol_count} symbol(s), {footprint_count} footprint(s), {} file error(s)",+        sym_file_errors + mod_file_errors+    );+    if !kicad_refused.is_empty() {+        output.push_str(&format!(", KiCad refuses {} file(s)", kicad_refused.len()));+    }+    json!({+        "success": true,+        "ok": ok,+        "kicadParser": kicad_parser,+        "output": output,+        "data": {+            "kind": li.kind,+            "source_path": path.to_string_lossy().to_string(),+            "summary": {+                "symbolCount": symbol_count,+                "footprintCount": footprint_count,+                "symFileErrorCount": sym_file_errors,+                "modFileErrorCount": mod_file_errors,+                "fileFormatVersions": versions,+            },+            "symbols": parsed_sym,+            "footprints": parsed_mod,+        },+        "_hint": hint,+    })+}++// ───────────────────────── kicad_format_upgrade ─────────────────────────++/// The four `kicad-cli <kind> upgrade` kinds.+pub const UPGRADE_KINDS: [&str; 4] = ["pcb", "sch", "sym", "fp"];++/// A caller-supplied `kind`, lower-cased and checked. Empty means auto-detect.+pub fn normalize_kind(kind: &str) -> Option<&'static str> {+    let k = kind.trim().to_lowercase();+    UPGRADE_KINDS.iter().copied().find(|&u| u == k)+}++/// Python auto-detect: by extension for a file (case-sensitive, as the Python was), by+/// contents for a directory (`.pretty` or any `.kicad_mod` means fp, any `.kicad_sym`+/// means sym).+pub fn detect_kind(path: &Path) -> Option<&'static str> {+    if path.is_file() {+        let ext = path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();+        return match ext.as_str() {+            "kicad_pcb" => Some("pcb"),+            "kicad_sch" => Some("sch"),+            "kicad_sym" => Some("sym"),+            "kicad_mod" => Some("fp"),+            _ => None,+        };+    }+    if path.is_dir() {+        if ext_lower(path) == ".pretty" || !glob_ext(path, ".kicad_mod").is_empty() {+            return Some("fp");+        }+        if !glob_ext(path, ".kicad_sym").is_empty() {+            return Some("sym");+        }+    }+    None+}++/// The lint verb to re-run after an upgrade of `kind`. (The Python compared `kind`+/// against "board"/"schematic", which never occur, so it always said lint_library.)+pub fn lint_verb_for(kind: &str) -> &'static str {+    match kind {+        "pcb" => "kicad_lint_board",+        "sch" => "kicad_lint_schematic",+        _ => "kicad_lint_library",+    }+}++/// The `_hint` of `kicad_format_upgrade`.+pub fn upgrade_hint(kind: &str, success: bool, exit_code: i32) -> String {+    if success {+        format!(+            "✅ kicad-cli {kind} upgrade completed. File(s) rewritten to current KiCad format. \+             Any open KiCad GUI windows with this file loaded should be closed + reopened; they \+             have the OLD in-memory state. Re-run {} to confirm the post-upgrade file is clean.",+            lint_verb_for(kind)+        )+    } else {+        format!(+            "❌ kicad-cli {kind} upgrade failed with exitCode={exit_code}. \+             stderr likely names the offending file or syntax issue. If it's a 'file format too new' \+             error, the user's KiCad install is OLDER than the source file; surface to user. \+             If 'file already up to date', pass force:true to resave anyway."+        )+    }+}++/// `handle_format_upgrade` after the arg checks: `kicad-cli <kind> upgrade [--force] <path>`.+/// WARNING: this MUTATES the file in place (kicad-cli has no --dry-run).+pub fn format_upgrade(cli: &Path, path: &Path, kind: &str, force: bool, timeout: Duration) -> Value {+    let path_s = path.to_string_lossy().to_string();+    let mut argv: Vec<&str> = vec![kind, "upgrade"];+    if force {+        argv.push("--force");+    }+    argv.push(&path_s);+    let r = cli::run(cli, &argv, timeout);+    if r.timed_out {+        return json!({+            "success": false,+            "errorCode": "cli_timeout",+            "error": format!("kicad-cli upgrade timed out after {}s", timeout.as_secs()),+            "_hint": "Likely a huge directory of footprints. Re-run with a single file at a time.",+        });+    }+    if r.exit_code == -1 {+        return json!({"success": false, "errorCode": "cli_failed", "error": r.stderr.trim(), "_hint": "kicad-cli could not be started; check kicad_list_versions."});+    }+    let success = r.exit_code == 0;+    json!({+        "success": success,+        "exitCode": r.exit_code,+        "kind": kind,+        "stdout": r.stdout,+        "stderr": r.stderr,+        "source_path": path_s,+        "cliElapsedMs": r.elapsed_ms,+        "_hint": upgrade_hint(kind, success, r.exit_code),+    })+}++// ───────────────────────── tests ─────────────────────────++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn version_and_generator() {+        let head = "(kicad_symbol_lib\n\t(version 20231120)\n\t(generator \"kicad_symbol_editor\")\n";+        assert_eq!(file_version(head), Some(20231120));+        assert_eq!(generator(head), Some("kicad_symbol_editor".into()));+        assert_eq!(generator("(generator eeschema)"), Some("eeschema".into()));+        assert_eq!(file_version("(version)"), None);+        assert_eq!(file_version("(version_x 1)"), None);+        assert_eq!(file_version("(generator_version \"8.0\") (version 20240108)"), Some(20240108));+    }++    #[test]+    fn sniff_pcb_ok() {+        let v = sniff_head("(kicad_pcb (version 20240108) (generator \"pcbnew\")", PCB);+        assert_eq!(v["ok"], json!(true));+        assert_eq!(v["magic"], json!("matched"));+        assert_eq!(v["fileVersion"], json!(20240108));+        assert_eq!(v["expected"], json!("kicad_pcb"));+    }++    #[test]+    fn sniff_wrong_type_and_garbage() {+        let v = sniff_head("  (kicad_sch (version 20231120)", PCB);+        assert_eq!(v["ok"], json!(false));+        assert_eq!(v["actual"], json!("kicad_sch"));+        assert!(v["_hint"].as_str().unwrap().contains("kicad_lint_schematic"));+        assert!(v["_error"].as_str().unwrap().starts_with("file is not a kicad_pcb (first bytes: '(kicad_sch"));+        let g = sniff_head("PK\u{3}\u{4}zip", SCH);+        assert_eq!(g["actual"], json!("non-kicad"));+        assert!(g["_hint"].as_str().unwrap().contains("corrupted"));+        let ok = sniff_head("(kicad_sch (version 20231120)", SCH);+        assert_eq!(ok["ok"], json!(true));+    }++    const DRC: &str = r#"{+      "$schema": "https://schemas.kicad.org/drc.v1.json",+      "kicad_version": "10.0.5",+      "source": "board.kicad_pcb",+      "violations": [+        {"type": "clearance", "severity": "error", "description": "Clearance violation",+         "items": [{"description": "Track [GND]", "pos": {"x": 1.0, "y": 2.0}}]},+        {"type": "silk_over_copper", "severity": "warning", "description": "Silk over pad", "items": []},+        {"type": "courtyards_overlap", "severity": "error", "description": "Courtyards overlap", "items": []}+      ],+      "unconnected_items": [+        {"type": "unconnected_items", "severity": "error", "description": "Missing connection", "items": [{"description": "Pad 1"}]}+      ],+      "schematic_parity": [+        {"type": "net_conflict", "severity": "warning", "description": "Net mismatch", "items": []}+      ]+    }"#;++    #[test]+    fn parse_drc_report() {+        let data: Value = serde_json::from_str(DRC).unwrap();+        let (v, c) = parse_violations(Some(&data));+        assert_eq!(v.len(), 5);+        assert_eq!(c["error"], 3);+        assert_eq!(c["warning"], 2);+        assert_eq!(c["info"], 0);+        assert_eq!(v[0]["items"][0]["pos"]["x"], json!(1.0));+        assert_eq!(v[3]["type"], json!("unconnected_items"));+        assert_eq!(v[3]["items"][0]["pos"], json!({}));+        assert_eq!(top_error_types(&v), vec!["clearance", "courtyards_overlap", "unconnected_items"]);+        let hint = board_hint(&v, 3, 2);+        assert!(hint.starts_with("❌ 3 DRC error(s) + 2 warning(s). Top error types: clearance, courtyards_overlap, unconnected_items."));+        assert!(board_hint(&[], 0, 0).contains("DRC clean"));+        assert!(board_hint(&[], 0, 4).starts_with("⚠️  4 DRC warning(s), 0 errors."));+    }++    const ERC: &str = r#"{+      "$schema": "https://schemas.kicad.org/erc.v1.json",+      "kicad_version": "10.0.5",+      "source": "board.kicad_sch",+      "sheets": [+        {"path": "/", "uuid_path": "/abc", "violations": [+          {"type": "pin_not_connected", "severity": "error", "description": "Pin not connected", "items": [{"description": "U1 pin 3", "pos": {"x": 5, "y": 6}}]},+          {"type": "power_pin_not_driven", "severity": "warning", "description": "Not driven", "items": []}+        ]},+        {"path": "/sub", "uuid_path": "/abc/def", "violations": [+          {"type": "lib_symbol_mismatch", "description": "no severity given", "items": []}+        ]}+      ]+    }"#;++    #[test]+    fn parse_erc_report_walks_sheets() {+        let data: Value = serde_json::from_str(ERC).unwrap();+        let (v, c) = parse_violations(Some(&data));+        assert_eq!(v.len(), 3);+        assert_eq!(c["error"], 1);+        assert_eq!(c["warning"], 2, "missing severity defaults to warning");+        assert!(schematic_hint(&v, 1, 2).starts_with("❌ 1 ERC error(s) + 2 warning(s). Top error types: pin_not_connected."));+        assert!(schematic_hint(&[], 0, 0).contains("ERC clean"));+        assert!(schematic_hint(&[], 0, 1).starts_with("⚠️  1 ERC warning(s), 0 errors."));+        let (none, c0) = parse_violations(None);+        assert!(none.is_empty());+        assert_eq!(c0.len(), 4);+        let (arr, _) = parse_violations(Some(&json!([1, 2])));+        assert!(arr.is_empty());+    }++    #[test]+    fn structure_checks() {+        let good = "(kicad_symbol_lib (version 20231120)\n  (symbol \"R\" (property \"a\" \"x)\"))\n)\n";+        assert!(check_symbol_lib_structure(good).is_ok());+        let (ro, rc, kids) = quote_aware_children(good).unwrap();+        assert_eq!(ro, 0);+        assert_eq!(rc, good.len() - 2);+        assert_eq!(kids.len(), 2);+        assert_eq!(quote_aware_children("(a (b)").unwrap_err(), "unbalanced s-expression: the top-level list never closes");+        assert_eq!(quote_aware_children(") (a)").unwrap_err(), "unbalanced s-expression: extra ')' at offset 0");+        assert!(quote_aware_children("(a) )").is_ok(), "the scan stops at the root's closer; trailing text is the structure check's job");+        assert_eq!(quote_aware_children("(a \"open").unwrap_err(), "unterminated quoted string");+        assert_eq!(quote_aware_children("nothing").unwrap_err(), "no s-expression found");+        assert_eq!(check_symbol_lib_structure("(kicad_symbol_lib (a))\n(junk)").unwrap_err(), "trailing content after the library's closing paren: '(junk)'");+        assert_eq!(check_symbol_lib_structure("(kicad_pcb (a))").unwrap_err(), "not a symbol library (top-level list is 'kicad_pcb')");+        assert!(check_footprint_structure("(footprint \"X\" (pad \"1\" smd rect))").is_ok());+        assert!(check_footprint_structure("(footprint \"X\") extra").unwrap_err().starts_with("trailing content after the footprint's closing paren"));+    }++    #[test]+    fn inventory_of_symbol_lib() {+        let text = r#"(kicad_symbol_lib+	(version 20231120)+	(generator "kicad_symbol_editor")+	(symbol "R" (pin_numbers hide)+		(property "Reference" "R")+		(symbol "R_0_1" (rectangle (start 0 0) (end 1 1)))+		(symbol "R_1_1"+			(pin passive line (at 0 3.81 270) (length 1.27))+			(pin passive line (at 0 -3.81 90) (length 1.27))+		)+	)+	(symbol "OpAmp_Dual"+		(symbol "OpAmp_Dual_1_1" (pin input line (at 0 0 0)))+		(symbol "OpAmp_Dual_2_1" (pin input line (at 0 0 0)))+		(symbol "OpAmp_Dual_3_1" (pin power_in line (at 0 0 0)))+	)+)+"#;+        let v = parse_kicad_sym(text);+        assert_eq!(v["symbolCount"], json!(2));+        assert_eq!(v["fileVersion"], json!(20231120));+        assert_eq!(v["generator"], json!("kicad_symbol_editor"));+        assert_eq!(v["symbols"][0]["name"], json!("R"));+        // The Python counts pins in a 5000-char window from the symbol, which here runs+        // into OpAmp_Dual's three pins: 2 + 3. Same over-count, on purpose.+        assert_eq!(v["symbols"][0]["pinCount"], json!(5));+        assert_eq!(v["symbols"][0]["unitCount"], json!(2), "units 0 and 1, as the Python's _N_M set counts them");+        assert_eq!(v["symbols"][1]["name"], json!("OpAmp_Dual"));+        assert_eq!(v["symbols"][1]["pinCount"], json!(3));+        assert_eq!(v["symbols"][1]["unitCount"], json!(3));+        assert!(ends_with_unit_suffix("R_0_1"));+        assert!(!ends_with_unit_suffix("R_0"));+        assert!(!ends_with_unit_suffix("LM358"));+        assert!(sym_magic_ok("\n(kicad_symbol_lib"));+        assert!(!sym_magic_ok("(kicad_pcb"));+    }++    #[test]+    fn inventory_of_footprint() {+        let text = r#"(footprint "R_0603_1608Metric" (version 20240108) (generator "pcbnew")+	(layer "F.Cu")+	(fp_line (start -1 -1) (end 1 1) (layer "F.SilkS"))+	(pad "1" smd roundrect (at -0.8 0) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask"))+	(pad "2" smd roundrect (at 0.8 0) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask"))+)+"#;+        let v = parse_kicad_mod(text);+        assert_eq!(v["name"], json!("R_0603_1608Metric"));+        assert_eq!(v["padCount"], json!(2));+        assert_eq!(v["layers"], json!(["F.Cu", "F.SilkS"]));+        assert_eq!(v["fileVersion"], json!(20240108));+        assert_eq!(v["generator"], json!("pcbnew"));+        let legacy = parse_kicad_mod("(module R_0805 (layer F.Cu) (tedit 5A1)\n  (pad 1 smd rect (at 0 0))\n)");+        assert_eq!(legacy["name"], json!("R_0805"));+        assert_eq!(legacy["padCount"], json!(1));+        assert_eq!(legacy["layers"], json!(["F.Cu"]));+        assert_eq!(legacy["fileVersion"], Value::Null);+        assert!(mod_magic_ok("(module X"));+        assert!(!mod_magic_ok("(kicad_symbol_lib"));+    }++    #[test]+    fn plot_lines_from_sym_export() {+        let text = "Plotting symbol 'R' unit 1\nPlotting symbol 'OpAmp' unit 1\nPlotting symbol 'OpAmp' unit 2\nDone.\n";+        let (names, units) = parse_plot_lines(text);+        assert_eq!(names, vec!["R", "OpAmp"]);+        assert_eq!(units, 3);+        assert_eq!(plot_line_name("Plotting symbol 'X' unit x"), None);+        assert_eq!(parse_plot_lines("Unable to load library").0.len(), 0);+    }++    #[test]+    fn library_hint_tiers() {+        let refused = vec![json!({"path": "C:/libs/Adom.kicad_sym", "error": "Unable to load library", "available": true, "ok": false})];+        let h = library_hint(&refused, 0, 0, 5, 0, &[20231120], &refused);+        assert!(h.starts_with("❌ KICAD REFUSES 1 symbol file(s) that the bridge's structural check passed: Adom.kicad_sym: Unable to load library."));+        let h = library_hint(&[], 1, 2, 0, 0, &[], &[]);+        assert!(h.starts_with("❌ 1 symbol file(s) + 2 footprint file(s) failed to parse."));+        let h = library_hint(&[], 0, 0, 3, 1, &[20211014, 20231120], &[]);+        assert!(h.starts_with("⚠️  3 symbol(s) + 1 footprint(s) parsed cleanly, BUT file-format version(s) [20211014] are pre-KiCad-7."));+        let ok = vec![json!({"available": true, "ok": true, "symbols": ["A", "B"]})];+        let h = library_hint(&[], 0, 0, 2, 0, &[20231120], &ok);+        assert!(h.starts_with("✅ KiCad's own parser loads 2 symbol(s) from 1 file(s) (kicadParser); the bridge's structural check counts 2 symbol(s) + 0 footprint(s), format version(s) [20231120]."));+        let na = vec![json!({"available": false, "ok": null, "symbols": []})];+        let h = library_hint(&[], 0, 0, 2, 0, &[], &na);+        assert!(h.contains("format version(s) unspecified. KiCad's own parser was NOT consulted"));+    }++    #[test]+    fn upgrade_kind_and_hint() {+        assert_eq!(normalize_kind("PCB"), Some("pcb"));+        assert_eq!(normalize_kind(" fp "), Some("fp"));+        assert_eq!(normalize_kind("board"), None);+        assert_eq!(normalize_kind(""), None);+        assert!(upgrade_hint("pcb", true, 0).contains("Re-run kicad_lint_board to confirm"));+        assert!(upgrade_hint("sch", true, 0).contains("Re-run kicad_lint_schematic to confirm"));+        assert!(upgrade_hint("sym", true, 0).contains("Re-run kicad_lint_library to confirm"));+        assert!(upgrade_hint("fp", false, 2).starts_with("❌ kicad-cli fp upgrade failed with exitCode=2."));+    }++    #[test]+    fn detect_kind_and_discover_on_disk() {+        let dir = std::env::temp_dir().join(format!("kicad-lint-test-{}", std::process::id()));+        let pretty = dir.join("Test.pretty");+        std::fs::create_dir_all(&pretty).unwrap();+        std::fs::write(pretty.join("R.kicad_mod"), "(footprint \"R\" (version 20240108) (pad \"1\" smd rect))\n").unwrap();+        std::fs::write(dir.join("Lib.kicad_sym"), "(kicad_symbol_lib (version 20231120)\n  (symbol \"R\" (pin passive line))\n)\n").unwrap();+        std::fs::write(dir.join("board.kicad_pcb"), "(kicad_pcb (version 20240108))\n").unwrap();+        std::fs::write(dir.join("notes.txt"), "hello").unwrap();++        assert_eq!(detect_kind(&pretty), Some("fp"));+        assert_eq!(detect_kind(&dir), Some("sym"));+        assert_eq!(detect_kind(&dir.join("board.kicad_pcb")), Some("pcb"));+        assert_eq!(detect_kind(&dir.join("Lib.kicad_sym")), Some("sym"));+        assert_eq!(detect_kind(&dir.join("notes.txt")), None);++        let li = discover_library(&pretty).unwrap();+        assert_eq!(li.kind, "footprint_lib_dir");+        assert_eq!(li.mod_files.len(), 1);+        let li = discover_library(&dir).unwrap();+        assert_eq!(li.kind, "mixed_dir");+        assert_eq!((li.sym_files.len(), li.mod_files.len()), (1, 0));+        assert_eq!(discover_library(&dir.join("Lib.kicad_sym")).unwrap().kind, "symbol_lib_file");+        assert!(discover_library(&dir.join("notes.txt")).unwrap_err().0.starts_with("unsupported file extension: .txt"));+        assert!(discover_library(&dir.join("missing")).unwrap_err().0.starts_with("path not found"));++        // Full library lint with no kicad-cli: structural verdict only, hint says KiCad was not asked.+        let v = lint_library(&dir, None, Duration::from_secs(1));+        assert_eq!(v["success"], json!(true));+        assert_eq!(v["ok"], json!(true));+        assert_eq!(v["data"]["summary"]["symbolCount"], json!(1));+        assert_eq!(v["data"]["summary"]["fileFormatVersions"], json!([20231120]));+        assert_eq!(v["kicadParser"]["available"], json!(false));+        assert!(v["_hint"].as_str().unwrap().contains("NOT consulted"));+        assert_eq!(v["data"]["symbols"][0]["structuralCheck"], json!("balanced"));++        // A broken library is a file error, not a pass.+        std::fs::write(dir.join("Bad.kicad_sym"), "(kicad_symbol_lib (symbol \"R\")\n").unwrap();+        let v = lint_library(&dir.join("Bad.kicad_sym"), None, Duration::from_secs(1));+        assert_eq!(v["ok"], json!(false));+        assert_eq!(v["data"]["summary"]["symFileErrorCount"], json!(1));+        assert!(v["data"]["symbols"][0]["_error"].as_str().unwrap().starts_with("structural: unbalanced"));+        assert!(v["_hint"].as_str().unwrap().starts_with("❌ 1 symbol file(s) + 0 footprint file(s) failed to parse."));++        // Sniff on disk.+        let s = file_format_sniff(&dir.join("board.kicad_pcb"), PCB);+        assert_eq!(s["ok"], json!(true));+        assert_eq!(file_format_sniff(&dir.join("nope.kicad_pcb"), PCB)["ok"], json!(false));+        assert_eq!(file_format_sniff(&dir, PCB)["_error"], json!(format!("not a file: {}", dir.display())));++        let _ = std::fs::remove_dir_all(&dir);+    }+}
rust/crates/kicad-core/src/model_check.rsadded+928
@@ -0,0 +1,928 @@+//! kicad_model_check: read-only 3D model diagnostics for a footprint or a board.+//!+//! Port of handlers/model_check.py. A footprint editor that renders pads and no body+//! is a silent failure: the 3D viewer does not complain. This says, per footprint,+//! which `(model ...)` references exist, where KiCad would look for each file,+//! whether the file exists, whether it looks like a real STEP/VRML with geometry, and+//! what is still unverified (the render itself). Nothing here touches a window.+//!+//! KiCad's resolver (FILENAME_RESOLVER::ResolvePath): an absolute path is used as is;+//! `${VAR}` / `$(VAR)` expand from the environment and Configure Paths; anything else+//! is tried against the project directory (KIPRJMOD) and then each 3D search path+//! (KICAD<n>_3DMODEL_DIR, KICAD<n>_3RD_PARTY, system.extra_3d_search_dirs). It never+//! looks next to the .kicad_mod file, which is the trap in #88.+//!+//! This file also carries the tiny s-expression reader shared by the maintenance+//! group (footprint `(model ...)` clauses, board footprints, fp-lib-table rows) and+//! the `Ctx` path context (version, base dir, config dir, user dir, kicad_common.json).++use std::path::{Path, PathBuf};++use serde_json::{json, Map, Value};++use crate::detect::KicadInfo;++// ── Tiny s-expression reader ──────────────────────────────────────────────────++/// One node of a KiCad s-expression: a bare or quoted atom, or a parenthesised list.+#[derive(Clone, Debug, PartialEq)]+pub enum Sx {+    Atom(String),+    List(Vec<Sx>),+}++impl Sx {+    pub fn as_atom(&self) -> Option<&str> {+        match self {+            Sx::Atom(s) => Some(s),+            Sx::List(_) => None,+        }+    }+    pub fn as_list(&self) -> Option<&[Sx]> {+        match self {+            Sx::List(v) => Some(v),+            Sx::Atom(_) => None,+        }+    }+    /// The list's tag: its first element when that is an atom.+    pub fn tag(&self) -> Option<&str> {+        self.as_list().and_then(|v| v.first()).and_then(Sx::as_atom)+    }+    /// The i-th element (0 is the tag) as an atom.+    pub fn atom_at(&self, i: usize) -> Option<&str> {+        self.as_list().and_then(|v| v.get(i)).and_then(Sx::as_atom)+    }+    /// The first direct child list tagged `tag`.+    pub fn find(&self, tag: &str) -> Option<&Sx> {+        self.as_list()?.iter().find(|c| c.tag() == Some(tag))+    }+    /// Every direct child list tagged `tag`.+    pub fn find_all<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a Sx> + 'a {+        self.as_list().into_iter().flatten().filter(move |c| c.tag() == Some(tag))+    }+    /// `(tag "value" ...)` -> "value".+    pub fn value(&self) -> Option<&str> {+        self.atom_at(1)+    }+}++/// Tokens are "(", ")" or an atom (quotes stripped, `\"` and `\\` unescaped),+/// matching parsers/sexpr.py.+fn tokenize(text: &str) -> Vec<String> {+    let mut out = Vec::new();+    let mut chars = text.chars().peekable();+    while let Some(c) = chars.next() {+        match c {+            '(' => out.push("(".into()),+            ')' => out.push(")".into()),+            '"' => {+                let mut s = String::new();+                loop {+                    match chars.next() {+                        None => break,+                        Some('"') => break,+                        Some('\\') => match chars.next() {+                            Some('"') => s.push('"'),+                            Some('\\') => s.push('\\'),+                            Some(o) => {+                                s.push('\\');+                                s.push(o);+                            }+                            None => s.push('\\'),+                        },+                        Some(o) => s.push(o),+                    }+                }+                out.push(s);+            }+            c if c.is_whitespace() => {}+            c => {+                let mut s = String::new();+                s.push(c);+                while let Some(&n) = chars.peek() {+                    if n.is_whitespace() || n == '(' || n == ')' {+                        break;+                    }+                    s.push(n);+                    chars.next();+                }+                out.push(s);+            }+        }+    }+    out+}++fn parse_expr(tokens: &[String], pos: &mut usize) -> Sx {+    if tokens[*pos] == "(" {+        *pos += 1;+        let mut children = Vec::new();+        while *pos < tokens.len() && tokens[*pos] != ")" {+            children.push(parse_expr(tokens, pos));+        }+        if *pos < tokens.len() {+            *pos += 1;+        }+        Sx::List(children)+    } else {+        let a = Sx::Atom(tokens[*pos].clone());+        *pos += 1;+        a+    }+}++/// Parse the first top-level expression. None on empty input.+pub fn parse_sexpr(text: &str) -> Option<Sx> {+    let tokens = tokenize(text);+    if tokens.is_empty() {+        return None;+    }+    let mut pos = 0;+    Some(parse_expr(&tokens, &mut pos))+}++// ── Path context ──────────────────────────────────────────────────────────────++/// The KiCad facts the maintenance verbs need, resolved once per call. Tests build+/// one by hand; the verbs build it from detection with `Ctx::from_info`.+#[derive(Clone, Debug, Default)]+pub struct Ctx {+    /// Full version when known (kicad-cli X.Y.Z), else major.minor.+    pub version: String,+    /// KiCad install root (the parent of bin/), if installed.+    pub base_dir: Option<PathBuf>,+    /// Per-user settings dir (kicad_common.json, fp-lib-table, installed_packages.json).+    pub config_dir: Option<PathBuf>,+    /// Documents/KiCad/<major.minor>: footprints/, symbols/, 3rdparty/.+    pub user_dir: Option<PathBuf>,+    /// Parsed kicad_common.json, or Null when absent.+    pub common: Value,+}++impl Ctx {+    pub fn from_info(info: &KicadInfo) -> Ctx {+        let plat = kicad_platform::native();+        let inst = info.primary();+        let version = inst+            .map(|i| if i.cli_version.is_empty() { i.version.clone() } else { i.cli_version.clone() })+            .unwrap_or_default();+        let mm = kicad_platform::major_minor(&version);+        let config_dir = if mm.is_empty() { None } else { plat.config_dir(&mm) };+        let user_dir = if mm.is_empty() { None } else { plat.user_dir(&mm) };+        let common = config_dir+            .as_ref()+            .and_then(|c| std::fs::read_to_string(c.join("kicad_common.json")).ok())+            .and_then(|s| serde_json::from_str::<Value>(&s).ok())+            .unwrap_or(Value::Null);+        Ctx {+            version,+            base_dir: inst.map(|i| PathBuf::from(&i.base_dir)),+            config_dir,+            user_dir,+            common,+        }+    }+    pub fn major(&self) -> String {+        self.version.split('.').next().unwrap_or("").to_string()+    }+    /// kicad_common.json environment.vars (Configure Paths overrides).+    pub fn env_vars(&self) -> Map<String, Value> {+        self.common+            .get("environment")+            .and_then(|e| e.get("vars"))+            .and_then(Value::as_object)+            .cloned()+            .unwrap_or_default()+    }+    fn env_str(&self, key: &str) -> Option<String> {+        self.env_vars().get(key).and_then(Value::as_str).filter(|s| !s.is_empty()).map(|s| s.to_string())+    }+    /// The 3rdparty root KiCad uses, in KiCad's priority: kicad_common.json override+    /// (KICAD<n>_3RD_PARTY, or the older KICAD_3RD_PARTY spelling), else <user_dir>/3rdparty.+    /// Returns (path, how it was resolved).+    pub fn third_party_dir(&self) -> Option<(PathBuf, &'static str)> {+        let major = self.major();+        if !major.is_empty() {+            if let Some(v) = self.env_str(&format!("KICAD{major}_3RD_PARTY")) {+                return Some((PathBuf::from(v), "kicad_common.json override"));+            }+        }+        if let Some(v) = self.env_str("KICAD_3RD_PARTY") {+            return Some((PathBuf::from(v), "kicad_common.json override"));+        }+        self.user_dir.as_ref().map(|u| (u.join("3rdparty"), "platform user_dir (Documents/KiCad/<ver>/3rdparty)"))+    }+    /// Stock library roots and the variables KiCad defines for them.+    fn stock_subs(&self) -> Vec<(String, String)> {+        let major = self.major();+        let mut subs = Vec::new();+        if let Some(base) = &self.base_dir {+            let share = base.join("share").join("kicad");+            subs.push((format!("KICAD{major}_3DMODEL_DIR"), norm(&share.join("3dmodels"))));+            subs.push((format!("KICAD{major}_FOOTPRINT_DIR"), norm(&share.join("footprints"))));+            subs.push((format!("KICAD{major}_SYMBOL_DIR"), norm(&share.join("symbols"))));+            subs.push((format!("KICAD{major}_TEMPLATE_DIR"), norm(&share.join("template"))));+        }+        if let Some(u) = &self.user_dir {+            subs.push(("KICAD_USER_TEMPLATE_DIR".into(), norm(&u.join("template"))));+        }+        if let Some((third, _)) = self.third_party_dir() {+            subs.push((format!("KICAD{major}_3RD_PARTY"), norm(&third)));+            subs.push(("KICAD_3RD_PARTY".into(), norm(&third)));+        }+        for (k, v) in self.env_vars() {+            if let Some(s) = v.as_str() {+                subs.push((k, s.replace('\\', "/")));+            }+        }+        subs+    }+    /// The directories KiCad tries for a relative model path, in its order.+    pub fn search_dirs(&self, project_dir: Option<&str>) -> Vec<String> {+        let mut dirs = Vec::new();+        if let Some(p) = project_dir {+            dirs.push(p.to_string());+        }+        let major = self.major();+        let stock = self+            .env_str(&format!("KICAD{major}_3DMODEL_DIR"))+            .or_else(|| self.base_dir.as_ref().map(|b| norm(&b.join("share").join("kicad").join("3dmodels"))));+        if let Some(s) = stock {+            dirs.push(s);+        }+        if let Some((third, _)) = self.third_party_dir() {+            dirs.push(norm(&third));+        }+        if let Some(extra) = self.common.get("system").and_then(|s| s.get("extra_3d_search_dirs")).and_then(Value::as_array) {+            for d in extra {+                if let Some(s) = d.as_str() {+                    if !s.is_empty() {+                        dirs.push(s.to_string());+                    }+                }+            }+        }+        dirs.into_iter().map(|d| d.replace('\\', "/")).collect()+    }+    /// Expand `${VAR}` / `$(VAR)` the way KiCad does. Returns (expanded, unresolved vars).+    pub fn expand(&self, path: &str, project_dir: Option<&str>) -> (String, Vec<String>) {+        let mut p = path.replace('\\', "/");+        if p.contains("$(") {+            p = p.replace("$(", "${").replace(')', "}");+        }+        if let Some(pd) = project_dir {+            if p.contains("${KIPRJMOD}") {+                p = p.replace("${KIPRJMOD}", &pd.replace('\\', "/"));+            }+        }+        if !p.contains("${") {+            return (p, Vec::new());+        }+        for (k, v) in self.stock_subs() {+            p = p.replace(&format!("${{{k}}}"), &v);+        }+        (p.clone(), unresolved_vars(&p))+    }+    /// Expand an fp-lib-table / sym-lib-table uri. KIPRJMOD and unknown variables are+    /// refused (None) rather than guessed, like adom_library._expand_kicad_uri.+    pub fn expand_lib_uri(&self, uri: &str) -> Option<String> {+        let mut out = uri.replace('\\', "/");+        if out.contains("${KIPRJMOD}") {+            return None;+        }+        for (k, v) in self.stock_subs() {+            out = out.replace(&format!("${{{k}}}"), &v);+        }+        if out.contains("${") {+            return None;+        }+        Some(out)+    }+    /// The .pretty directory behind a footprint library nickname: the user's+    /// fp-lib-table first (URIs expanded), then unregistered <user_dir>/footprints/<lib>.pretty.+    pub fn footprint_library_dir(&self, library: &str) -> Option<PathBuf> {+        if let Some(cfg) = &self.config_dir {+            if let Ok(text) = std::fs::read_to_string(cfg.join("fp-lib-table")) {+                if let Some(tree) = parse_sexpr(&text) {+                    for row in tree.find_all("lib") {+                        let name = row.find("name").and_then(Sx::value).unwrap_or("");+                        if !name.eq_ignore_ascii_case(library) {+                            continue;+                        }+                        let uri = row.find("uri").and_then(Sx::value).unwrap_or("");+                        if let Some(resolved) = self.expand_lib_uri(uri) {+                            let p = PathBuf::from(resolved);+                            if p.is_dir() {+                                return Some(p);+                            }+                        }+                    }+                }+            }+        }+        if let Some(u) = &self.user_dir {+            let root = u.join("footprints");+            if let Ok(rd) = std::fs::read_dir(&root) {+                for e in rd.flatten() {+                    let p = e.path();+                    let stem = p.file_name().and_then(|s| s.to_str()).and_then(|s| s.strip_suffix(".pretty"));+                    if let Some(stem) = stem {+                        if stem.eq_ignore_ascii_case(library) && p.is_dir() {+                            return Some(p);+                        }+                    }+                }+            }+        }+        None+    }+}++fn unresolved_vars(p: &str) -> Vec<String> {+    let mut out = Vec::new();+    let mut rest = p;+    while let Some(i) = rest.find("${") {+        let after = &rest[i + 2..];+        match after.find('}') {+            Some(j) => {+                out.push(after[..j].to_string());+                rest = &after[j + 1..];+            }+            None => break,+        }+    }+    out+}++pub fn norm(p: &Path) -> String {+    p.to_string_lossy().replace('\\', "/")+}++fn is_absolute(p: &str) -> bool {+    // Accept both OS conventions regardless of the host: the check runs on the desktop+    // that owns the file, but a Windows path in a Linux test must still count.+    Path::new(p).is_absolute() || p.starts_with('/') || (p.len() >= 3 && p.as_bytes()[1] == b':' && (p.as_bytes()[2] == b'/' || p.as_bytes()[2] == b'\\'))+}++// ── Model rows ────────────────────────────────────────────────────────────────++const NO_MODEL_EXPECTED: &[&str] = &[+    "testpoint", "fiducial", "mountinghole", "solderjumper", "jumper", "net-tie", "nettie", "logo", "symbol", "marking", "hole_",+];++fn count_sub(hay: &[u8], needle: &[u8]) -> usize {+    if needle.is_empty() || hay.len() < needle.len() {+        return 0;+    }+    let mut n = 0;+    let mut i = 0;+    while i + needle.len() <= hay.len() {+        if &hay[i..i + needle.len()] == needle {+            n += 1;+            i += needle.len();+        } else {+            i += 1;+        }+    }+    n+}++/// Cheap format check: is this a STEP or VRML with something inside?+fn sniff(path: &Path) -> Map<String, Value> {+    let mut out = Map::new();+    out.insert("bytes".into(), Value::Null);+    out.insert("format".into(), Value::Null);+    out.insert("geometryHints".into(), json!(0));+    let (size, body) = match std::fs::metadata(path).and_then(|m| {+        use std::io::Read;+        let f = std::fs::File::open(path)?;+        let mut body = Vec::new();+        f.take(4 * 1024 * 1024).read_to_end(&mut body)?;+        Ok((m.len(), body))+    }) {+        Ok(v) => v,+        Err(e) => {+            let msg: String = e.to_string().chars().take(120).collect();+            out.insert("readError".into(), json!(msg));+            return out;+        }+    };+    out.insert("bytes".into(), json!(size));+    let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).unwrap_or_default();+    if body.starts_with(b"ISO-10303-21") {+        out.insert("format".into(), json!("step"));+        let hints = count_sub(&body, b"MANIFOLD_SOLID_BREP") + count_sub(&body, b"CLOSED_SHELL") + count_sub(&body, b"ADVANCED_FACE");+        out.insert("geometryHints".into(), json!(hints));+        let tail = &body[body.len().saturating_sub(4096)..];+        let terminated = count_sub(tail, b"END-ISO-10303-21") > 0 || size > body.len() as u64;+        out.insert("terminated".into(), json!(terminated));+    } else if body.starts_with(b"#VRML") {+        out.insert("format".into(), json!("vrml"));+        let hints = count_sub(&body, b"IndexedFaceSet") + count_sub(&body, b"Shape");+        out.insert("geometryHints".into(), json!(hints));+    } else if body.starts_with(&[0x1f, 0x8b]) && ext == "wrz" {+        out.insert("format".into(), json!("vrml-gzip"));+        out.insert("geometryHints".into(), Value::Null);+    } else {+        out.insert("format".into(), json!("unknown"));+    }+    out+}++fn xyz(node: &Sx, name: &str) -> Value {+    let Some(n) = node.find(name) else { return Value::Null };+    let Some(x) = n.find("xyz") else { return Value::Null };+    let Some(l) = x.as_list() else { return Value::Null };+    if l.len() < 4 {+        return Value::Null;+    }+    let mut v = Vec::new();+    for item in &l[1..4] {+        match item.as_atom().and_then(|s| s.parse::<f64>().ok()) {+            Some(f) => v.push(f),+            None => return Value::Null,+        }+    }+    json!(v)+}++fn warn(code: &str, hint: String) -> Value {+    json!({"code": code, "hint": hint})+}++fn model_rows(fp: &Sx, ctx: &Ctx, project_dir: Option<&str>) -> Vec<Value> {+    let mut rows = Vec::new();+    for m in fp.find_all("model") {+        let raw = m.atom_at(1).unwrap_or("").to_string();+        let mut hidden = false;+        for child in m.as_list().map(|l| l.iter().skip(2)).into_iter().flatten() {+            match child {+                Sx::Atom(a) if a == "hide" => hidden = true,+                Sx::List(_) if child.tag() == Some("hide") => {+                    let v = child.atom_at(1).unwrap_or("").to_ascii_lowercase();+                    hidden = matches!(v.as_str(), "yes" | "true" | "1");+                }+                _ => {}+            }+        }+        let mut row = Map::new();+        row.insert("reference".into(), json!(raw));+        row.insert("hidden".into(), json!(hidden));+        row.insert("offset".into(), xyz(m, "offset"));+        row.insert("scale".into(), xyz(m, "scale"));+        row.insert("rotate".into(), xyz(m, "rotate"));+        row.insert("resolved".into(), Value::Null);+        row.insert("exists".into(), json!(false));+        let mut warnings: Vec<Value> = Vec::new();+        let (expanded, unresolved) = ctx.expand(&raw, project_dir);+        if !unresolved.is_empty() {+            warnings.push(json!({"code": "unresolved_variable", "vars": unresolved,+                "hint": "KiCad cannot expand this variable on this box (Preferences > Configure Paths, or KIPRJMOD outside a project)."}));+        }+        let mut candidates: Vec<String> = Vec::new();+        let absolute = is_absolute(&expanded);+        if !unresolved.is_empty() {+        } else if absolute {+            candidates.push(expanded.clone());+        } else {+            let dirs = ctx.search_dirs(project_dir);+            candidates = dirs.iter().map(|d| format!("{}/{}", d.trim_end_matches('/'), expanded)).collect();+            row.insert("triedDirs".into(), json!(dirs));+        }+        let mut exists = false;+        for c in &candidates {+            if Path::new(c).is_file() {+                row.insert("resolved".into(), json!(c));+                row.insert("exists".into(), json!(true));+                exists = true;+                break;+            }+        }+        if !exists && unresolved.is_empty() {+            if !candidates.is_empty() && !absolute {+                warnings.push(warn("not_found_relative", format!(+                    "A bare relative model path is tried against the project directory and the 3D search paths, never next to the .kicad_mod. Use ${{KICAD{}_3DMODEL_DIR}}/<lib>.3dshapes/<file>.step, a Configure Paths variable, or an absolute path.",+                    ctx.major())));+            } else {+                warnings.push(warn("file_missing", format!("No file at {expanded}.")));+            }+        }+        if exists {+            let resolved = row.get("resolved").and_then(Value::as_str).unwrap_or("").to_string();+            let sn = sniff(Path::new(&resolved));+            let read_error = sn.get("readError").and_then(Value::as_str).map(|s| s.to_string());+            let format = sn.get("format").and_then(Value::as_str).map(|s| s.to_string());+            let hints = sn.get("geometryHints").cloned();+            for (k, v) in sn {+                row.insert(k, v);+            }+            if let Some(e) = read_error {+                warnings.push(warn("unreadable", e));+            } else if format.as_deref() == Some("unknown") {+                warnings.push(warn("not_step_or_vrml", "The file does not start like a STEP (ISO-10303-21) or VRML (#VRML) file; KiCad will draw nothing for it.".into()));+            } else if hints.as_ref().and_then(Value::as_u64) == Some(0) {+                warnings.push(warn("empty_geometry", "The file parses as STEP/VRML but declares no solids or faces.".into()));+            }+        }+        if let Some(sc) = row.get("scale").and_then(Value::as_array) {+            let vals: Vec<f64> = sc.iter().filter_map(Value::as_f64).collect();+            if vals.iter().any(|v| *v == 0.0) || vals.iter().any(|v| v.abs() > 100.0) {+                warnings.push(warn("implausible_scale", format!("scale {}: a zero or >100 factor makes the body invisible or huge.", fmt_list(&vals))));+            }+        }+        if hidden {+            warnings.push(warn("hidden", "This model is marked hidden in the footprint; the viewer will not draw it.".into()));+        }+        row.insert("warnings".into(), Value::Array(warnings));+        rows.push(Value::Object(row));+    }+    rows+}++/// Python-style list rendering for the scale hint: `[0.0, 1.0, 1.0]`.+fn fmt_list(v: &[f64]) -> String {+    let parts: Vec<String> = v.iter().map(|f| if f.fract() == 0.0 && f.abs() < 1e15 { format!("{f:.1}") } else { f.to_string() }).collect();+    format!("[{}]", parts.join(", "))+}++fn attrs(fp: &Sx) -> Vec<String> {+    fp.find("attr")+        .and_then(Sx::as_list)+        .map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(|s| s.to_string()).collect())+        .unwrap_or_default()+}++fn no_model_expected(fp_id: &str, attrs: &[String]) -> bool {+    let low = fp_id.to_ascii_lowercase();+    if NO_MODEL_EXPECTED.iter().any(|k| low.contains(k)) {+        return true;+    }+    let has = |a: &str| attrs.iter().any(|x| x == a);+    has("board_only") || (has("exclude_from_bom") && has("exclude_from_pos_files"))+}++fn tag_warnings(rows: &[Value]) -> Vec<Value> {+    let mut out = Vec::new();+    for r in rows {+        let model = r.get("reference").cloned().unwrap_or(Value::Null);+        for w in r.get("warnings").and_then(Value::as_array).into_iter().flatten() {+            let mut w = w.clone();+            if let Some(o) = w.as_object_mut() {+                o.insert("model".into(), model.clone());+            }+            out.push(w);+        }+    }+    out+}++/// Diagnose one footprint file (already located). `fp_id` is "Library:Name".+pub fn check_footprint_file(ctx: &Ctx, fp_id: &str, path: &Path, project_dir: Option<&str>) -> Result<Value, String> {+    let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", norm(path)))?;+    let tree = parse_sexpr(&text).ok_or_else(|| "Empty S-expression".to_string())?;+    let fp_node = if tree.tag() == Some("footprint") { tree.clone() } else { tree.find("footprint").cloned().unwrap_or(Sx::List(vec![])) };+    let rows = model_rows(&fp_node, ctx, project_dir);+    let attrs = attrs(&fp_node);+    let mut warnings = tag_warnings(&rows);+    let note;+    if rows.is_empty() {+        if no_model_expected(fp_id, &attrs) {+            note = "no model reference, and none expected for this kind of footprint".to_string();+        } else {+            warnings.push(json!({"code": "no_model_reference", "model": null,+                "hint": "The footprint carries no (model ...) clause, so the 3D viewer shows pads only. Attach one (kicad_install_footprint with model, or edit the footprint)."}));+            note = "no model reference".to_string();+        }+    } else {+        let good = rows+            .iter()+            .filter(|r| r.get("exists").and_then(Value::as_bool).unwrap_or(false) && r.get("warnings").and_then(Value::as_array).map(|w| w.is_empty()).unwrap_or(true))+            .count();+        note = format!("{good} of {} model reference(s) resolve to a readable STEP/VRML with geometry", rows.len());+    }+    let (library, footprint) = fp_id.split_once(':').unwrap_or(("", fp_id));+    Ok(json!({+        "success": true,+        "footprint": fp_id,+        "file": norm(path),+        "attrs": attrs,+        "models": rows,+        "ok": warnings.is_empty(),+        "warnings": warnings,+        "renderVerified": false,+        "note": note,+        "nextCheck": format!("kicad_show_3d_chip {{\"library\":\"{library}\",\"footprintName\":\"{footprint}\"}} and read the frame it returns: a body over the pads is the only proof KiCad drew the model."),+    }))+}++/// kicad_model_check {library, footprintName}.+pub fn check_footprint(ctx: &Ctx, library: &str, footprint: &str, project_dir: Option<&str>) -> Value {+    let Some(pretty) = ctx.footprint_library_dir(library) else {+        return json!({"success": false, "errorCode": "library_not_found",+            "error": format!("No library named '{library}' in the fp-lib-table or user footprints dir")});+    };+    let path = pretty.join(format!("{footprint}.kicad_mod"));+    if !path.is_file() {+        return json!({"success": false, "errorCode": "footprint_not_found", "error": format!("{} does not exist", norm(&path))});+    }+    match check_footprint_file(ctx, &format!("{library}:{footprint}"), &path, project_dir) {+        Ok(v) => v,+        Err(e) => json!({"success": false, "errorCode": "model_check_failed", "error": e.chars().take(200).collect::<String>()}),+    }+}++/// kicad_model_check {boardPath}.+pub fn check_board(ctx: &Ctx, board_path: &str, limit: usize) -> Value {+    let p = Path::new(board_path);+    if !p.is_file() {+        return json!({"success": false, "errorCode": "board_not_found", "error": format!("{board_path} does not exist")});+    }+    let text = match std::fs::read_to_string(p) {+        Ok(t) => t,+        Err(e) => return json!({"success": false, "errorCode": "model_check_failed", "error": e.to_string().chars().take(200).collect::<String>()}),+    };+    let Some(tree) = parse_sexpr(&text) else {+        return json!({"success": false, "errorCode": "model_check_failed", "error": "Empty S-expression"});+    };+    let project_dir = p.parent().map(norm).unwrap_or_default();+    let mut per = Vec::new();+    let (mut footprints, mut with_model, mut no_model_expected_n, mut missing, mut unresolved, mut nwarn) = (0u64, 0u64, 0u64, 0u64, 0u64, 0u64);+    for fp in tree.find_all("footprint") {+        let fp_id = fp.atom_at(1).unwrap_or("?").to_string();+        let mut reference = String::new();+        for prop in fp.find_all("property") {+            if prop.atom_at(1) == Some("Reference") {+                if let Some(v) = prop.atom_at(2) {+                    reference = v.to_string();+                }+            }+        }+        let rows = model_rows(fp, ctx, Some(&project_dir));+        let a = attrs(fp);+        footprints += 1;+        let mut w = tag_warnings(&rows);+        if !rows.is_empty() {+            with_model += 1;+        } else if no_model_expected(&fp_id, &a) {+            no_model_expected_n += 1;+        } else {+            missing += 1;+            w.push(json!({"code": "no_model_reference", "model": null, "hint": "no (model ...) clause"}));+        }+        if w.iter().any(|x| matches!(x.get("code").and_then(Value::as_str), Some("unresolved_variable" | "not_found_relative" | "file_missing"))) {+            unresolved += 1;+        }+        nwarn += w.len() as u64;+        if !w.is_empty() && per.len() < limit {+            let models: Vec<Value> = rows+                .iter()+                .map(|r| json!({"reference": r.get("reference"), "resolved": r.get("resolved"), "exists": r.get("exists")}))+                .collect();+            per.push(json!({"reference": reference, "footprint": fp_id, "warnings": w, "models": models}));+        }+    }+    json!({+        "success": true,+        "board": norm(p),+        "projectDir": project_dir,+        "searchDirs": ctx.search_dirs(Some(&project_dir)),+        "counts": {"footprints": footprints, "withModel": with_model, "noModelExpected": no_model_expected_n,+                   "missingModel": missing, "unresolvedModel": unresolved, "warnings": nwarn},+        "footprintsWithWarnings": per,+        "ok": nwarn == 0,+        "renderVerified": false,+        "nextCheck": "kicad_show_3d_board and read the frame: bodies on every populated footprint are the proof.",+    })+}++pub const HINT: &str = "Read-only file diagnostics: resolution, existence, format and geometry per model reference. renderVerified is always false here; the viewer frame from nextCheck is the render proof. Warnings carry a code and a hint; ok:true means every reference resolved to a real STEP/VRML with geometry.";++/// kicad_model_check {library, footprintName} or {boardPath|filePath}: the verb body.+pub fn handle(ctx: &Ctx, args: &Value) -> Value {+    let s = |k: &str| args.get(k).and_then(Value::as_str).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());+    let board = s("boardPath").or_else(|| s("filePath"));+    let mut out = match board {+        Some(b) if b.to_ascii_lowercase().ends_with(".kicad_pcb") => {+            let limit = args.get("limit").and_then(Value::as_u64).filter(|n| *n > 0).unwrap_or(50) as usize;+            check_board(ctx, &b, limit)+        }+        _ => {+            let lib = s("library").or_else(|| s("libraryName"));+            let fp = s("footprintName").or_else(|| s("footprint"));+            match (lib, fp) {+                (Some(lib), Some(fp)) => check_footprint(ctx, &lib, &fp, s("projectDir").as_deref()),+                _ => json!({"success": false, "errorCode": "missing_arg", "error": "Pass library + footprintName, or boardPath (.kicad_pcb)"}),+            }+        }+    };+    if out.get("success").and_then(Value::as_bool).unwrap_or(false) {+        out["_hint"] = json!(HINT);+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    const STEP: &[u8] = b"ISO-10303-21;\nHEADER;ENDSEC;\nDATA;\n#1=MANIFOLD_SOLID_BREP('x',#2);\n#2=CLOSED_SHELL('',());\nENDSEC;\nEND-ISO-10303-21;\n";++    struct Fixture {+        root: PathBuf,+        pretty: PathBuf,+        ctx: Ctx,+    }+    impl Drop for Fixture {+        fn drop(&mut self) {+            let _ = std::fs::remove_dir_all(&self.root);+        }+    }+    fn fixture(name: &str) -> Fixture {+        let root = std::env::temp_dir().join(format!("kicad-core-model-check-{}-{name}", std::process::id()));+        let _ = std::fs::remove_dir_all(&root);+        let pretty = root.join("footprints").join("Adom.pretty");+        std::fs::create_dir_all(&pretty).unwrap();+        let base = root.join("kicad");+        let shapes = base.join("share/kicad/3dmodels/Adom.3dshapes");+        std::fs::create_dir_all(&shapes).unwrap();+        std::fs::write(shapes.join("GOOD.step"), STEP).unwrap();+        std::fs::write(pretty.join("BARE.step"), STEP).unwrap();+        std::fs::write(pretty.join("EMPTY.step"), b"ISO-10303-21;\nHEADER;ENDSEC;DATA;ENDSEC;END-ISO-10303-21;").unwrap();+        let ctx = Ctx { version: "10.0".into(), base_dir: Some(base), config_dir: None, user_dir: Some(root.clone()), common: Value::Null };+        Fixture { root, pretty, ctx }+    }+    fn fp_text(model: &str) -> String {+        format!("(footprint \"X\" (version 20240108) (generator \"t\") (layer \"F.Cu\") (attr smd) (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\")){model})")+    }+    fn write(f: &Fixture, name: &str, model: &str) {+        std::fs::write(f.pretty.join(format!("{name}.kicad_mod")), fp_text(model)).unwrap();+    }+    fn codes(r: &Value) -> Vec<String> {+        let mut c: Vec<String> = r["warnings"].as_array().unwrap().iter().map(|w| w["code"].as_str().unwrap().to_string()).collect();+        c.sort();+        c+    }++    #[test]+    fn tokenizer_handles_quotes_and_escapes() {+        let t = parse_sexpr(r#"(model "a \"b\" c\\d" (hide yes) hide)"#).unwrap();+        assert_eq!(t.atom_at(1), Some("a \"b\" c\\d"));+        assert_eq!(t.find("hide").and_then(Sx::value), Some("yes"));+        assert_eq!(t.as_list().unwrap().len(), 4);+        assert!(parse_sexpr("   ").is_none());+    }++    #[test]+    fn good_stock_reference() {+        let f = fixture("good");+        write(&f, "A", "(model \"${KICAD10_3DMODEL_DIR}/Adom.3dshapes/GOOD.step\" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0)))");+        let r = check_footprint(&f.ctx, "Adom", "A", None);+        assert_eq!(r["ok"], json!(true), "{r}");+        assert_eq!(r["models"][0]["format"], json!("step"));+        assert!(r["models"][0]["geometryHints"].as_u64().unwrap() > 0);+        assert_eq!(r["models"][0]["terminated"], json!(true));+        assert_eq!(r["models"][0]["scale"], json!([1.0, 1.0, 1.0]));+        assert_eq!(r["renderVerified"], json!(false));+        assert_eq!(r["footprint"], json!("Adom:A"));+    }++    #[test]+    fn bare_relative_next_to_footprint_is_not_found() {+        let f = fixture("bare");+        write(&f, "B", "(model \"BARE.step\" (offset (xyz 0 1.39 0.18)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 -90)))");+        let r = check_footprint(&f.ctx, "Adom", "B", None);+        assert_eq!(r["ok"], json!(false));+        assert_eq!(r["warnings"][0]["code"], json!("not_found_relative"));+        assert!(r["warnings"][0]["hint"].as_str().unwrap().contains("KICAD10_3DMODEL_DIR"));+        assert_eq!(r["warnings"][0]["model"], json!("BARE.step"));+        let tried = r["models"][0]["triedDirs"].as_array().unwrap();+        assert_eq!(tried.len(), 2, "stock 3dmodels then 3rdparty: {tried:?}");+        assert!(tried[1].as_str().unwrap().ends_with("/3rdparty"));+    }++    #[test]+    fn absolute_empty_geometry_hidden_and_scale() {+        let f = fixture("abs");+        let p = norm(&f.pretty.join("EMPTY.step"));+        write(&f, "C", &format!("(model \"{p}\" (hide yes) (offset (xyz 0 0 0)) (scale (xyz 0 1 1)) (rotate (xyz 0 0 0)))"));+        let r = check_footprint(&f.ctx, "Adom", "C", None);+        assert_eq!(codes(&r), vec!["empty_geometry", "hidden", "implausible_scale"]);+        assert_eq!(r["models"][0]["exists"], json!(true));+        let hint = r["warnings"].as_array().unwrap().iter().find(|w| w["code"] == "implausible_scale").unwrap()["hint"].as_str().unwrap().to_string();+        assert!(hint.starts_with("scale [0.0, 1.0, 1.0]"), "{hint}");+    }++    #[test]+    fn no_model_and_exempt() {+        let f = fixture("nomodel");+        write(&f, "D", "");+        let r = check_footprint(&f.ctx, "Adom", "D", None);+        assert_eq!(r["warnings"][0]["code"], json!("no_model_reference"));+        assert_eq!(r["warnings"][0]["model"], Value::Null);+        write(&f, "TestPoint_Pad", "");+        let r = check_footprint(&f.ctx, "Adom", "TestPoint_Pad", None);+        assert_eq!(r["ok"], json!(true));+        assert!(r["note"].as_str().unwrap().contains("none expected"));+    }++    #[test]+    fn unresolved_variable_and_missing_absolute() {+        let f = fixture("unresolved");+        write(&f, "E", "(model \"${NOPE}/x.step\")");+        let r = check_footprint(&f.ctx, "Adom", "E", None);+        assert_eq!(r["warnings"][0]["code"], json!("unresolved_variable"));+        assert_eq!(r["warnings"][0]["vars"], json!(["NOPE"]));+        let gone = norm(&f.root.join("nowhere.step"));+        write(&f, "F", &format!("(model \"{gone}\")"));+        let r = check_footprint(&f.ctx, "Adom", "F", None);+        assert_eq!(r["warnings"][0]["code"], json!("file_missing"));+    }++    #[test]+    fn not_step_or_vrml_and_paren_vars() {+        let f = fixture("junk");+        std::fs::write(f.pretty.join("JUNK.step"), b"hello").unwrap();+        // $(VAR) spelling and a Configure Paths override from kicad_common.json+        let mut ctx = f.ctx.clone();+        ctx.common = json!({"environment": {"vars": {"MYMODELS": norm(&f.pretty)}}});+        write(&f, "G", "(model \"$(MYMODELS)/JUNK.step\")");+        let r = check_footprint(&ctx, "Adom", "G", None);+        assert_eq!(codes(&r), vec!["not_step_or_vrml"]);+        assert_eq!(r["models"][0]["format"], json!("unknown"));+    }++    #[test]+    fn library_and_footprint_not_found() {+        let f = fixture("notfound");+        let r = check_footprint(&f.ctx, "Nope", "A", None);+        assert_eq!(r["errorCode"], json!("library_not_found"));+        let r = check_footprint(&f.ctx, "Adom", "Missing", None);+        assert_eq!(r["errorCode"], json!("footprint_not_found"));+    }++    #[test]+    fn fp_lib_table_row_resolves_library() {+        let f = fixture("libtable");+        let cfg = f.root.join("config");+        std::fs::create_dir_all(&cfg).unwrap();+        let other = f.root.join("elsewhere").join("Other.pretty");+        std::fs::create_dir_all(&other).unwrap();+        std::fs::write(other.join("Z.kicad_mod"), fp_text("")).unwrap();+        std::fs::write(cfg.join("fp-lib-table"), format!(+            "(fp_lib_table (version 7)\n  (lib (name \"Other\")(type \"KiCad\")(uri \"{}\")(options \"\")(descr \"\"))\n  (lib (name \"Proj\")(type \"KiCad\")(uri \"${{KIPRJMOD}}/x.pretty\")(options \"\")(descr \"\"))\n)",+            norm(&other))).unwrap();+        let mut ctx = f.ctx.clone();+        ctx.config_dir = Some(cfg);+        assert_eq!(ctx.footprint_library_dir("other"), Some(other));+        assert_eq!(ctx.footprint_library_dir("Proj"), None);+        let r = check_footprint(&ctx, "Other", "Z", None);+        assert_eq!(r["success"], json!(true));+        assert_eq!(r["warnings"][0]["code"], json!("no_model_reference"));+    }++    #[test]+    fn board() {+        let f = fixture("board");+        let board = f.root.join("b.kicad_pcb");+        std::fs::write(&board, "(kicad_pcb (version 20240108) (generator \"t\") (footprint \"Adom:A\" (layer \"F.Cu\") (property \"Reference\" \"U1\") (model \"${KICAD10_3DMODEL_DIR}/Adom.3dshapes/GOOD.step\")) (footprint \"Adom:B\" (layer \"F.Cu\") (property \"Reference\" \"U2\") (model \"BARE.step\")) (footprint \"TestPoint:TP\" (layer \"F.Cu\") (property \"Reference\" \"TP1\")) (footprint \"Adom:C\" (layer \"F.Cu\") (property \"Reference\" \"U3\")))").unwrap();+        let r = check_board(&f.ctx, &norm(&board), 50);+        let c = &r["counts"];+        assert_eq!((c["footprints"].as_u64(), c["withModel"].as_u64(), c["noModelExpected"].as_u64(), c["missingModel"].as_u64(), c["unresolvedModel"].as_u64()),+            (Some(4), Some(2), Some(1), Some(1), Some(1)));+        let refs: Vec<&str> = r["footprintsWithWarnings"].as_array().unwrap().iter().map(|x| x["reference"].as_str().unwrap()).collect();+        assert_eq!(refs, vec!["U2", "U3"]);+        assert_eq!(r["searchDirs"][0], json!(norm(&f.root)));+        assert_eq!(r["ok"], json!(false));+        // limit caps the per-footprint list, not the counts+        let r = check_board(&f.ctx, &norm(&board), 1);+        assert_eq!(r["footprintsWithWarnings"].as_array().unwrap().len(), 1);+        assert_eq!(r["counts"]["warnings"], json!(2));+    }++    #[test]+    fn handle_routes_args() {+        let f = fixture("handle");+        write(&f, "A", "");+        let r = handle(&f.ctx, &json!({"library": "Adom", "footprintName": "A"}));+        assert_eq!(r["success"], json!(true));+        assert_eq!(r["_hint"], json!(HINT));+        let r = handle(&f.ctx, &json!({"libraryName": "Adom", "footprint": "A"}));+        assert_eq!(r["success"], json!(true));+        let r = handle(&f.ctx, &json!({}));+        assert_eq!(r["errorCode"], json!("missing_arg"));+        let r = handle(&f.ctx, &json!({"boardPath": norm(&f.root.join("none.kicad_pcb"))}));+        assert_eq!(r["errorCode"], json!("board_not_found"));+    }++    #[test]+    fn third_party_dir_honours_common_override() {+        let mut ctx = Ctx { version: "10.0.6".into(), user_dir: Some(PathBuf::from("/u/Documents/KiCad/10.0")), ..Default::default() };+        assert_eq!(ctx.third_party_dir().unwrap().0, PathBuf::from("/u/Documents/KiCad/10.0/3rdparty"));+        ctx.common = json!({"environment": {"vars": {"KICAD10_3RD_PARTY": "/custom/3rd"}}});+        let (p, how) = ctx.third_party_dir().unwrap();+        assert_eq!(p, PathBuf::from("/custom/3rd"));+        assert_eq!(how, "kicad_common.json override");+        assert_eq!(ctx.search_dirs(None).last().map(String::as_str), Some("/custom/3rd"));+        assert_eq!(ctx.expand("${KICAD10_3RD_PARTY}/x.step", None).0, "/custom/3rd/x.step");+    }+}
rust/crates/kicad-core/src/netlist.rsadded+766
@@ -0,0 +1,766 @@+//! 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,+}++#[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"));+                }+            }+        }+        "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()));+            }+        }+        "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 {+            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,+                    _ => {}+                }+            }+        } 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> },+}++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 {+    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 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");+    }+}
rust/crates/kicad-core/src/pcm.rsadded+957
@@ -0,0 +1,957 @@+//! KiCad Plugin and Content Manager (PCM) automation: install packages from a PCM+//! repository WITHOUT the dialog, the way PCM itself does it on disk.+//!+//! Port of handlers/pcm.py. Three verbs: pcm_install, pcm_list, pcm_uninstall. PCM has+//! no CLI and its dialog is an owned window the bridge's input verbs cannot target, but+//! PCM is only files:+//!+//!   1. extract  <zip>/<folder>/<contents>  ->  <3rdparty>/<folder>/<pkgid>/<contents>+//!      (kicad/pcm/pcm_task_manager.cpp extract(): folder must be one of+//!      plugins/footprints/symbols/3dmodels/resources/colors; identifier dots become+//!      underscores on disk so Python packages import flat)+//!   2. record   <config>/installed_packages.json  {"packages":[entry...]}+//!      (pcm_data.cpp to_json(PCM_INSTALLATION_ENTRY): package, current_version,+//!      repository_id, repository_name, install_timestamp, pinned)+//!   3. NOTHING ELSE. KiCad registers the libraries itself: PCM_SYM_LIB_TRAVERSER and+//!      PCM_FP_LIB_TRAVERSER scan $KICADn_3RD_PARTY/symbols/<pkgid>/*.kicad_sym and+//!      footprints/<pkgid>/*.pretty when the GLOBAL TABLE LOADS and insert rows named+//!      <lib_prefix><name> (default "PCM_"), as long as pcm.lib_auto_add is on (the+//!      default). That scan happens at editor load, which is why a running editor needs+//!      a restart to see a new package: reported here as restartNeeded, never done.+//!+//! repository_id is sha256(repository url)[:16] (pcm.cpp SetRepositoryList), so KiCad's+//! own PCM dialog shows a bridge-installed package as Installed, offers updates for it,+//! and can uninstall it. The bridge is an automation of PCM, not a parallel mechanism.+//!+//! Neither `ring` nor `flate2` is a declared dependency of this crate, so the SHA-256+//! and the zip reader (stored and deflate entries, no zip64) live here on std alone.++use std::path::{Path, PathBuf};+use std::time::Duration;++use serde_json::{json, Map, Value};++use crate::model_check::{norm, Ctx};++pub const FOLDERS: &[&str] = &["plugins", "footprints", "symbols", "3dmodels", "resources", "colors"];+pub const MAX_ZIP: u64 = 512 * 1024 * 1024;+const UA: &str = "adom-kicad-bridge pcm";++/// Bytes fetcher: (url, timeout) -> bytes. Production uses `http_fetch`; tests hand in a table.+pub type Fetch<'a> = &'a dyn Fn(&str, Duration) -> Result<Vec<u8>, String>;++/// GET a URL over HTTPS (rustls, bundled roots) with the PCM user agent.+#[cfg(not(feature = "net"))]+pub fn http_fetch(_url: &str, _timeout: Duration) -> Result<Vec<u8>, String> {+    Err("the net feature is off in this build: no HTTPS client".into())+}++#[cfg(feature = "net")]+pub fn http_fetch(url: &str, timeout: Duration) -> Result<Vec<u8>, String> {+    let mut resp = ureq::get(url)+        .header("User-Agent", UA)+        .config()+        .timeout_global(Some(timeout))+        .build()+        .call()+        .map_err(|e| format!("{}: {e}", err_kind(&e)))?;+    resp.body_mut().with_config().limit(MAX_ZIP).read_to_vec().map_err(|e| format!("{}: {e}", err_kind(&e)))+}++#[cfg(feature = "net")]+fn err_kind(e: &ureq::Error) -> &'static str {+    match e {+        ureq::Error::StatusCode(_) => "HTTPError",+        ureq::Error::Timeout(_) => "TimeoutError",+        ureq::Error::Io(_) => "OSError",+        _ => "URLError",+    }+}++// ── SHA-256 (FIPS 180-4), std only ────────────────────────────────────────────++pub fn sha256_hex(data: &[u8]) -> String {+    const K: [u32; 64] = [+        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,+        0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,+        0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,+        0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,+        0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,+        0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,+    ];+    let mut h: [u32; 8] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];+    let mut msg = data.to_vec();+    let bit_len = (data.len() as u64).wrapping_mul(8);+    msg.push(0x80);+    while msg.len() % 64 != 56 {+        msg.push(0);+    }+    msg.extend_from_slice(&bit_len.to_be_bytes());+    let mut w = [0u32; 64];+    for chunk in msg.chunks(64) {+        for i in 0..16 {+            w[i] = u32::from_be_bytes([chunk[i * 4], chunk[i * 4 + 1], chunk[i * 4 + 2], chunk[i * 4 + 3]]);+        }+        for i in 16..64 {+            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);+            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);+            w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);+        }+        let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;+        for i in 0..64 {+            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);+            let ch = (e & f) ^ (!e & g);+            let t1 = hh.wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]);+            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);+            let maj = (a & b) ^ (a & c) ^ (b & c);+            let t2 = s0.wrapping_add(maj);+            hh = g;+            g = f;+            f = e;+            e = d.wrapping_add(t1);+            d = c;+            c = b;+            b = a;+            a = t1.wrapping_add(t2);+        }+        for (x, y) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) {+            *x = x.wrapping_add(y);+        }+    }+    h.iter().map(|x| format!("{x:08x}")).collect()+}++// ── Inflate (RFC 1951), std only ──────────────────────────────────────────────++mod inflate {+    struct Bits<'a> {+        data: &'a [u8],+        pos: usize,+        buf: u64,+        cnt: u32,+    }+    impl<'a> Bits<'a> {+        fn bits(&mut self, n: u32) -> Result<u32, String> {+            while self.cnt < n {+                let b = *self.data.get(self.pos).ok_or("deflate stream truncated")?;+                self.pos += 1;+                self.buf |= (b as u64) << self.cnt;+                self.cnt += 8;+            }+            let v = (self.buf & ((1u64 << n) - 1)) as u32;+            self.buf >>= n;+            self.cnt -= n;+            Ok(v)+        }+        fn align(&mut self) {+            let drop = self.cnt % 8;+            self.buf >>= drop;+            self.cnt -= drop;+        }+    }++    struct Huff {+        count: [u16; 16],+        symbol: Vec<u16>,+    }+    impl Huff {+        fn new(lengths: &[u8]) -> Result<Huff, String> {+            let mut count = [0u16; 16];+            for &l in lengths {+                count[l as usize] += 1;+            }+            count[0] = 0;+            let mut left: i32 = 1;+            for c in &count[1..] {+                left = (left << 1) - *c as i32;+                if left < 0 {+                    return Err("over-subscribed huffman code".into());+                }+            }+            let mut offs = [0u16; 16];+            for i in 1..15 {+                offs[i + 1] = offs[i] + count[i];+            }+            let mut symbol = vec![0u16; lengths.len()];+            for (s, &l) in lengths.iter().enumerate() {+                if l != 0 {+                    symbol[offs[l as usize] as usize] = s as u16;+                    offs[l as usize] += 1;+                }+            }+            Ok(Huff { count, symbol })+        }+        fn decode(&self, b: &mut Bits) -> Result<u16, String> {+            let (mut code, mut first, mut index) = (0i32, 0i32, 0i32);+            for len in 1..16 {+                code |= b.bits(1)? as i32;+                let count = self.count[len] as i32;+                if code - count < first {+                    return self.symbol.get((index + (code - first)) as usize).copied().ok_or_else(|| "bad huffman symbol".to_string());+                }+                index += count;+                first += count;+                first <<= 1;+                code <<= 1;+            }+            Err("invalid huffman code".into())+        }+    }++    const LEN_BASE: [u16; 29] = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258];+    const LEN_EXTRA: [u8; 29] = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0];+    const DIST_BASE: [u16; 30] = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577];+    const DIST_EXTRA: [u8; 30] = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13];++    fn codes(b: &mut Bits, out: &mut Vec<u8>, lit: &Huff, dist: &Huff, cap: usize) -> Result<(), String> {+        loop {+            let sym = lit.decode(b)? as usize;+            if sym < 256 {+                out.push(sym as u8);+            } else if sym == 256 {+                return Ok(());+            } else {+                let i = sym - 257;+                if i >= 29 {+                    return Err("bad length symbol".into());+                }+                let len = LEN_BASE[i] as usize + b.bits(LEN_EXTRA[i] as u32)? as usize;+                let d = dist.decode(b)? as usize;+                if d >= 30 {+                    return Err("bad distance symbol".into());+                }+                let distance = DIST_BASE[d] as usize + b.bits(DIST_EXTRA[d] as u32)? as usize;+                if distance > out.len() {+                    return Err("distance too far back".into());+                }+                let start = out.len() - distance;+                for k in 0..len {+                    let byte = out[start + k];+                    out.push(byte);+                }+            }+            if out.len() > cap {+                return Err("inflated size exceeds the declared size".into());+            }+        }+    }++    /// Inflate a raw deflate stream. `expected` bounds the output so a bogus entry cannot balloon.+    pub fn inflate(data: &[u8], expected: usize) -> Result<Vec<u8>, String> {+        let mut b = Bits { data, pos: 0, buf: 0, cnt: 0 };+        let mut out = Vec::with_capacity(expected);+        let cap = expected;+        loop {+            let last = b.bits(1)?;+            match b.bits(2)? {+                0 => {+                    b.align();+                    let len = b.bits(16)? as usize;+                    let nlen = b.bits(16)? as usize;+                    if len != (!nlen & 0xffff) {+                        return Err("stored block length check failed".into());+                    }+                    let end = b.pos.checked_add(len).ok_or("overflow")?;+                    let chunk = data.get(b.pos..end).ok_or("stored block truncated")?;+                    out.extend_from_slice(chunk);+                    b.pos = end;+                    if out.len() > cap {+                        return Err("inflated size exceeds the declared size".into());+                    }+                }+                1 => {+                    let mut l = [0u8; 288];+                    for (i, v) in l.iter_mut().enumerate() {+                        *v = match i {+                            0..=143 => 8,+                            144..=255 => 9,+                            256..=279 => 7,+                            _ => 8,+                        };+                    }+                    let lit = Huff::new(&l)?;+                    let dist = Huff::new(&[5u8; 30])?;+                    codes(&mut b, &mut out, &lit, &dist, cap)?;+                }+                2 => {+                    let nlen = b.bits(5)? as usize + 257;+                    let ndist = b.bits(5)? as usize + 1;+                    let ncode = b.bits(4)? as usize + 4;+                    if nlen > 286 || ndist > 30 {+                        return Err("bad dynamic block counts".into());+                    }+                    const ORDER: [usize; 19] = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15];+                    let mut cl = [0u8; 19];+                    for &o in ORDER.iter().take(ncode) {+                        cl[o] = b.bits(3)? as u8;+                    }+                    let clh = Huff::new(&cl)?;+                    let mut lengths = vec![0u8; nlen + ndist];+                    let mut i = 0;+                    while i < nlen + ndist {+                        let sym = clh.decode(&mut b)?;+                        match sym {+                            0..=15 => {+                                lengths[i] = sym as u8;+                                i += 1;+                            }+                            16 => {+                                if i == 0 {+                                    return Err("repeat with no previous length".into());+                                }+                                let prev = lengths[i - 1];+                                let n = 3 + b.bits(2)? as usize;+                                if i + n > lengths.len() {+                                    return Err("too many lengths".into());+                                }+                                for _ in 0..n {+                                    lengths[i] = prev;+                                    i += 1;+                                }+                            }+                            17 | 18 => {+                                let n = if sym == 17 { 3 + b.bits(3)? as usize } else { 11 + b.bits(7)? as usize };+                                if i + n > lengths.len() {+                                    return Err("too many lengths".into());+                                }+                                i += n;+                            }+                            _ => return Err("bad code length symbol".into()),+                        }+                    }+                    if lengths[256] == 0 {+                        return Err("no end-of-block code".into());+                    }+                    let lit = Huff::new(&lengths[..nlen])?;+                    let dist = Huff::new(&lengths[nlen..])?;+                    codes(&mut b, &mut out, &lit, &dist, cap)?;+                }+                _ => return Err("invalid block type".into()),+            }+            if last == 1 {+                return Ok(out);+            }+        }+    }+}++// ── Minimal zip reader ────────────────────────────────────────────────────────++pub struct ZipEntry {+    pub name: String,+    pub method: u16,+    pub compressed_size: u64,+    pub size: u64,+    local_offset: usize,+}++pub struct ZipFile<'a> {+    data: &'a [u8],+    pub entries: Vec<ZipEntry>,+}++fn u16_at(d: &[u8], i: usize) -> Result<u16, String> {+    Ok(u16::from_le_bytes([*d.get(i).ok_or("zip truncated")?, *d.get(i + 1).ok_or("zip truncated")?]))+}+fn u32_at(d: &[u8], i: usize) -> Result<u32, String> {+    Ok(u32::from_le_bytes([+        *d.get(i).ok_or("zip truncated")?,+        *d.get(i + 1).ok_or("zip truncated")?,+        *d.get(i + 2).ok_or("zip truncated")?,+        *d.get(i + 3).ok_or("zip truncated")?,+    ]))+}++impl<'a> ZipFile<'a> {+    pub fn parse(data: &'a [u8]) -> Result<ZipFile<'a>, String> {+        if data.len() < 22 {+            return Err("not a zip file (too small)".into());+        }+        let min = data.len().saturating_sub(22 + 65535);+        let mut eocd = None;+        let mut i = data.len() - 22;+        loop {+            if &data[i..i + 4] == b"PK\x05\x06" {+                eocd = Some(i);+                break;+            }+            if i == min {+                break;+            }+            i -= 1;+        }+        let eocd = eocd.ok_or("not a zip file (no end-of-central-directory record)")?;+        let total = u16_at(data, eocd + 10)? as usize;+        let cd_off = u32_at(data, eocd + 16)? as usize;+        if total == 0xffff || cd_off == 0xffff_ffff {+            return Err("zip64 archives are not supported".into());+        }+        let mut entries = Vec::with_capacity(total);+        let mut p = cd_off;+        for _ in 0..total {+            if data.get(p..p + 4) != Some(b"PK\x01\x02") {+                return Err("bad central directory entry".into());+            }+            let method = u16_at(data, p + 10)?;+            let compressed_size = u32_at(data, p + 20)? as u64;+            let size = u32_at(data, p + 24)? as u64;+            let n = u16_at(data, p + 28)? as usize;+            let e = u16_at(data, p + 30)? as usize;+            let c = u16_at(data, p + 32)? as usize;+            let local_offset = u32_at(data, p + 42)? as usize;+            if compressed_size == 0xffff_ffff || size == 0xffff_ffff || local_offset == 0xffff_ffff {+                return Err("zip64 entries are not supported".into());+            }+            let name = String::from_utf8_lossy(data.get(p + 46..p + 46 + n).ok_or("zip truncated")?).to_string();+            entries.push(ZipEntry { name, method, compressed_size, size, local_offset });+            p += 46 + n + e + c;+        }+        Ok(ZipFile { data, entries })+    }++    pub fn read(&self, entry: &ZipEntry) -> Result<Vec<u8>, String> {+        let p = entry.local_offset;+        if self.data.get(p..p + 4) != Some(b"PK\x03\x04") {+            return Err(format!("bad local header for {}", entry.name));+        }+        let n = u16_at(self.data, p + 26)? as usize;+        let e = u16_at(self.data, p + 28)? as usize;+        let start = p + 30 + n + e;+        let end = start.checked_add(entry.compressed_size as usize).ok_or("overflow")?;+        let raw = self.data.get(start..end).ok_or_else(|| format!("entry data truncated: {}", entry.name))?;+        match entry.method {+            0 => Ok(raw.to_vec()),+            8 => {+                let out = inflate::inflate(raw, entry.size as usize).map_err(|e| format!("{}: {e}", entry.name))?;+                if out.len() as u64 != entry.size {+                    return Err(format!("{}: inflated {} bytes, expected {}", entry.name, out.len(), entry.size));+                }+                Ok(out)+            }+            m => Err(format!("{}: unsupported zip compression method {m}", entry.name)),+        }+    }+}++// ── PCM on disk ───────────────────────────────────────────────────────────────++pub struct Paths {+    pub third: PathBuf,+    pub installed: PathBuf,+    pub version: String,+    pub resolved_by: &'static str,+}++/// (3rdparty dir, installed_packages.json, version) or None when KiCad's dirs are unknown.+pub fn paths(ctx: &Ctx) -> Option<Paths> {+    let (third, resolved_by) = ctx.third_party_dir()?;+    let cfg = ctx.config_dir.as_ref()?;+    Some(Paths { third, installed: cfg.join("installed_packages.json"), version: ctx.version.clone(), resolved_by })+}++pub fn read_installed(p: &Path) -> Value {+    if let Ok(text) = std::fs::read_to_string(p) {+        if let Ok(v) = serde_json::from_str::<Value>(&text) {+            if v.is_object() && v.get("packages").map(Value::is_array).unwrap_or(false) {+                return v;+            }+        }+    }+    json!({"packages": []})+}++/// Atomic write, four-space indent like KiCad's own file.+pub fn write_installed(p: &Path, d: &Value) -> Result<(), String> {+    if let Some(parent) = p.parent() {+        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;+    }+    let mut buf = Vec::new();+    let mut ser = serde_json::Serializer::with_formatter(&mut buf, serde_json::ser::PrettyFormatter::with_indent(b"    "));+    serde::Serialize::serialize(d, &mut ser).map_err(|e| e.to_string())?;+    buf.push(b'\n');+    let tmp = p.with_extension("json.tmp");+    std::fs::write(&tmp, &buf).map_err(|e| e.to_string())?;+    std::fs::rename(&tmp, p).map_err(|e| e.to_string())+}++pub fn pkgdir(identifier: &str) -> String {+    identifier.replace('.', "_")+}++pub struct Extracted {+    pub files: usize,+    pub symbols: Vec<String>,+    pub footprints: Vec<String>,+    pub models: Vec<String>,+    pub package_dir: String,+}++/// PCM's extract(): <folder>/<contents> -> <3rdparty>/<folder>/<pkgid>/<contents>.+/// Zip-slip guarded; entries outside the known folders are ignored (as PCM does).+pub fn extract(zip_bytes: &[u8], third: &Path, identifier: &str) -> Result<Extracted, String> {+    let zf = ZipFile::parse(zip_bytes)?;+    let total: u64 = zf.entries.iter().map(|e| e.size).sum();+    if total > MAX_ZIP {+        return Err(format!("package unpacks to {total} bytes, over the {MAX_ZIP} cap"));+    }+    let pkgid = pkgdir(identifier);+    let mut out = Extracted { files: 0, symbols: vec![], footprints: vec![], models: vec![], package_dir: pkgid.clone() };+    for entry in &zf.entries {+        let name = entry.name.replace('\\', "/");+        if name.is_empty() || name.ends_with('/') {+            continue;+        }+        let parts: Vec<&str> = name.split('/').filter(|p| !p.is_empty() && *p != ".").collect();+        let drive = name.len() >= 2 && name.as_bytes()[0].is_ascii_alphabetic() && name.as_bytes()[1] == b':';+        if parts.iter().any(|p| *p == "..") || drive || name.starts_with('/') {+            return Err(format!("unsafe zip entry: {}", entry.name));+        }+        if parts.len() < 2 || !FOLDERS.contains(&parts[0]) {+            continue;+        }+        let base = third.join(parts[0]).join(&pkgid);+        let mut target = base.clone();+        for p in &parts[1..] {+            target.push(p);+        }+        if !target.starts_with(&base) {+            return Err(format!("unsafe zip entry: {}", entry.name));+        }+        let bytes = zf.read(entry)?;+        if let Some(parent) = target.parent() {+            std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", norm(parent)))?;+        }+        std::fs::write(&target, &bytes).map_err(|e| format!("{}: {e}", norm(&target)))?;+        out.files += 1;+        let last = parts[parts.len() - 1];+        if parts[0] == "symbols" && name.ends_with(".kicad_sym") {+            out.symbols.push(last[..last.len() - 10].to_string());+        } else if parts[0] == "footprints" && parts.len() >= 3 && parts[1].ends_with(".pretty") {+            let lib = parts[1][..parts[1].len() - 7].to_string();+            if !out.footprints.contains(&lib) {+                out.footprints.push(lib);+            }+        } else if parts[0] == "3dmodels" && parts.len() >= 3 && !out.models.iter().any(|m| m == parts[1]) {+            out.models.push(parts[1].to_string());+        }+    }+    Ok(out)+}++/// kicad.json pcm block: (lib_auto_add, lib_prefix). Defaults are KiCad's.+fn pcm_prefs(ctx: &Ctx) -> (bool, String) {+    let v = ctx+        .config_dir+        .as_ref()+        .and_then(|c| std::fs::read_to_string(c.join("kicad.json")).ok())+        .and_then(|s| serde_json::from_str::<Value>(&s).ok())+        .unwrap_or(Value::Null);+    let pcm = v.get("pcm").cloned().unwrap_or(Value::Null);+    let auto_add = pcm.get("lib_auto_add").and_then(Value::as_bool).unwrap_or(true);+    let prefix = pcm.get("lib_prefix").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("PCM_").to_string();+    (auto_add, prefix)+}++fn now_secs() -> u64 {+    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)+}++fn ident_of(entry: &Value) -> Option<&str> {+    entry.get("package").and_then(|p| p.get("identifier")).and_then(Value::as_str)+}++/// The native build has no process scan yet, so a running KiCad is not detected:+/// kicadRunning is null and restartNeeded is true (the safe answer).+fn running_block() -> (Value, bool) {+    (Value::Null, true)+}++/// kicad_pcm_install {repositoryUrl, identifier, version?, libPrefix?, force?}.+pub fn install(ctx: &Ctx, args: &Value, fetch: Fetch) -> Value {+    let s = |k: &str| args.get(k).and_then(Value::as_str).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());+    let (auto_add, default_prefix) = pcm_prefs(ctx);+    let prefix = s("libPrefix").unwrap_or(default_prefix);+    let (Some(repo_url), Some(ident)) = (s("repositoryUrl"), s("identifier")) else {+        return json!({"success": false, "error": "repositoryUrl and identifier are required",+            "_hint": "repositoryUrl is the repository.json URL; identifier like inc.adom.kicad.basic-parts."});+    };+    let want = s("version");+    let Some(paths) = paths(ctx) else {+        return json!({"success": false, "error": "KiCad user/config directories unknown",+            "_hint": "kicad_readiness first; the bridge needs a detected KiCad to know 3rdparty and settings paths."});+    };+    let repo_result: Result<(Value, Vec<Value>), String> = (|| {+        let repo: Value = serde_json::from_slice(&fetch(&repo_url, Duration::from_secs(120))?).map_err(|e| format!("JSONDecodeError: {e}"))?;+        let pkgs_url = repo.get("packages").and_then(|p| p.get("url")).and_then(Value::as_str).ok_or("KeyError: 'packages'")?.to_string();+        let raw = fetch(&pkgs_url, Duration::from_secs(120))?;+        let want_sha = repo.get("packages").and_then(|p| p.get("sha256")).and_then(Value::as_str).unwrap_or("");+        if sha256_hex(&raw) != want_sha {+            return Err("__hash__".into());+        }+        let pk: Value = serde_json::from_slice(&raw).map_err(|e| format!("JSONDecodeError: {e}"))?;+        let list = pk.get("packages").and_then(Value::as_array).cloned().ok_or("KeyError: 'packages'")?;+        Ok((repo, list))+    })();+    let (repo, packages) = match repo_result {+        Ok(v) => v,+        Err(e) if e == "__hash__" => {+            return json!({"success": false, "error": "packages.json hash does not match repository.json",+                "_hint": "The repository is mid-update or corrupted; retry in a minute."})+        }+        Err(e) => return json!({"success": false, "error": format!("could not read repository: {e}")}),+    };+    let Some(pkg) = packages.iter().find(|p| p.get("identifier").and_then(Value::as_str) == Some(ident.as_str())) else {+        let available: Vec<Value> = packages.iter().take(20).map(|p| p.get("identifier").cloned().unwrap_or(Value::Null)).collect();+        return json!({"success": false, "error": format!("package not in repository: {ident}"), "available": available});+    };+    let versions = pkg.get("versions").and_then(Value::as_array).cloned().unwrap_or_default();+    let vsel = match &want {+        Some(w) => match versions.iter().find(|v| v.get("version").and_then(Value::as_str) == Some(w.as_str())) {+            Some(v) => v.clone(),+            None => {+                let available: Vec<Value> = versions.iter().map(|v| v.get("version").cloned().unwrap_or(Value::Null)).collect();+                return json!({"success": false, "error": format!("version {w} not offered"), "available": available});+            }+        },+        None => match versions.last() {+            Some(v) => v.clone(),+            None => return json!({"success": false, "error": "package has no downloadable version"}),+        },+    };+    let Some(download_url) = vsel.get("download_url").and_then(Value::as_str).filter(|u| !u.is_empty()).map(String::from) else {+        return json!({"success": false, "error": "package has no downloadable version"});+    };+    let version = vsel.get("version").and_then(Value::as_str).unwrap_or("").to_string();++    let mut installed = read_installed(&paths.installed);+    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);+    let prior_same = installed["packages"]+        .as_array()+        .map(|a| a.iter().any(|e| ident_of(e) == Some(ident.as_str()) && e.get("current_version").and_then(Value::as_str) == Some(version.as_str())))+        .unwrap_or(false);+    if prior_same && !force {+        return json!({"success": true, "alreadyInstalled": true, "identifier": ident, "version": version,+            "_hint": "Already installed at this version; pass force:true to reinstall."});+    }++    let ext = match (|| -> Result<Result<Extracted, Value>, String> {+        let blob = fetch(&download_url, Duration::from_secs(600))?;+        let got = sha256_hex(&blob);+        if let Some(want_sha) = vsel.get("download_sha256").and_then(Value::as_str).filter(|s| !s.is_empty()) {+            if got != want_sha {+                return Ok(Err(json!({"success": false, "error": "download sha256 mismatch", "expected": want_sha, "got": got,+                    "_hint": "Refusing to install a package whose bytes do not match the repository's digest."})));+            }+        }+        // replace-on-update: remove the previous extraction first, as PCM does+        for folder in FOLDERS {+            let old = paths.third.join(folder).join(pkgdir(&ident));+            if old.exists() {+                let _ = std::fs::remove_dir_all(&old);+            }+        }+        Ok(Ok(extract(&blob, &paths.third, &ident)?))+    })() {+        Ok(Ok(e)) => e,+        Ok(Err(v)) => return v,+        Err(e) => return json!({"success": false, "error": format!("install failed: {e}")}),+    };++    let mut package: Map<String, Value> = pkg.as_object().cloned().unwrap_or_default();+    package.remove("versions");+    package.insert("versions".into(), json!([vsel]));+    let entry = json!({+        "package": package,+        "current_version": version,+        "repository_id": &sha256_hex(repo_url.as_bytes())[..16],+        "repository_name": repo.get("name").and_then(Value::as_str).unwrap_or(""),+        "install_timestamp": now_secs(),+        "pinned": false,+    });+    let mut list: Vec<Value> = installed["packages"].as_array().cloned().unwrap_or_default();+    list.retain(|e| ident_of(e) != Some(ident.as_str()));+    list.push(entry);+    installed["packages"] = Value::Array(list);+    if let Err(e) = write_installed(&paths.installed, &installed) {+        return json!({"success": false, "error": format!("install failed: could not write installed_packages.json: {e}")});+    }++    let (running, restart) = running_block();+    let nick = |names: &[String]| -> Vec<String> { names.iter().map(|n| format!("{prefix}{n}")).collect() };+    let mut hint = String::from(+        "Installed exactly as PCM would; KiCad's PCM dialog will list it as Installed. Library nicknames above are created by KiCad's own PCM scan when the global library table next LOADS, so a KiCad that is already running must be restarted (never done for you) before the libraries appear.");+    if !auto_add {+        hint.push_str(" kicad.json pcm.lib_auto_add is OFF on this box, so KiCad will NOT add the rows itself: register the libraries with kicad_install_library or turn lib_auto_add back on.");+    }+    json!({+        "success": true, "identifier": ident, "version": version,+        "filesInstalled": ext.files,+        "installedTo": norm(&paths.third.join("*").join(&ext.package_dir)),+        "thirdPartyDir": norm(&paths.third), "thirdPartyResolvedBy": paths.resolved_by,+        "symbolLibraries": nick(&ext.symbols),+        "footprintLibraries": nick(&ext.footprints),+        "modelSets": ext.models,+        "libAutoAdd": auto_add, "libPrefix": prefix,+        "kicadRunning": running, "restartNeeded": restart,+        "_hint": hint,+    })+}++/// kicad_pcm_list {}: packages KiCad's PCM considers installed (read-only).+pub fn list(ctx: &Ctx) -> Value {+    let Some(paths) = paths(ctx) else {+        return json!({"success": false, "error": "KiCad config directory unknown"});+    };+    let d = read_installed(&paths.installed);+    let out: Vec<Value> = d["packages"]+        .as_array()+        .into_iter()+        .flatten()+        .map(|e| {+            let p = e.get("package").cloned().unwrap_or(Value::Null);+            let id = p.get("identifier").and_then(Value::as_str).unwrap_or("");+            let on_disk = FOLDERS.iter().any(|f| paths.third.join(f).join(pkgdir(id)).exists());+            json!({"identifier": p.get("identifier"), "name": p.get("name"), "type": p.get("type"),+                   "version": e.get("current_version"), "repository": e.get("repository_name"), "onDisk": on_disk})+        })+        .collect();+    json!({"success": true, "count": out.len(), "packages": out,+           "thirdPartyDir": norm(&paths.third), "thirdPartyResolvedBy": paths.resolved_by, "kicadVersion": paths.version})+}++/// kicad_pcm_uninstall {identifier}: remove the 3rdparty directories and the record.+pub fn uninstall(ctx: &Ctx, args: &Value) -> Value {+    let Some(ident) = args.get("identifier").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else {+        return json!({"success": false, "error": "identifier is required"});+    };+    let Some(paths) = paths(ctx) else {+        return json!({"success": false, "error": "KiCad user/config directories unknown"});+    };+    let mut removed = Vec::new();+    for folder in FOLDERS {+        let d = paths.third.join(folder).join(pkgdir(ident));+        if d.exists() {+            let _ = std::fs::remove_dir_all(&d);+            removed.push(norm(&d));+        }+    }+    let mut installed = read_installed(&paths.installed);+    let mut list: Vec<Value> = installed["packages"].as_array().cloned().unwrap_or_default();+    let before = list.len();+    list.retain(|e| ident_of(e) != Some(ident));+    let record_removed = before != list.len();+    installed["packages"] = Value::Array(list);+    if let Err(e) = write_installed(&paths.installed, &installed) {+        return json!({"success": false, "error": format!("could not write installed_packages.json: {e}")});+    }+    let (running, restart) = running_block();+    json!({"success": true, "identifier": ident, "removedDirs": removed, "recordRemoved": record_removed,+           "kicadRunning": running, "restartNeeded": restart,+           "_hint": "KiCad drops the PCM_ library rows itself on next load when pcm.lib_auto_remove is on."})+}++#[cfg(test)]+mod tests {+    use super::*;+    use std::collections::HashMap;++    // Built with Python zipfile: metadata.json, plugins/__init__.py, symbols/Adom.kicad_sym,+    // footprints/Adom.pretty/R.kicad_mod, 3dmodels/Adom.3dshapes/R.step (deflate), a+    // footprints/ directory entry, colors/theme.json (stored).+    const ZIP: &[u8] = &[80,75,3,4,20,0,0,0,8,0,85,94,43,93,70,221,171,251,38,0,0,0,36,0,0,0,13,0,0,0,109,101,116,97,100,97,116,97,46,106,115,111,110,171,86,202,76,73,205,43,201,76,203,76,45,82,178,82,202,204,75,214,75,76,201,207,213,203,206,76,78,76,209,43,73,45,46,81,170,5,0,80,75,3,4,20,0,0,0,8,0,85,94,43,93,175,166,158,248,14,0,0,0,12,0,0,0,19,0,0,0,112,108,117,103,105,110,115,47,95,95,105,110,105,116,95,95,46,112,121,43,40,202,204,43,209,80,207,200,84,215,228,2,0,80,75,3,4,20,0,0,0,8,0,85,94,43,93,136,205,60,219,64,0,0,0,16,4,0,0,22,0,0,0,115,121,109,98,111,108,115,47,65,100,111,109,46,107,105,99,97,100,95,115,121,109,211,200,206,76,78,76,137,47,174,204,77,202,207,137,207,201,76,82,208,40,75,45,42,206,204,207,83,48,50,48,50,54,52,52,50,208,84,208,72,79,205,75,45,74,44,201,47,82,40,209,212,228,210,24,213,51,170,103,84,207,176,212,3,0,80,75,3,4,20,0,0,0,8,0,85,94,43,93,173,223,251,226,33,0,0,0,31,0,0,0,34,0,0,0,102,111,111,116,112,114,105,110,116,115,47,65,100,111,109,46,112,114,101,116,116,121,47,82,46,107,105,99,97,100,95,109,111,100,211,72,203,207,47,41,40,202,204,43,81,80,10,82,82,208,200,73,172,76,45,82,80,114,211,115,46,85,210,212,228,2,0,80,75,3,4,20,0,0,0,8,0,85,94,43,93,79,211,211,10,41,0,0,0,58,0,0,0,29,0,0,0,51,100,109,111,100,101,108,115,47,65,100,111,109,46,51,100,115,104,97,112,101,115,47,82,46,115,116,101,112,243,12,246,215,53,52,48,54,48,214,53,50,180,230,242,112,117,116,113,13,178,118,245,115,9,118,117,182,118,113,12,113,132,177,129,148,174,39,138,90,0,80,75,3,4,20,0,0,0,8,0,85,94,43,93,0,0,0,0,2,0,0,0,0,0,0,0,11,0,0,0,102,111,111,116,112,114,105,110,116,115,47,3,0,80,75,3,4,20,0,0,0,0,0,0,0,33,0,163,169,132,166,21,0,0,0,21,0,0,0,17,0,0,0,99,111,108,111,114,115,47,116,104,101,109,101,46,106,115,111,110,123,34,109,101,116,97,34,58,123,34,110,97,109,101,34,58,34,120,34,125,125,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,70,221,171,251,38,0,0,0,36,0,0,0,13,0,0,0,0,0,0,0,0,0,0,0,128,1,0,0,0,0,109,101,116,97,100,97,116,97,46,106,115,111,110,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,175,166,158,248,14,0,0,0,12,0,0,0,19,0,0,0,0,0,0,0,0,0,0,0,128,1,81,0,0,0,112,108,117,103,105,110,115,47,95,95,105,110,105,116,95,95,46,112,121,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,136,205,60,219,64,0,0,0,16,4,0,0,22,0,0,0,0,0,0,0,0,0,0,0,128,1,144,0,0,0,115,121,109,98,111,108,115,47,65,100,111,109,46,107,105,99,97,100,95,115,121,109,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,173,223,251,226,33,0,0,0,31,0,0,0,34,0,0,0,0,0,0,0,0,0,0,0,128,1,4,1,0,0,102,111,111,116,112,114,105,110,116,115,47,65,100,111,109,46,112,114,101,116,116,121,47,82,46,107,105,99,97,100,95,109,111,100,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,79,211,211,10,41,0,0,0,58,0,0,0,29,0,0,0,0,0,0,0,0,0,0,0,128,1,101,1,0,0,51,100,109,111,100,101,108,115,47,65,100,111,109,46,51,100,115,104,97,112,101,115,47,82,46,115,116,101,112,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,0,0,0,0,2,0,0,0,0,0,0,0,11,0,0,0,0,0,0,0,0,0,16,0,253,65,201,1,0,0,102,111,111,116,112,114,105,110,116,115,47,80,75,1,2,20,3,20,0,0,0,0,0,0,0,33,0,163,169,132,166,21,0,0,0,21,0,0,0,17,0,0,0,0,0,0,0,0,0,0,0,128,1,244,1,0,0,99,111,108,111,114,115,47,116,104,101,109,101,46,106,115,111,110,80,75,5,6,0,0,0,0,7,0,7,0,211,1,0,0,56,2,0,0,0,0];+    const ZIP_SHA: &str = "6b091da4f29425644b6bb432a635430ae43d8ed4eff700a54f053cd3d3aea5bc";+    // zlib.compressobj(9, DEFLATED, -15) of a 2280-byte repetitive text: a dynamic-huffman block.+    const RAW_DYN: &[u8] = &[237,202,215,17,128,32,20,5,209,86,110,5,142,57,244,65,3,68,179,40,138,169,122,173,131,121,159,59,123,88,167,177,249,94,142,16,206,94,11,140,189,49,248,121,221,97,79,237,112,252,123,226,239,3,101,219,8,140,48,97,194,132,9,19,14,11,199,73,154,229,69,89,213,13,23,82,105,67,29,86,127];+    const RAW_STORED: &[u8] = &[1,18,0,237,255,104,101,108,108,111,32,115,116,111,114,101,100,32,98,108,111,99,107];++    #[test]+    fn sha256_vectors() {+        assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");+        assert_eq!(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");+        assert_eq!(sha256_hex(&[b'a'; 1000]), "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3");+        assert_eq!(sha256_hex(ZIP), ZIP_SHA);+    }++    #[test]+    fn inflate_dynamic_and_stored_blocks() {+        let text = "The quick brown fox jumps over the lazy dog. ".repeat(40) + &"0123456789abcdef".repeat(30);+        let out = inflate::inflate(RAW_DYN, text.len()).unwrap();+        assert_eq!(out, text.as_bytes());+        assert_eq!(inflate::inflate(RAW_STORED, 18).unwrap(), b"hello stored block");+        assert!(inflate::inflate(RAW_DYN, 10).is_err(), "output bound enforced");+        assert!(inflate::inflate(&RAW_DYN[..20], text.len()).is_err(), "truncated stream reported");+    }++    #[test]+    fn zip_reader_lists_and_reads_entries() {+        let zf = ZipFile::parse(ZIP).unwrap();+        let names: Vec<&str> = zf.entries.iter().map(|e| e.name.as_str()).collect();+        assert_eq!(names, vec!["metadata.json", "plugins/__init__.py", "symbols/Adom.kicad_sym", "footprints/Adom.pretty/R.kicad_mod", "3dmodels/Adom.3dshapes/R.step", "footprints/", "colors/theme.json"]);+        let sym = zf.entries.iter().find(|e| e.name == "symbols/Adom.kicad_sym").unwrap();+        assert_eq!(sym.method, 8);+        assert_eq!(zf.read(sym).unwrap(), "(kicad_symbol_lib (version 20231120) (generator t))\n".repeat(20).as_bytes());+        let stored = zf.entries.iter().find(|e| e.name == "colors/theme.json").unwrap();+        assert_eq!(stored.method, 0);+        assert_eq!(zf.read(stored).unwrap(), br#"{"meta":{"name":"x"}}"#);+        assert!(ZipFile::parse(b"not a zip at all, definitely not").is_err());+    }++    struct Tmp(PathBuf);+    impl Drop for Tmp {+        fn drop(&mut self) {+            let _ = std::fs::remove_dir_all(&self.0);+        }+    }+    fn tmp(name: &str) -> Tmp {+        let p = std::env::temp_dir().join(format!("kicad-core-pcm-{}-{name}", std::process::id()));+        let _ = std::fs::remove_dir_all(&p);+        std::fs::create_dir_all(&p).unwrap();+        Tmp(p)+    }+    fn ctx(root: &Path) -> Ctx {+        Ctx { version: "10.0.6".into(), base_dir: None, config_dir: Some(root.join("config")), user_dir: Some(root.join("Documents/KiCad/10.0")), common: Value::Null }+    }+    const REPO_URL: &str = "https://wiki.adom.inc/adom/kicad-pcm/repository.json";+    fn repo_table(download_sha: Option<&str>) -> HashMap<String, Vec<u8>> {+        let mut v = json!({"version": "1.0.0", "download_url": "https://example.test/pkg.zip"});+        if let Some(s) = download_sha {+            v["download_sha256"] = json!(s);+        }+        let packages = json!({"packages": [+            {"identifier": "inc.adom.kicad.test", "name": "Adom Test", "type": "library", "versions": [{"version": "0.9.0", "download_url": "https://example.test/old.zip"}, v]},+            {"identifier": "inc.adom.kicad.other", "name": "Other", "type": "plugin", "versions": []}+        ]}).to_string().into_bytes();+        let repo = json!({"name": "Adom PCM", "packages": {"url": "https://example.test/packages.json", "sha256": sha256_hex(&packages)}}).to_string().into_bytes();+        let mut t = HashMap::new();+        t.insert(REPO_URL.to_string(), repo);+        t.insert("https://example.test/packages.json".to_string(), packages);+        t.insert("https://example.test/pkg.zip".to_string(), ZIP.to_vec());+        t+    }+    fn fetcher(table: HashMap<String, Vec<u8>>) -> impl Fn(&str, Duration) -> Result<Vec<u8>, String> {+        move |url: &str, _t: Duration| table.get(url).cloned().ok_or_else(|| format!("URLError: no such url {url}"))+    }++    #[test]+    fn extract_lays_out_like_pcm() {+        let t = tmp("extract");+        let third = t.0.join("3rdparty");+        let e = extract(ZIP, &third, "inc.adom.kicad.test").unwrap();+        assert_eq!(e.package_dir, "inc_adom_kicad_test");+        assert_eq!(e.files, 5, "metadata.json and the directory entry are skipped");+        assert_eq!(e.symbols, vec!["Adom"]);+        assert_eq!(e.footprints, vec!["Adom"]);+        assert_eq!(e.models, vec!["Adom.3dshapes"]);+        assert!(third.join("footprints/inc_adom_kicad_test/Adom.pretty/R.kicad_mod").is_file());+        assert!(third.join("plugins/inc_adom_kicad_test/__init__.py").is_file());+        assert!(third.join("colors/inc_adom_kicad_test/theme.json").is_file());+        assert!(!third.join("metadata.json").exists());+    }++    #[test]+    fn install_list_uninstall_round_trip() {+        let t = tmp("install");+        let c = ctx(&t.0);+        let f = fetcher(repo_table(Some(ZIP_SHA)));+        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &f);+        assert_eq!(r["success"], json!(true), "{r}");+        assert_eq!(r["version"], json!("1.0.0"));+        assert_eq!(r["filesInstalled"], json!(5));+        assert_eq!(r["symbolLibraries"], json!(["PCM_Adom"]));+        assert_eq!(r["footprintLibraries"], json!(["PCM_Adom"]));+        assert_eq!(r["modelSets"], json!(["Adom.3dshapes"]));+        assert_eq!(r["restartNeeded"], json!(true));+        assert_eq!(r["thirdPartyDir"], json!(norm(&t.0.join("Documents/KiCad/10.0/3rdparty"))));+        let reg = read_installed(&t.0.join("config/installed_packages.json"));+        let e = &reg["packages"][0];+        assert_eq!(e["current_version"], json!("1.0.0"));+        assert_eq!(e["repository_id"], json!(&sha256_hex(REPO_URL.as_bytes())[..16]));+        assert_eq!(e["repository_name"], json!("Adom PCM"));+        assert_eq!(e["pinned"], json!(false));+        assert_eq!(e["package"]["versions"].as_array().unwrap().len(), 1, "only the installed version is recorded");+        assert!(e["install_timestamp"].as_u64().unwrap() > 1_700_000_000);+        // second call: already installed unless forced+        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &f);+        assert_eq!(r["alreadyInstalled"], json!(true));+        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test", "force": true, "libPrefix": "X_"}), &f);+        assert_eq!(r["symbolLibraries"], json!(["X_Adom"]));+        // list+        let l = list(&c);+        assert_eq!(l["count"], json!(1));+        assert_eq!(l["packages"][0]["identifier"], json!("inc.adom.kicad.test"));+        assert_eq!(l["packages"][0]["onDisk"], json!(true));+        assert_eq!(l["kicadVersion"], json!("10.0.6"));+        // uninstall+        let u = uninstall(&c, &json!({"identifier": "inc.adom.kicad.test"}));+        assert_eq!(u["recordRemoved"], json!(true));+        assert_eq!(u["removedDirs"].as_array().unwrap().len(), 5);+        assert!(!t.0.join("Documents/KiCad/10.0/3rdparty/symbols/inc_adom_kicad_test").exists());+        assert_eq!(list(&c)["count"], json!(0));+        let u = uninstall(&c, &json!({"identifier": "inc.adom.kicad.test"}));+        assert_eq!(u["recordRemoved"], json!(false));+    }++    #[test]+    fn install_refusals() {+        let t = tmp("refuse");+        let c = ctx(&t.0);+        let f = fetcher(repo_table(Some("00")));+        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &f);+        assert_eq!(r["error"], json!("download sha256 mismatch"));+        assert_eq!(r["got"], json!(ZIP_SHA));+        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.nope"}), &f);+        assert!(r["error"].as_str().unwrap().starts_with("package not in repository"));+        assert_eq!(r["available"], json!(["inc.adom.kicad.test", "inc.adom.kicad.other"]));+        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test", "version": "3.0"}), &f);+        assert_eq!(r["error"], json!("version 3.0 not offered"));+        assert_eq!(r["available"], json!(["0.9.0", "1.0.0"]));+        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.other"}), &f);+        assert_eq!(r["error"], json!("package has no downloadable version"));+        let r = install(&c, &json!({"identifier": "x"}), &f);+        assert!(r["error"].as_str().unwrap().contains("required"));+        // packages.json digest mismatch+        let mut table = repo_table(None);+        table.insert("https://example.test/packages.json".into(), b"{\"packages\":[]}".to_vec());+        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &fetcher(table));+        assert!(r["error"].as_str().unwrap().contains("hash does not match"));+        // unreachable repository+        let r = install(&c, &json!({"repositoryUrl": "https://example.test/none.json", "identifier": "x"}), &fetcher(HashMap::new()));+        assert!(r["error"].as_str().unwrap().starts_with("could not read repository: URLError"));+        // no KiCad dirs known+        let r = install(&Ctx::default(), &json!({"repositoryUrl": REPO_URL, "identifier": "x"}), &f);+        assert!(r["error"].as_str().unwrap().contains("directories unknown"));+        assert_eq!(list(&Ctx::default())["success"], json!(false));+    }++    #[test]+    fn kicad_json_prefs_and_common_override() {+        let t = tmp("prefs");+        let mut c = ctx(&t.0);+        std::fs::create_dir_all(t.0.join("config")).unwrap();+        std::fs::write(t.0.join("config/kicad.json"), r#"{"pcm": {"lib_auto_add": false, "lib_prefix": "ADOM_"}}"#).unwrap();+        c.common = json!({"environment": {"vars": {"KICAD10_3RD_PARTY": norm(&t.0.join("custom3rd"))}}});+        let f = fetcher(repo_table(None));+        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &f);+        assert_eq!(r["success"], json!(true), "{r}");+        assert_eq!(r["libPrefix"], json!("ADOM_"));+        assert_eq!(r["libAutoAdd"], json!(false));+        assert_eq!(r["symbolLibraries"], json!(["ADOM_Adom"]));+        assert_eq!(r["thirdPartyResolvedBy"], json!("kicad_common.json override"));+        assert!(t.0.join("custom3rd/symbols/inc_adom_kicad_test/Adom.kicad_sym").is_file());+        assert!(r["_hint"].as_str().unwrap().contains("lib_auto_add is OFF"));+    }++    #[test]+    fn installed_registry_is_tolerant_and_pretty() {+        let t = tmp("registry");+        let p = t.0.join("installed_packages.json");+        assert_eq!(read_installed(&p), json!({"packages": []}));+        std::fs::write(&p, "garbage").unwrap();+        assert_eq!(read_installed(&p), json!({"packages": []}));+        write_installed(&p, &json!({"packages": [{"current_version": "1"}]})).unwrap();+        let text = std::fs::read_to_string(&p).unwrap();+        assert!(text.starts_with("{\n    \"packages\""), "{text}");+        assert!(text.ends_with("}\n"));+        assert_eq!(read_installed(&p)["packages"][0]["current_version"], json!("1"));+    }+}
rust/crates/kicad-core/src/settings.rsadded+185
@@ -0,0 +1,185 @@+//! The bridge's own user-visible settings (`kicad_get_settings` / `kicad_set_settings`),+//! ported from `handlers/settings.py`. These are NOT KiCad's settings: they are the+//! preferences the dashboard renders (taskbar overlay badges, activity indicator),+//! persisted per box in `<home>/.adom/kicad-bridge-settings.json`.+//!+//! The Python writes the file in place with no backup and no KiCad-running check;+//! the port keeps that contract but writes through a sibling temp file and a+//! rename so a crash mid-write cannot leave a half-written JSON behind.++use std::path::PathBuf;++use serde_json::{json, Map, Value};++/// Every settable key with its default. Order is the order `_hint` lists them.+pub fn defaults() -> Vec<(&'static str, Value)> {+    vec![+        ("overlayBadges", json!(true)),+        // How the taskbar shows "Adom is driving KiCad": progress paints a bar from+        // the measured verb timings and suppresses the OS flash; flash leaves the+        // Windows attention flash alone; both; none.+        ("activityIndicator", json!("progress")),+    ]+}++pub const ACTIVITY_CHOICES: &[&str] = &["progress", "flash", "both", "none"];++pub fn defaults_map() -> Map<String, Value> {+    defaults().into_iter().map(|(k, v)| (k.to_string(), v)).collect()+}++/// `<USERPROFILE or HOME>/.adom/kicad-bridge-settings.json`. Creates `.adom/`.+pub fn settings_path() -> PathBuf {+    let base = std::env::var("USERPROFILE").ok().filter(|s| !s.is_empty())+        .or_else(|| std::env::var("HOME").ok().filter(|s| !s.is_empty()))+        .map(PathBuf::from)+        .unwrap_or_else(|| PathBuf::from("."));+    let d = base.join(".adom");+    let _ = std::fs::create_dir_all(&d);+    d.join("kicad-bridge-settings.json")+}++/// Defaults overlaid with whatever the file holds (unknown keys are kept, like the+/// Python `dict.update`). An unreadable or invalid file yields the defaults.+pub fn get_settings() -> Map<String, Value> {+    let mut out = defaults_map();+    if let Ok(text) = std::fs::read_to_string(settings_path()) {+        if let Ok(Value::Object(m)) = serde_json::from_str::<Value>(&text) {+            for (k, v) in m {+                out.insert(k, v);+            }+        }+    }+    out+}++/// Type a raw value by its default's type. The first Python cut bool()'d everything,+/// so `activityIndicator:"flash"` persisted as true; this is the fix carried over.+pub fn coerce(key: &str, raw: &Value) -> Result<Value, String> {+    if key == "activityIndicator" {+        let v = value_text(raw).trim().to_lowercase();+        if !ACTIVITY_CHOICES.contains(&v.as_str()) {+            return Err(format!("{key} must be one of {}", ACTIVITY_CHOICES.join(", ")));+        }+        return Ok(json!(v));+    }+    let default = defaults().into_iter().find(|(k, _)| *k == key).map(|(_, v)| v).unwrap_or(Value::Null);+    if default.is_boolean() {+        return Ok(json!(match raw {+            Value::String(s) => matches!(s.trim().to_lowercase().as_str(), "1" | "true" | "on" | "yes"),+            Value::Bool(b) => *b,+            Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),+            Value::Null => false,+            Value::Array(a) => !a.is_empty(),+            Value::Object(o) => !o.is_empty(),+        }));+    }+    Ok(raw.clone())+}++fn value_text(v: &Value) -> String {+    match v {+        Value::String(s) => s.clone(),+        Value::Bool(b) => if *b { "True".into() } else { "False".into() },+        other => other.to_string(),+    }+}++/// Python `handle_set_settings`: `restoreDefaults` first, then every known key+/// present in `args`. Returns the full verb response.+pub fn set_settings(args: &Value) -> Value {+    let mut s = get_settings();+    let mut changed = Map::new();+    if args.get("restoreDefaults").and_then(truthy).unwrap_or(false) {+        for (k, v) in defaults() {+            if s.get(k) != Some(&v) {+                changed.insert(k.to_string(), v.clone());+            }+            s.insert(k.to_string(), v);+        }+    }+    for (k, _) in defaults() {+        if let Some(raw) = args.get(k) {+            let v = match coerce(k, raw) {+                Ok(v) => v,+                Err(e) => return json!({"success": false, "error": e}),+            };+            if s.get(k) != Some(&v) {+                changed.insert(k.to_string(), v.clone());+            }+            s.insert(k.to_string(), v);+        }+    }+    let keys: Vec<&str> = defaults().into_iter().map(|(k, _)| k).collect();+    if changed.is_empty() {+        return json!({"success": true, "settings": s, "changed": {},+                      "output": "no settings changed",+                      "_hint": format!("Settable keys: {}.", keys.join(", "))});+    }+    let text = serde_json::to_string_pretty(&Value::Object(s.clone())).unwrap_or_default();+    if let Err(e) = crate::libraries::write_atomic(&settings_path(), text.as_bytes()) {+        return json!({"success": false, "error": format!("could not persist settings: {e}")});+    }+    json!({"success": true, "settings": s, "changed": changed,+           "output": format!("settings updated: {}", python_repr(&changed))})+}++/// A Python-flavoured truthiness for the `restoreDefaults` flag.+fn truthy(v: &Value) -> Option<bool> {+    Some(match v {+        Value::Bool(b) => *b,+        Value::Null => false,+        Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),+        Value::String(s) => !s.is_empty(),+        Value::Array(a) => !a.is_empty(),+        Value::Object(o) => !o.is_empty(),+    })+}++/// `{'overlayBadges': False}`: the Python `output` line prints the dict with repr.+fn python_repr(m: &Map<String, Value>) -> String {+    let parts: Vec<String> = m+        .iter()+        .map(|(k, v)| {+            let val = match v {+                Value::Bool(true) => "True".to_string(),+                Value::Bool(false) => "False".to_string(),+                Value::Null => "None".to_string(),+                Value::String(s) => format!("'{s}'"),+                other => other.to_string(),+            };+            format!("'{k}': {val}")+        })+        .collect();+    format!("{{{}}}", parts.join(", "))+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn coerce_types_by_default() {+        assert_eq!(coerce("overlayBadges", &json!("off")).unwrap(), json!(false));+        assert_eq!(coerce("overlayBadges", &json!("Yes")).unwrap(), json!(true));+        assert_eq!(coerce("overlayBadges", &json!(0)).unwrap(), json!(false));+        assert_eq!(coerce("activityIndicator", &json!("Flash")).unwrap(), json!("flash"));+        assert!(coerce("activityIndicator", &json!("blink")).is_err());+        assert!(coerce("activityIndicator", &json!(true)).is_err());+    }++    #[test]+    fn repr_matches_python() {+        let mut m = Map::new();+        m.insert("overlayBadges".into(), json!(false));+        m.insert("activityIndicator".into(), json!("flash"));+        assert_eq!(python_repr(&m), "{'activityIndicator': 'flash', 'overlayBadges': False}");+    }++    #[test]+    fn defaults_have_every_key() {+        let d = defaults_map();+        assert_eq!(d.get("overlayBadges"), Some(&json!(true)));+        assert_eq!(d.get("activityIndicator"), Some(&json!("progress")));+    }+}
rust/crates/kicad-core/src/updates.rsadded+267
@@ -0,0 +1,267 @@+//! kicad_check_for_updates: the read-only half of handlers/upgrade.py.+//!+//! Installed KiCad versus the latest STABLE release, resolved from KiCad's GitLab tags+//! (baked fallback on a network blip), plus the "your KiCad predates the IPC API,+//! upgrade" nudge and the official downloads.kicad.org installer URL. The installer+//! itself (kicad_upgrade: download, silent NSIS install, elevation facts) is a later+//! phase; `remote_installer_size` is here already because it is the anti-truncation+//! ground truth that phase needs.+//!+//! TLS is rustls with bundled roots, so the CA-store problem the Python bridge had on+//! ab's portable interpreter cannot recur.++use std::time::Duration;++use serde_json::{json, Value};++/// Baked fallback if the GitLab tag query fails. Bump on new stable.+pub const FALLBACK_LATEST: &str = "10.0.6";+pub const TAGS_URL: &str = "https://gitlab.com/api/v4/projects/kicad%2Fcode%2Fkicad/repository/tags?per_page=30";+const DL_URL: &str = "https://downloads.kicad.org/kicad/windows/explore/stable/download/kicad-{v}-x86_64.exe";+const UA: &str = "kicad-bridge";+/// The oldest KiCad whose IPC API our routing verbs drive (docs/rust-port-plan.md).+const IPC_MIN: [u32; 3] = [10, 0, 1];++pub type Fetch<'a> = &'a dyn Fn(&str, Duration) -> Result<Vec<u8>, String>;++/// GET a URL with the bridge user agent (rustls, bundled roots).+#[cfg(not(feature = "net"))]+pub fn http_fetch(_url: &str, _timeout: Duration) -> Result<Vec<u8>, String> {+    Err("the net feature is off in this build: no HTTPS client".into())+}++#[cfg(feature = "net")]+pub fn http_fetch(url: &str, timeout: Duration) -> Result<Vec<u8>, String> {+    let mut resp = ureq::get(url)+        .header("User-Agent", UA)+        .config()+        .timeout_global(Some(timeout))+        .build()+        .call()+        .map_err(|e| e.to_string())?;+    resp.body_mut().with_config().limit(8 * 1024 * 1024).read_to_vec().map_err(|e| e.to_string())+}++/// Parse "10.0" / "9.0.9.1" / "8.0.4-rc1" into a sortable vector (kicad_detect._version_tuple):+/// pieces split on `.`, `-`, `_`; leading digits or 0.+pub fn version_tuple(v: &str) -> Vec<u32> {+    let parts: Vec<u32> = v+        .split(['.', '-', '_'])+        .map(|piece| {+            let digits: String = piece.chars().take_while(|c| c.is_ascii_digit()).collect();+            digits.parse::<u32>().unwrap_or(0)+        })+        .collect();+    if parts.is_empty() {+        vec![0]+    } else {+        parts+    }+}++/// True for a real stable tag like 10.0.4 / 9.0.9.1. Drops `-rcN` prereleases and+/// KiCad's `X.99.*` nightly tags (minor component == 99).+pub fn is_stable(tag: &str) -> bool {+    if tag.is_empty() {+        return false;+    }+    let parts: Vec<&str> = tag.split('.').collect();+    if parts.len() < 2 || parts.iter().any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit())) {+        return false;+    }+    parts[1] != "99"+}++/// The newest stable tag in a GitLab tags API response body, if any.+pub fn latest_stable_from_tags(body: &[u8]) -> Option<String> {+    let tags: Value = serde_json::from_slice(body).ok()?;+    let mut stables: Vec<String> = tags+        .as_array()?+        .iter()+        .filter_map(|t| t.get("name").and_then(Value::as_str))+        .filter(|t| is_stable(t))+        .map(String::from)+        .collect();+    stables.sort_by(|a, b| version_tuple(b).cmp(&version_tuple(a)));+    stables.into_iter().next()+}++/// (version, source) where source is "gitlab" or "fallback".+pub fn latest_stable_version(fetch: Fetch) -> (String, &'static str) {+    if let Ok(body) = fetch(TAGS_URL, Duration::from_secs(10)) {+        if let Some(v) = latest_stable_from_tags(&body) {+            return (v, "gitlab");+        }+    }+    (FALLBACK_LATEST.to_string(), "fallback")+}++pub fn download_url(version: &str) -> String {+    DL_URL.replace("{v}", version)+}++/// The server's size for the installer, redirects followed. The installer phase+/// compares a cached or downloaded file against this so a dropped connection cannot+/// pass as a complete download. HEAD first; downloads.kicad.org answers 405 to HEAD,+/// so the fallback is a one-byte ranged GET and the total in Content-Range (the+/// s3.cern.ch mirror honours ranges). None when both probes fail.+#[cfg(not(feature = "net"))]+pub fn remote_installer_size(_url: &str, _timeout: Duration) -> Option<u64> {+    None+}++#[cfg(feature = "net")]+pub fn remote_installer_size(url: &str, timeout: Duration) -> Option<u64> {+    let head = ureq::head(url).header("User-Agent", UA).config().timeout_global(Some(timeout)).build().call();+    if let Ok(resp) = head {+        if let Some(n) = resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|s| s.trim().parse::<u64>().ok()) {+            return Some(n);+        }+    }+    let resp = ureq::get(url)+        .header("User-Agent", UA)+        .header("Range", "bytes=0-0")+        .config()+        .timeout_global(Some(timeout))+        .build()+        .call()+        .ok()?;+    if resp.status().as_u16() == 206 {+        return content_range_total(resp.headers().get("content-range")?.to_str().ok()?);+    }+    // A server that ignores Range answers 200 with the whole body; its Content-Length is the size.+    resp.headers().get("content-length")?.to_str().ok()?.trim().parse().ok()+}++/// "bytes 0-0/967765696" -> 967765696.+pub fn content_range_total(value: &str) -> Option<u64> {+    value.trim().rsplit('/').next()?.trim().parse().ok().filter(|n: &u64| *n > 0)+}++/// Does this KiCad version carry the IPC API our routing verbs need?+pub fn ipc_api_available(version: &str) -> bool {+    version_tuple(version) >= IPC_MIN.to_vec()+}++pub const HINT: &str = "Run kicad_upgrade '{}' to install the latest (downloads the OFFICIAL downloads.kicad.org installer + silent /S install, NOT winget, which lags). Pass {\"version\":\"X.Y.Z\"} to pin a specific build.";++/// kicad_check_for_updates: is a newer KiCad available, and does the current one have the IPC API?+/// `current` is the installed kicad-cli X.Y.Z (None when KiCad is not installed);+/// `installer_bytes` is an optional HEAD probe result to report as downloadBytes.+pub fn check_for_updates(current: Option<&str>, fetch: Fetch, installer_bytes: Option<Option<u64>>) -> Value {+    let (latest, src) = latest_stable_version(fetch);+    let cur_t = current.map(version_tuple).unwrap_or_else(|| vec![0]);+    let lat_t = version_tuple(&latest);+    let upgrade_available = current.is_none() || lat_t > cur_t;+    let ipc = current.map(ipc_api_available).unwrap_or(false);+    let rec = match current {+        None => format!("KiCad isn't installed. Run kicad_upgrade to install {latest} from the official downloads.kicad.org installer."),+        Some(c) if upgrade_available && !ipc => {+            format!("Your KiCad {c} predates the modern IPC / kipy automation API: upgrade to {latest} to unlock it (plus the latest fixes). Run kicad_upgrade.")+        }+        Some(c) if upgrade_available => format!("KiCad {latest} is available (you're on {c}). Run kicad_upgrade to update."),+        Some(c) => format!("KiCad {c} is up to date (latest stable is {latest})."),+    };+    let mut out = json!({+        "success": true,+        "currentVersion": current,+        "latestVersion": latest,+        "latestSource": src,+        "upgradeAvailable": upgrade_available,+        "ipcApiAvailable": ipc,+        "downloadUrl": download_url(&latest),+        "recommendation": rec,+        "_hint": HINT,+    });+    if let Some(bytes) = installer_bytes {+        out["downloadBytes"] = json!(bytes);+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    const TAGS: &str = r#"[{"name":"10.99.0-rc1"},{"name":"10.0.6"},{"name":"10.0.6-rc1"},{"name":"9.0.9.1"},{"name":"10.0.10"},{"name":"10.99.5"},{"name":"v1"}]"#;++    #[test]+    fn version_tuples_sort_like_python() {+        assert_eq!(version_tuple("10.0"), vec![10, 0]);+        assert_eq!(version_tuple("9.0.9.1"), vec![9, 0, 9, 1]);+        assert_eq!(version_tuple("8.0.4-rc1"), vec![8, 0, 4, 0]);+        assert_eq!(version_tuple(""), vec![0]);+        assert!(version_tuple("10.0.10") > version_tuple("10.0.6"));+        assert!(version_tuple("10.0") < version_tuple("10.0.5"));+        assert!(ipc_api_available("10.0.6"));+        assert!(ipc_api_available("10.0.1"));+        assert!(!ipc_api_available("10.0.0"));+        assert!(!ipc_api_available("9.0.9"));+    }++    #[test]+    fn stable_tags_only() {+        assert!(is_stable("10.0.4"));+        assert!(is_stable("9.0.9.1"));+        assert!(!is_stable("10.0.4-rc1"));+        assert!(!is_stable("10.99.0"));+        assert!(!is_stable("v10"));+        assert!(!is_stable("10"));+        assert!(!is_stable(""));+        assert_eq!(latest_stable_from_tags(TAGS.as_bytes()), Some("10.0.10".into()));+        assert_eq!(latest_stable_from_tags(b"[]"), None);+        assert_eq!(latest_stable_from_tags(b"not json"), None);+    }++    #[test]+    fn latest_falls_back_when_offline() {+        let offline = |_: &str, _: Duration| Err::<Vec<u8>, String>("no network in tests".into());+        assert_eq!(latest_stable_version(&offline), (FALLBACK_LATEST.to_string(), "fallback"));+        let online = |url: &str, _: Duration| {+            assert_eq!(url, TAGS_URL);+            Ok::<Vec<u8>, String>(TAGS.as_bytes().to_vec())+        };+        assert_eq!(latest_stable_version(&online), ("10.0.10".to_string(), "gitlab"));+        assert_eq!(download_url("10.0.6"), "https://downloads.kicad.org/kicad/windows/explore/stable/download/kicad-10.0.6-x86_64.exe");+    }++    #[test]+    fn content_range_totals() {+        assert_eq!(content_range_total("bytes 0-0/967765696"), Some(967765696));+        assert_eq!(content_range_total(" bytes 0-0/12 "), Some(12));+        assert_eq!(content_range_total("bytes */0"), None);+        assert_eq!(content_range_total("bytes 0-0/*"), None);+        assert_eq!(content_range_total(""), None);+    }++    #[test]+    fn check_for_updates_shapes() {+        let online = |_: &str, _: Duration| Ok::<Vec<u8>, String>(TAGS.as_bytes().to_vec());+        let r = check_for_updates(Some("10.0.6"), &online, None);+        assert_eq!(r["success"], json!(true));+        assert_eq!(r["currentVersion"], json!("10.0.6"));+        assert_eq!(r["latestVersion"], json!("10.0.10"));+        assert_eq!(r["latestSource"], json!("gitlab"));+        assert_eq!(r["upgradeAvailable"], json!(true));+        assert_eq!(r["ipcApiAvailable"], json!(true));+        assert!(r["recommendation"].as_str().unwrap().starts_with("KiCad 10.0.10 is available (you're on 10.0.6)"));+        assert!(r.get("downloadBytes").is_none());++        let r = check_for_updates(Some("10.0.10"), &online, Some(Some(1234)));+        assert_eq!(r["upgradeAvailable"], json!(false));+        assert!(r["recommendation"].as_str().unwrap().contains("up to date"));+        assert_eq!(r["downloadBytes"], json!(1234));++        let r = check_for_updates(Some("9.0.9"), &online, None);+        assert_eq!(r["ipcApiAvailable"], json!(false));+        assert!(r["recommendation"].as_str().unwrap().contains("predates the modern IPC"));++        let offline = |_: &str, _: Duration| Err::<Vec<u8>, String>("offline".into());+        let r = check_for_updates(None, &offline, None);+        assert_eq!(r["currentVersion"], Value::Null);+        assert_eq!(r["latestSource"], json!("fallback"));+        assert_eq!(r["upgradeAvailable"], json!(true));+        assert!(r["recommendation"].as_str().unwrap().starts_with("KiCad isn't installed"));+        assert_eq!(r["downloadUrl"], json!(download_url(FALLBACK_LATEST)));+    }+}
rust/crates/kicad-core/tests/fixtures/rp2040-bom.csvadded+21
@@ -0,0 +1,21 @@+"Refs","Value","Footprint","Qty","DNP"+"C1","100nF","Capacitor_SMD:C_0402_1005Metric","1",""+"C2","100nF","Capacitor_SMD:C_0402_1005Metric","1",""+"C3","1uF","Capacitor_SMD:C_0402_1005Metric","1",""+"C4","22pF","Capacitor_SMD:C_0402_1005Metric","1",""+"C5","22pF","Capacitor_SMD:C_0402_1005Metric","1",""+"J1","USB-C","Connector_USB:USB_C_Receptacle_Amphenol_12401610E4-2A","1",""+"MC1","MC","AdomRP2040:ADOM_MEDIUM_CONTACT","1",""+"MC2","MC","AdomRP2040:ADOM_MEDIUM_CONTACT","1",""+"MC3","MC","AdomRP2040:ADOM_MEDIUM_CONTACT","1",""+"MC4","MC","AdomRP2040:ADOM_MEDIUM_CONTACT","1",""+"MC5","MC","AdomRP2040:ADOM_MEDIUM_CONTACT","1",""+"MC6","MC","AdomRP2040:ADOM_MEDIUM_CONTACT","1",""+"MC7","MC","AdomRP2040:ADOM_MEDIUM_CONTACT","1",""+"MC8","MC","AdomRP2040:ADOM_MEDIUM_CONTACT","1",""+"MP1","MP","AdomRP2040:ADOM_MEDIUM_PIN_SHORTER_v1","1",""+"MP2","MP","AdomRP2040:ADOM_MEDIUM_PIN_SHORTER_v1","1",""+"MP3","MP","AdomRP2040:ADOM_MEDIUM_PIN_SHORTER_v1","1",""+"MP4","MP","AdomRP2040:ADOM_MEDIUM_PIN_SHORTER_v1","1",""+"U1","RP2040","AdomRP2040:QFN-56_AdomRP2040","1",""+"Y1","12MHz","Crystal:Crystal_SMD_3225-4Pin_3.2x2.5mm","1",""
rust/crates/kicad-core/tests/fixtures/rp2040-drc.jsonadded+1740
@@ -0,0 +1,1740 @@+{+    "$schema": "https://schemas.kicad.org/drc.v1.json",+    "coordinate_units": "mm",+    "date": "2026-09-11T12:03:14",+    "ignored_checks": [+        {+            "description": "Footprint has no courtyard defined",+            "key": "missing_courtyard"+        },+        {+            "description": "Track endpoint not centered on via",+            "key": "track_not_centered_on_via"+        },+        {+            "description": "Tuning profile track geometries",+            "key": "tuning_profile_track_geometries"+        },+        {+            "description": "Footprint doesn't match symbol's footprint filters",+            "key": "footprint_filters_mismatch"+        },+        {+            "description": "Footprint component type doesn't match footprint pads",+            "key": "footprint_type_mismatch"+        }+    ],+    "included_severities": [+        "error",+        "warning"+    ],+    "kicad_version": "10.0.2",+    "schematic_parity": [],+    "source": ".tmp9SeItV.kicad_pcb",+    "unconnected_items": [],+    "violations": [+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad A3 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 108.25+                    },+                    "uuid": "fd52fd5b-7516-471e-b906-7e8a110756fb"+                },+                {+                    "description": "Pad A2 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 107.75+                    },+                    "uuid": "b2b488a1-77f6-4a87-87d2-c50e9fa3f33a"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad A1 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 107.25+                    },+                    "uuid": "19db812f-109a-43c0-8c36-c62bf5339a2c"+                },+                {+                    "description": "Pad A2 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 107.75+                    },+                    "uuid": "b2b488a1-77f6-4a87-87d2-c50e9fa3f33a"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad A7 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 110.25+                    },+                    "uuid": "7a3f0c8f-c12d-41d1-8e85-a87061f9da69"+                },+                {+                    "description": "Pad A6 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 109.75+                    },+                    "uuid": "299a155b-1678-4aa7-af4f-981e384493e9"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad B3 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 111.5+                    },+                    "uuid": "b8838ebc-0797-49d2-ac3e-072751b36d37"+                },+                {+                    "description": "Pad B4 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 111.0+                    },+                    "uuid": "4b96c5f1-ce34-484d-9ece-30002deafabc"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad A9 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 111.25+                    },+                    "uuid": "37569dab-d7e1-4f03-b3a9-67d1cbce4c19"+                },+                {+                    "description": "Pad A8 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 110.75+                    },+                    "uuid": "9717ecb1-3e50-4712-ae62-6c8b56bd7b12"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad B1 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 112.5+                    },+                    "uuid": "c92d8a4c-d0d1-4328-ab06-ef97ae4763ff"+                },+                {+                    "description": "Pad B2 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 112.0+                    },+                    "uuid": "d4294c37-fa42-47bc-b0ed-65786d89b5d7"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad A11 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 112.25+                    },+                    "uuid": "ab33561d-5678-468f-b47f-eb09a93eada1"+                },+                {+                    "description": "Pad A10 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 111.75+                    },+                    "uuid": "813ac32d-8495-4b2e-ac0e-32bca337a07e"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad A5 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 109.25+                    },+                    "uuid": "e002a928-c69d-4aa5-b7bf-425b9f784cd1"+                },+                {+                    "description": "Pad A4 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 108.75+                    },+                    "uuid": "af567fd8-1b0b-4e41-b59c-73ad3828f689"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad B7 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 109.5+                    },+                    "uuid": "71109a45-faa1-465b-8bd9-057db91dce40"+                },+                {+                    "description": "Pad B6 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 110.0+                    },+                    "uuid": "8aa526f4-ddb3-4feb-9111-04bc706e378c"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad B9 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 108.5+                    },+                    "uuid": "d1e8ae03-510a-436a-be79-c95c325d2e8b"+                },+                {+                    "description": "Pad B10 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 108.0+                    },+                    "uuid": "1057d372-3993-4593-8846-396afaf466dc"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad B5 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 110.5+                    },+                    "uuid": "979d6779-6cc8-4ca2-b4ab-bedcb0b0bbce"+                },+                {+                    "description": "Pad B4 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 111.0+                    },+                    "uuid": "4b96c5f1-ce34-484d-9ece-30002deafabc"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad B11 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 107.5+                    },+                    "uuid": "6b5a0a82-7069-4228-8a42-fb0166e4a571"+                },+                {+                    "description": "Pad B10 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 108.0+                    },+                    "uuid": "1057d372-3993-4593-8846-396afaf466dc"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad A3 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 108.25+                    },+                    "uuid": "fd52fd5b-7516-471e-b906-7e8a110756fb"+                },+                {+                    "description": "Pad A4 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 108.75+                    },+                    "uuid": "af567fd8-1b0b-4e41-b59c-73ad3828f689"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad A7 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 110.25+                    },+                    "uuid": "7a3f0c8f-c12d-41d1-8e85-a87061f9da69"+                },+                {+                    "description": "Pad A8 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 110.75+                    },+                    "uuid": "9717ecb1-3e50-4712-ae62-6c8b56bd7b12"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad B3 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 111.5+                    },+                    "uuid": "b8838ebc-0797-49d2-ac3e-072751b36d37"+                },+                {+                    "description": "Pad B2 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 112.0+                    },+                    "uuid": "d4294c37-fa42-47bc-b0ed-65786d89b5d7"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad A9 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 111.25+                    },+                    "uuid": "37569dab-d7e1-4f03-b3a9-67d1cbce4c19"+                },+                {+                    "description": "Pad A10 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 111.75+                    },+                    "uuid": "813ac32d-8495-4b2e-ac0e-32bca337a07e"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad B11 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 107.5+                    },+                    "uuid": "6b5a0a82-7069-4228-8a42-fb0166e4a571"+                },+                {+                    "description": "Pad B12 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 107.0+                    },+                    "uuid": "73e61240-4bd1-41f6-9215-b101033f09d6"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad A11 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 112.25+                    },+                    "uuid": "ab33561d-5678-468f-b47f-eb09a93eada1"+                },+                {+                    "description": "Pad A12 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 112.75+                    },+                    "uuid": "890765de-dfad-441c-a4c7-7f83371481d8"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad A5 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 109.25+                    },+                    "uuid": "e002a928-c69d-4aa5-b7bf-425b9f784cd1"+                },+                {+                    "description": "Pad A6 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 109.75+                    },+                    "uuid": "299a155b-1678-4aa7-af4f-981e384493e9"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad B7 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 109.5+                    },+                    "uuid": "71109a45-faa1-465b-8bd9-057db91dce40"+                },+                {+                    "description": "Pad B8 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 109.0+                    },+                    "uuid": "a7a51cf7-4621-471f-8950-470485c05a8e"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad B9 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 108.5+                    },+                    "uuid": "d1e8ae03-510a-436a-be79-c95c325d2e8b"+                },+                {+                    "description": "Pad B8 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 109.0+                    },+                    "uuid": "a7a51cf7-4621-471f-8950-470485c05a8e"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Front solder mask aperture bridges items with different nets",+            "items": [+                {+                    "description": "Pad B5 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 110.5+                    },+                    "uuid": "979d6779-6cc8-4ca2-b4ab-bedcb0b0bbce"+                },+                {+                    "description": "Pad B6 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 110.0+                    },+                    "uuid": "8aa526f4-ddb3-4feb-9111-04bc706e378c"+                }+            ],+            "severity": "error",+            "type": "solder_mask_bridge"+        },+        {+            "description": "Board edge clearance violation (board setup constraints edge clearance 0.5000 mm; actual 0.3210 mm)",+            "items": [+                {+                    "description": "NPTH pad of J1",+                    "pos": {+                        "x": 118.36,+                        "y": 113.6+                    },+                    "uuid": "84674de2-d663-4561-9cdc-79832e711267"+                },+                {+                    "description": "Pad A12 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 112.75+                    },+                    "uuid": "890765de-dfad-441c-a4c7-7f83371481d8"+                }+            ],+            "severity": "error",+            "type": "copper_edge_clearance"+        },+        {+            "description": "Board edge clearance violation (board setup constraints edge clearance 0.5000 mm; actual 0.3988 mm)",+            "items": [+                {+                    "description": "NPTH pad of J1",+                    "pos": {+                        "x": 118.36,+                        "y": 113.6+                    },+                    "uuid": "84674de2-d663-4561-9cdc-79832e711267"+                },+                {+                    "description": "PTH pad SH [<no net>] of J1",+                    "pos": {+                        "x": 117.11,+                        "y": 114.13+                    },+                    "uuid": "9eebc2f0-82e6-4432-9c63-c0ba17841a08"+                }+            ],+            "severity": "error",+            "type": "copper_edge_clearance"+        },+        {+            "description": "Board edge clearance violation (board setup constraints edge clearance 0.5000 mm; actual 0.2000 mm)",+            "items": [+                {+                    "description": "Segment on Edge.Cuts",+                    "pos": {+                        "x": 150.0,+                        "y": 122.0+                    },+                    "uuid": "713ae09c-328f-440d-a0ed-18dd5cbcef4c"+                },+                {+                    "description": "PTH pad 1 [<no net>] of PIN**",+                    "pos": {+                        "x": 149.0,+                        "y": 121.0+                    },+                    "uuid": "fb9c9b40-0220-46a1-907b-54477ba4f011"+                }+            ],+            "severity": "error",+            "type": "copper_edge_clearance"+        },+        {+            "description": "Board edge clearance violation (board setup constraints edge clearance 0.5000 mm; actual 0.2000 mm)",+            "items": [+                {+                    "description": "Segment on Edge.Cuts",+                    "pos": {+                        "x": 110.0,+                        "y": 98.0+                    },+                    "uuid": "013a11d1-880d-4afc-888e-5ebce8b109d9"+                },+                {+                    "description": "PTH pad 1 [<no net>] of PIN**",+                    "pos": {+                        "x": 149.0,+                        "y": 121.0+                    },+                    "uuid": "fb9c9b40-0220-46a1-907b-54477ba4f011"+                }+            ],+            "severity": "error",+            "type": "copper_edge_clearance"+        },+        {+            "description": "Board edge clearance violation (board setup constraints edge clearance 0.5000 mm; actual 0.2000 mm)",+            "items": [+                {+                    "description": "Segment on Edge.Cuts",+                    "pos": {+                        "x": 110.0,+                        "y": 98.0+                    },+                    "uuid": "013a11d1-880d-4afc-888e-5ebce8b109d9"+                },+                {+                    "description": "PTH pad 1 [<no net>] of PIN**",+                    "pos": {+                        "x": 149.0,+                        "y": 121.0+                    },+                    "uuid": "fb9c9b40-0220-46a1-907b-54477ba4f011"+                }+            ],+            "severity": "error",+            "type": "copper_edge_clearance"+        },+        {+            "description": "Board edge clearance violation (board setup constraints edge clearance 0.5000 mm; actual 0.2000 mm)",+            "items": [+                {+                    "description": "Segment on Edge.Cuts",+                    "pos": {+                        "x": 150.0,+                        "y": 98.0+                    },+                    "uuid": "d1902210-244a-40af-b3b2-8152134635e6"+                },+                {+                    "description": "PTH pad 1 [<no net>] of PIN**",+                    "pos": {+                        "x": 149.0,+                        "y": 121.0+                    },+                    "uuid": "fb9c9b40-0220-46a1-907b-54477ba4f011"+                }+            ],+            "severity": "error",+            "type": "copper_edge_clearance"+        },+        {+            "description": "Board edge clearance violation (board setup constraints edge clearance 0.5000 mm; actual 0.3500 mm)",+            "items": [+                {+                    "description": "Segment on Edge.Cuts",+                    "pos": {+                        "x": 110.0,+                        "y": 98.0+                    },+                    "uuid": "013a11d1-880d-4afc-888e-5ebce8b109d9"+                },+                {+                    "description": "PTH pad 1 [<no net>] of PIN**",+                    "pos": {+                        "x": 119.0,+                        "y": 121.0+                    },+                    "uuid": "739bcfb0-181d-4bd4-9584-ab2d39f99f1b"+                }+            ],+            "severity": "error",+            "type": "copper_edge_clearance"+        },+        {+            "description": "Board edge clearance violation (board setup constraints edge clearance 0.5000 mm; actual 0.3500 mm)",+            "items": [+                {+                    "description": "Segment on Edge.Cuts",+                    "pos": {+                        "x": 110.0,+                        "y": 98.0+                    },+                    "uuid": "013a11d1-880d-4afc-888e-5ebce8b109d9"+                },+                {+                    "description": "PTH pad 1 [<no net>] of PIN**",+                    "pos": {+                        "x": 119.0,+                        "y": 121.0+                    },+                    "uuid": "739bcfb0-181d-4bd4-9584-ab2d39f99f1b"+                }+            ],+            "severity": "error",+            "type": "copper_edge_clearance"+        },+        {+            "description": "Board edge clearance violation (board setup constraints edge clearance 0.5000 mm; actual 0.3500 mm)",+            "items": [+                {+                    "description": "Segment on Edge.Cuts",+                    "pos": {+                        "x": 110.0,+                        "y": 98.0+                    },+                    "uuid": "013a11d1-880d-4afc-888e-5ebce8b109d9"+                },+                {+                    "description": "PTH pad 1 [<no net>] of PIN**",+                    "pos": {+                        "x": 119.0,+                        "y": 121.0+                    },+                    "uuid": "739bcfb0-181d-4bd4-9584-ab2d39f99f1b"+                }+            ],+            "severity": "error",+            "type": "copper_edge_clearance"+        },+        {+            "description": "Board edge clearance violation (board setup constraints edge clearance 0.5000 mm; actual 0.3500 mm)",+            "items": [+                {+                    "description": "Segment on Edge.Cuts",+                    "pos": {+                        "x": 110.0,+                        "y": 98.0+                    },+                    "uuid": "013a11d1-880d-4afc-888e-5ebce8b109d9"+                },+                {+                    "description": "PTH pad 1 [<no net>] of PIN**",+                    "pos": {+                        "x": 119.0,+                        "y": 121.0+                    },+                    "uuid": "739bcfb0-181d-4bd4-9584-ab2d39f99f1b"+                }+            ],+            "severity": "error",+            "type": "copper_edge_clearance"+        },+        {+            "description": "Board edge clearance violation (board setup constraints edge clearance 0.5000 mm; actual 0.3500 mm)",+            "items": [+                {+                    "description": "Segment on Edge.Cuts",+                    "pos": {+                        "x": 150.0,+                        "y": 122.0+                    },+                    "uuid": "713ae09c-328f-440d-a0ed-18dd5cbcef4c"+                },+                {+                    "description": "PTH pad 1 [<no net>] of PIN**",+                    "pos": {+                        "x": 119.0,+                        "y": 121.0+                    },+                    "uuid": "739bcfb0-181d-4bd4-9584-ab2d39f99f1b"+                }+            ],+            "severity": "error",+            "type": "copper_edge_clearance"+        },+        {+            "description": "Board edge clearance violation (board setup constraints edge clearance 0.5000 mm; actual 0.3500 mm)",+            "items": [+                {+                    "description": "Segment on Edge.Cuts",+                    "pos": {+                        "x": 150.0,+                        "y": 122.0+                    },+                    "uuid": "713ae09c-328f-440d-a0ed-18dd5cbcef4c"+                },+                {+                    "description": "PTH pad 1 [<no net>] of PIN**",+                    "pos": {+                        "x": 119.0,+                        "y": 121.0+                    },+                    "uuid": "739bcfb0-181d-4bd4-9584-ab2d39f99f1b"+                }+            ],+            "severity": "error",+            "type": "copper_edge_clearance"+        },+        {+            "description": "Board edge clearance violation (board setup constraints edge clearance 0.5000 mm; actual 0.3500 mm)",+            "items": [+                {+                    "description": "Segment on Edge.Cuts",+                    "pos": {+                        "x": 150.0,+                        "y": 122.0+                    },+                    "uuid": "713ae09c-328f-440d-a0ed-18dd5cbcef4c"+                },+                {+                    "description": "PTH pad 1 [<no net>] of PIN**",+                    "pos": {+                        "x": 119.0,+                        "y": 121.0+                    },+                    "uuid": "739bcfb0-181d-4bd4-9584-ab2d39f99f1b"+                }+            ],+            "severity": "error",+            "type": "copper_edge_clearance"+        },+        {+            "description": "Board edge clearance violation (board setup constraints edge clearance 0.5000 mm; actual 0.3500 mm)",+            "items": [+                {+                    "description": "Segment on Edge.Cuts",+                    "pos": {+                        "x": 150.0,+                        "y": 122.0+                    },+                    "uuid": "713ae09c-328f-440d-a0ed-18dd5cbcef4c"+                },+                {+                    "description": "PTH pad 1 [<no net>] of PIN**",+                    "pos": {+                        "x": 119.0,+                        "y": 121.0+                    },+                    "uuid": "739bcfb0-181d-4bd4-9584-ab2d39f99f1b"+                }+            ],+            "severity": "error",+            "type": "copper_edge_clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad A1 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 107.25+                    },+                    "uuid": "19db812f-109a-43c0-8c36-c62bf5339a2c"+                },+                {+                    "description": "Pad A2 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 107.75+                    },+                    "uuid": "b2b488a1-77f6-4a87-87d2-c50e9fa3f33a"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad A2 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 107.75+                    },+                    "uuid": "b2b488a1-77f6-4a87-87d2-c50e9fa3f33a"+                },+                {+                    "description": "Pad A3 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 108.25+                    },+                    "uuid": "fd52fd5b-7516-471e-b906-7e8a110756fb"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad A3 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 108.25+                    },+                    "uuid": "fd52fd5b-7516-471e-b906-7e8a110756fb"+                },+                {+                    "description": "Pad A4 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 108.75+                    },+                    "uuid": "af567fd8-1b0b-4e41-b59c-73ad3828f689"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad A4 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 108.75+                    },+                    "uuid": "af567fd8-1b0b-4e41-b59c-73ad3828f689"+                },+                {+                    "description": "Pad A5 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 109.25+                    },+                    "uuid": "e002a928-c69d-4aa5-b7bf-425b9f784cd1"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad A5 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 109.25+                    },+                    "uuid": "e002a928-c69d-4aa5-b7bf-425b9f784cd1"+                },+                {+                    "description": "Pad A6 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 109.75+                    },+                    "uuid": "299a155b-1678-4aa7-af4f-981e384493e9"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad A6 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 109.75+                    },+                    "uuid": "299a155b-1678-4aa7-af4f-981e384493e9"+                },+                {+                    "description": "Pad A7 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 110.25+                    },+                    "uuid": "7a3f0c8f-c12d-41d1-8e85-a87061f9da69"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad A7 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 110.25+                    },+                    "uuid": "7a3f0c8f-c12d-41d1-8e85-a87061f9da69"+                },+                {+                    "description": "Pad A8 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 110.75+                    },+                    "uuid": "9717ecb1-3e50-4712-ae62-6c8b56bd7b12"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad A8 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 110.75+                    },+                    "uuid": "9717ecb1-3e50-4712-ae62-6c8b56bd7b12"+                },+                {+                    "description": "Pad A9 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 111.25+                    },+                    "uuid": "37569dab-d7e1-4f03-b3a9-67d1cbce4c19"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad A9 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 111.25+                    },+                    "uuid": "37569dab-d7e1-4f03-b3a9-67d1cbce4c19"+                },+                {+                    "description": "Pad A10 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 111.75+                    },+                    "uuid": "813ac32d-8495-4b2e-ac0e-32bca337a07e"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad A10 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 111.75+                    },+                    "uuid": "813ac32d-8495-4b2e-ac0e-32bca337a07e"+                },+                {+                    "description": "Pad A11 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 112.25+                    },+                    "uuid": "ab33561d-5678-468f-b47f-eb09a93eada1"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad A11 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 112.25+                    },+                    "uuid": "ab33561d-5678-468f-b47f-eb09a93eada1"+                },+                {+                    "description": "Pad A12 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 119.02,+                        "y": 112.75+                    },+                    "uuid": "890765de-dfad-441c-a4c7-7f83371481d8"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad B1 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 112.5+                    },+                    "uuid": "c92d8a4c-d0d1-4328-ab06-ef97ae4763ff"+                },+                {+                    "description": "Pad B2 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 112.0+                    },+                    "uuid": "d4294c37-fa42-47bc-b0ed-65786d89b5d7"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad B2 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 112.0+                    },+                    "uuid": "d4294c37-fa42-47bc-b0ed-65786d89b5d7"+                },+                {+                    "description": "Pad B3 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 111.5+                    },+                    "uuid": "b8838ebc-0797-49d2-ac3e-072751b36d37"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad B3 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 111.5+                    },+                    "uuid": "b8838ebc-0797-49d2-ac3e-072751b36d37"+                },+                {+                    "description": "Pad B4 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 111.0+                    },+                    "uuid": "4b96c5f1-ce34-484d-9ece-30002deafabc"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad B4 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 111.0+                    },+                    "uuid": "4b96c5f1-ce34-484d-9ece-30002deafabc"+                },+                {+                    "description": "Pad B5 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 110.5+                    },+                    "uuid": "979d6779-6cc8-4ca2-b4ab-bedcb0b0bbce"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad B5 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 110.5+                    },+                    "uuid": "979d6779-6cc8-4ca2-b4ab-bedcb0b0bbce"+                },+                {+                    "description": "Pad B6 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 110.0+                    },+                    "uuid": "8aa526f4-ddb3-4feb-9111-04bc706e378c"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad B6 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 110.0+                    },+                    "uuid": "8aa526f4-ddb3-4feb-9111-04bc706e378c"+                },+                {+                    "description": "Pad B7 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 109.5+                    },+                    "uuid": "71109a45-faa1-465b-8bd9-057db91dce40"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad B7 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 109.5+                    },+                    "uuid": "71109a45-faa1-465b-8bd9-057db91dce40"+                },+                {+                    "description": "Pad B8 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 109.0+                    },+                    "uuid": "a7a51cf7-4621-471f-8950-470485c05a8e"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad B8 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 109.0+                    },+                    "uuid": "a7a51cf7-4621-471f-8950-470485c05a8e"+                },+                {+                    "description": "Pad B9 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 108.5+                    },+                    "uuid": "d1e8ae03-510a-436a-be79-c95c325d2e8b"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad B9 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 108.5+                    },+                    "uuid": "d1e8ae03-510a-436a-be79-c95c325d2e8b"+                },+                {+                    "description": "Pad B10 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 108.0+                    },+                    "uuid": "1057d372-3993-4593-8846-396afaf466dc"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad B10 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 108.0+                    },+                    "uuid": "1057d372-3993-4593-8846-396afaf466dc"+                },+                {+                    "description": "Pad B11 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 107.5+                    },+                    "uuid": "6b5a0a82-7069-4228-8a42-fb0166e4a571"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0000 mm)",+            "items": [+                {+                    "description": "Pad B11 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 107.5+                    },+                    "uuid": "6b5a0a82-7069-4228-8a42-fb0166e4a571"+                },+                {+                    "description": "Pad B12 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 107.0+                    },+                    "uuid": "73e61240-4bd1-41f6-9215-b101033f09d6"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Clearance violation ( clearance 0.2000 mm; actual 0.0962 mm)",+            "items": [+                {+                    "description": "Pad B12 [<no net>] of J1 on F.Cu",+                    "pos": {+                        "x": 117.32,+                        "y": 107.0+                    },+                    "uuid": "73e61240-4bd1-41f6-9215-b101033f09d6"+                },+                {+                    "description": "PTH pad SH [<no net>] of J1",+                    "pos": {+                        "x": 117.11,+                        "y": 105.87+                    },+                    "uuid": "9ce74ffb-2983-43cf-84a5-864478dcdd2e"+                }+            ],+            "severity": "error",+            "type": "clearance"+        },+        {+            "description": "Silkscreen clipped by solder mask",+            "items": [+                {+                    "description": "Segment of J1 on F.Silkscreen",+                    "pos": {+                        "x": 119.37,+                        "y": 105.25+                    },+                    "uuid": "5a262e3b-7215-4fe3-8e39-dfa51edb7755"+                },+                {+                    "description": "PTH pad SH [<no net>] of J1",+                    "pos": {+                        "x": 117.11,+                        "y": 105.87+                    },+                    "uuid": "9ce74ffb-2983-43cf-84a5-864478dcdd2e"+                }+            ],+            "severity": "warning",+            "type": "silk_over_copper"+        },+        {+            "description": "Silkscreen clipped by solder mask",+            "items": [+                {+                    "description": "Segment of J1 on F.Silkscreen",+                    "pos": {+                        "x": 119.37,+                        "y": 114.75+                    },+                    "uuid": "e3666fba-01d0-443b-8df7-3ca4c4a10d8f"+                },+                {+                    "description": "PTH pad SH [<no net>] of J1",+                    "pos": {+                        "x": 117.11,+                        "y": 114.13+                    },+                    "uuid": "9eebc2f0-82e6-4432-9c63-c0ba17841a08"+                }+            ],+            "severity": "warning",+            "type": "silk_over_copper"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint U1",+                    "pos": {+                        "x": 130.0,+                        "y": 110.0+                    },+                    "uuid": "5cc49c41-4f1c-4cdd-87d1-16e23edccd50"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint C1",+                    "pos": {+                        "x": 126.5,+                        "y": 102.5+                    },+                    "uuid": "a1d36c1e-4d5d-44b0-9900-85e206b4e97e"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint C2",+                    "pos": {+                        "x": 133.5,+                        "y": 102.5+                    },+                    "uuid": "6e5c8a90-ab7a-4be3-98cb-7131e5e1c78f"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint C3",+                    "pos": {+                        "x": 130.0,+                        "y": 117.0+                    },+                    "uuid": "b445ff77-b1a5-4a51-8516-ae2f8bc2427d"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint Y1",+                    "pos": {+                        "x": 140.0,+                        "y": 110.0+                    },+                    "uuid": "5c06285f-c11f-4018-a957-2bef6b371c92"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint C4",+                    "pos": {+                        "x": 140.0,+                        "y": 106.0+                    },+                    "uuid": "b95dffdd-b71b-4ce5-9047-50c347555ebb"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint C5",+                    "pos": {+                        "x": 140.0,+                        "y": 114.0+                    },+                    "uuid": "e733cd70-7134-4f2c-a0ff-ae249b67193b"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint J1",+                    "pos": {+                        "x": 114.0,+                        "y": 110.0+                    },+                    "uuid": "4525eadc-f7d0-4ca1-b5fb-aa16e1215c00"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint PIN**",+                    "pos": {+                        "x": 111.0,+                        "y": 121.0+                    },+                    "uuid": "d68df503-3184-45cd-8648-c6707b3b0a3d"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint PIN**",+                    "pos": {+                        "x": 111.0,+                        "y": 99.0+                    },+                    "uuid": "19f9bd95-c9f1-48ec-b648-4bb25a3332a7"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint PIN**",+                    "pos": {+                        "x": 149.0,+                        "y": 99.0+                    },+                    "uuid": "13e54b3d-c017-4f8e-bf30-c26490505b7f"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint PIN**",+                    "pos": {+                        "x": 149.0,+                        "y": 121.0+                    },+                    "uuid": "db42aaa8-2496-4e89-a19c-6163244736f4"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint PIN**",+                    "pos": {+                        "x": 119.0,+                        "y": 99.0+                    },+                    "uuid": "b7fa2d80-d13c-4ff6-b547-065b21f1a0ad"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint PIN**",+                    "pos": {+                        "x": 127.0,+                        "y": 99.0+                    },+                    "uuid": "c1cd46ad-3359-47c6-a648-c93593579550"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint PIN**",+                    "pos": {+                        "x": 135.0,+                        "y": 99.0+                    },+                    "uuid": "7ed8f1b9-8987-4a3d-bc05-1ceb9028fdc7"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint PIN**",+                    "pos": {+                        "x": 143.0,+                        "y": 99.0+                    },+                    "uuid": "4c7ca5da-8728-4a37-a31b-5526d7f07b66"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint PIN**",+                    "pos": {+                        "x": 143.0,+                        "y": 121.0+                    },+                    "uuid": "d2e9658e-2641-4123-b145-91e087c4170c"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint PIN**",+                    "pos": {+                        "x": 135.0,+                        "y": 121.0+                    },+                    "uuid": "758a364b-d69e-484f-a62f-61a1537c9ec4"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint PIN**",+                    "pos": {+                        "x": 127.0,+                        "y": 121.0+                    },+                    "uuid": "170b3a43-1704-431d-87d2-68632057c715"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        },+        {+            "description": "The current configuration does not include the footprint library 'AdomRP2040'",+            "items": [+                {+                    "description": "Footprint PIN**",+                    "pos": {+                        "x": 119.0,+                        "y": 121.0+                    },+                    "uuid": "4ea35334-a288-4725-bd1a-6921fb847afb"+                }+            ],+            "severity": "warning",+            "type": "lib_footprint_issues"+        }+    ]+}
rust/crates/kicad-core/tests/fixtures/rp2040-erc.jsonadded+2377
@@ -0,0 +1,2377 @@+{+    "$schema": "https://schemas.kicad.org/erc.v1.json",+    "coordinate_units": "mm",+    "date": "2026-09-11T12:03:15",+    "ignored_checks": [+        {+            "description": "Global label only appears once in the schematic",+            "key": "single_global_label"+        },+        {+            "description": "Four connection points are joined together",+            "key": "four_way_junction"+        },+        {+            "description": "SPICE model issue",+            "key": "simulation_model_issue"+        },+        {+            "description": "Assigned footprint doesn't match footprint filters",+            "key": "footprint_filter"+        }+    ],+    "included_severities": [+        "error",+        "warning"+    ],+    "kicad_version": "10.0.2",+    "sheets": [+        {+            "path": "/",+            "uuid_path": "/1b967493-be8a-43e0-afe4-bbe7aa15b633",+            "violations": [+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol MC5 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.05,+                                "y": 1.95+                            },+                            "uuid": "286bd555-81b5-47a2-85f9-3d275798504b"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol MC6 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.15,+                                "y": 1.95+                            },+                            "uuid": "cd679cd1-f357-45e7-8542-4da1196243a6"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol MC7 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.25,+                                "y": 1.95+                            },+                            "uuid": "28e14bf7-c45c-44a0-a184-d88420db8f08"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol MC8 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.35,+                                "y": 1.95+                            },+                            "uuid": "cc4e247f-4a69-4722-b026-657094a2eb28"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol MC1 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.45,+                                "y": 1.7+                            },+                            "uuid": "8f8918c0-765e-4161-a1e4-5f90487ec2f3"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol MC2 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.55,+                                "y": 1.7+                            },+                            "uuid": "4a570150-316a-4507-89d0-c274d4a0bd76"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol MC3 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.65,+                                "y": 1.7+                            },+                            "uuid": "8b449cf6-4382-4c44-86e1-7117706c6e26"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol MC4 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.75,+                                "y": 1.7+                            },+                            "uuid": "3684dc9b-31f5-4be1-b8d9-ab726cb6bf47"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol J1 Pin SH [SHIELD, Passive, Line]",+                            "pos": {+                                "x": 0.8238,+                                "y": 1.2286+                            },+                            "uuid": "b760ec5f-9f32-493e-9ad2-343509ccef47"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol J1 Pin A1 [GND, Passive, Line]",+                            "pos": {+                                "x": 0.9,+                                "y": 1.2286+                            },+                            "uuid": "7f9448af-f7f5-4101-be14-7016f66e83d3"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol J1 Hidden pin A9 [VBUS, Passive, Line]",+                            "pos": {+                                "x": 1.0524,+                                "y": 0.8476+                            },+                            "uuid": "8db15e5f-5f0f-4284-9a9e-00648973066e"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol J1 Pin A5 [CC1, Bidirectional, Line]",+                            "pos": {+                                "x": 1.0524,+                                "y": 0.8984+                            },+                            "uuid": "f9e4f732-2717-4f2e-a58a-3742a2fdcf56"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol J1 Pin B5 [CC2, Bidirectional, Line]",+                            "pos": {+                                "x": 1.0524,+                                "y": 0.9238+                            },+                            "uuid": "f8d956bf-8c9c-43c5-bb7b-5a89632c9312"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol J1 Pin A7 [D-, Bidirectional, Line]",+                            "pos": {+                                "x": 1.0524,+                                "y": 0.9746+                            },+                            "uuid": "224978f4-1655-4204-95dd-6b0f092a175e"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol J1 Pin B7 [D-, Bidirectional, Line]",+                            "pos": {+                                "x": 1.0524,+                                "y": 1.0+                            },+                            "uuid": "f0e1fe0a-2446-4d2b-a287-c584cac0e76b"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol J1 Pin A6 [D+, Bidirectional, Line]",+                            "pos": {+                                "x": 1.0524,+                                "y": 1.0254+                            },+                            "uuid": "bee70b78-2b09-4baa-b686-43645a5b23c2"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol J1 Pin B6 [D+, Bidirectional, Line]",+                            "pos": {+                                "x": 1.0524,+                                "y": 1.0508+                            },+                            "uuid": "0a9af1c5-e4e4-497a-911e-ef9e3899d2a6"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol J1 Pin A8 [SBU1, Bidirectional, Line]",+                            "pos": {+                                "x": 1.0524,+                                "y": 1.127+                            },+                            "uuid": "b0320462-04bf-4d0f-b63c-074cbf5a7932"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol J1 Pin B8 [SBU2, Bidirectional, Line]",+                            "pos": {+                                "x": 1.0524,+                                "y": 1.1524+                            },+                            "uuid": "ffab05d3-bd41-4b73-aaab-c3d81954a341"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol C1 Pin 1 [Passive, Line]",+                            "pos": {+                                "x": 1.15,+                                "y": 0.6119+                            },+                            "uuid": "69a2a52b-37c3-4190-8980-c251aca680cb"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol C1 Pin 2 [Passive, Line]",+                            "pos": {+                                "x": 1.15,+                                "y": 0.6881+                            },+                            "uuid": "54fae1c6-db7e-464f-b1ac-9aa9cab276b1"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 19 [TESTEN, Input, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 0.6952+                            },+                            "uuid": "3e45d211-0b4c-4833-9a57-8c48470e6a70"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Input pin not driven by any Output pins",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 19 [TESTEN, Input, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 0.6952+                            },+                            "uuid": "3e45d211-0b4c-4833-9a57-8c48470e6a70"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_driven"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 26 [RUN, Input, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 0.7714+                            },+                            "uuid": "3a2a801c-4e73-4c03-a19c-35b9e96145a8"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Input pin not driven by any Output pins",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 26 [RUN, Input, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 0.7714+                            },+                            "uuid": "3a2a801c-4e73-4c03-a19c-35b9e96145a8"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_driven"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 46 [USB_DM, Bidirectional, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 0.8476+                            },+                            "uuid": "a50cc509-de2a-457b-82ce-623c9404a5a8"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 47 [USB_DP, Bidirectional, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 0.873+                            },+                            "uuid": "32932672-d230-4003-85cf-a69e540fec23"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 56 [~{QSPI_SS}, Output, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 0.9492+                            },+                            "uuid": "af62e7fc-d304-414b-a0f8-2d1eaf8d8307"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 52 [QSPI_SCLK, Output, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 0.9746+                            },+                            "uuid": "1aa165c0-e827-4135-b202-3e319ce0edca"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 53 [QSPI_SD0, Bidirectional, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 1.0+                            },+                            "uuid": "9b17d276-9eed-4072-96fd-d74ef2799b92"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 55 [QSPI_SD1, Bidirectional, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 1.0254+                            },+                            "uuid": "858efa74-66a1-45e0-8c24-3b79dc389058"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 54 [QSPI_SD2, Bidirectional, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 1.0508+                            },+                            "uuid": "3e6f799e-2838-433a-911c-da8960a5cb27"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 51 [QSPI_SD3, Bidirectional, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 1.0762+                            },+                            "uuid": "47c52bfc-cd24-496b-bd33-d7d379fbb593"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 20 [XIN, Input, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 1.1524+                            },+                            "uuid": "9bf9e373-5c37-4710-8c9f-57ea31c859ca"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Input pin not driven by any Output pins",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 20 [XIN, Input, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 1.1524+                            },+                            "uuid": "9bf9e373-5c37-4710-8c9f-57ea31c859ca"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_driven"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 21 [XOUT, Output, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 1.254+                            },+                            "uuid": "cc276e04-7324-47e1-b6e1-00701cc8ba30"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 24 [SWCLK, Input, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 1.3302+                            },+                            "uuid": "7b227a74-8889-4e03-9d66-f8f884c66c6e"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Input pin not driven by any Output pins",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 24 [SWCLK, Input, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 1.3302+                            },+                            "uuid": "7b227a74-8889-4e03-9d66-f8f884c66c6e"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_driven"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 25 [SWDIO, Bidirectional, Line]",+                            "pos": {+                                "x": 1.246,+                                "y": 1.3556+                            },+                            "uuid": "1083fc90-e941-4032-914f-c75b37cd1966"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol C2 Pin 1 [Passive, Line]",+                            "pos": {+                                "x": 1.3,+                                "y": 0.6119+                            },+                            "uuid": "c6bbc6a3-a48c-47a4-904d-3c97f6d06268"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol C2 Pin 2 [Passive, Line]",+                            "pos": {+                                "x": 1.3,+                                "y": 0.6881+                            },+                            "uuid": "fb29b16f-ad87-4750-859b-530919c0ee48"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 48 [USB_VDD, Power input, Line]",+                            "pos": {+                                "x": 1.373,+                                "y": 0.5428+                            },+                            "uuid": "afa3dd5a-416d-4a70-b7c4-0c00182f2554"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Input Power pin not driven by any Output Power pins",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 48 [USB_VDD, Power input, Line]",+                            "pos": {+                                "x": 1.373,+                                "y": 0.5428+                            },+                            "uuid": "afa3dd5a-416d-4a70-b7c4-0c00182f2554"+                        }+                    ],+                    "severity": "error",+                    "type": "power_pin_not_driven"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 43 [ADC_AVDD, Power input, Line]",+                            "pos": {+                                "x": 1.3984,+                                "y": 0.5428+                            },+                            "uuid": "78d24cdc-afcb-4ecc-8021-2b6f3a2d1ffa"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Input Power pin not driven by any Output Power pins",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 43 [ADC_AVDD, Power input, Line]",+                            "pos": {+                                "x": 1.3984,+                                "y": 0.5428+                            },+                            "uuid": "78d24cdc-afcb-4ecc-8021-2b6f3a2d1ffa"+                        }+                    ],+                    "severity": "error",+                    "type": "power_pin_not_driven"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol C3 Pin 1 [Passive, Line]",+                            "pos": {+                                "x": 1.45,+                                "y": 0.6119+                            },+                            "uuid": "1a6424e2-a05e-485d-890f-913e70acc2fb"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol C3 Pin 2 [Passive, Line]",+                            "pos": {+                                "x": 1.45,+                                "y": 0.6881+                            },+                            "uuid": "c786bb20-257e-4f98-a6b3-3bc93f6712c8"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 1 [IOVDD, Power input, Line]",+                            "pos": {+                                "x": 1.4746,+                                "y": 0.5428+                            },+                            "uuid": "ec22a3c8-a3a1-4bca-93b9-143f9c982fe3"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Input Power pin not driven by any Output Power pins",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 1 [IOVDD, Power input, Line]",+                            "pos": {+                                "x": 1.4746,+                                "y": 0.5428+                            },+                            "uuid": "ec22a3c8-a3a1-4bca-93b9-143f9c982fe3"+                        }+                    ],+                    "severity": "error",+                    "type": "power_pin_not_driven"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 57 [GND, Power input, Line]",+                            "pos": {+                                "x": 1.5,+                                "y": 1.4572+                            },+                            "uuid": "e20cfefa-a45f-44b5-ac14-6c5c4d661f65"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Input Power pin not driven by any Output Power pins",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 57 [GND, Power input, Line]",+                            "pos": {+                                "x": 1.5,+                                "y": 1.4572+                            },+                            "uuid": "e20cfefa-a45f-44b5-ac14-6c5c4d661f65"+                        }+                    ],+                    "severity": "error",+                    "type": "power_pin_not_driven"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 44 [VREG_VIN, Power input, Line]",+                            "pos": {+                                "x": 1.5254,+                                "y": 0.5428+                            },+                            "uuid": "98c2acb7-52dc-42eb-b4fe-2fd3e544b3c7"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Input Power pin not driven by any Output Power pins",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 44 [VREG_VIN, Power input, Line]",+                            "pos": {+                                "x": 1.5254,+                                "y": 0.5428+                            },+                            "uuid": "98c2acb7-52dc-42eb-b4fe-2fd3e544b3c7"+                        }+                    ],+                    "severity": "error",+                    "type": "power_pin_not_driven"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 45 [VREG_VOUT, Power output, Line]",+                            "pos": {+                                "x": 1.5762,+                                "y": 0.5428+                            },+                            "uuid": "1340e41b-fd4f-4605-8c11-ed1518942dd4"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 23 [DVDD, Power input, Line]",+                            "pos": {+                                "x": 1.627,+                                "y": 0.5428+                            },+                            "uuid": "291052f5-bbfc-4a53-af10-6b2f02cf08fe"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Input Power pin not driven by any Output Power pins",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 23 [DVDD, Power input, Line]",+                            "pos": {+                                "x": 1.627,+                                "y": 0.5428+                            },+                            "uuid": "291052f5-bbfc-4a53-af10-6b2f02cf08fe"+                        }+                    ],+                    "severity": "error",+                    "type": "power_pin_not_driven"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 2 [GPIO0, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 0.619+                            },+                            "uuid": "9dc5c3ae-223f-43a1-9a5d-ff4c513465d4"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 3 [GPIO1, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 0.6444+                            },+                            "uuid": "316d7b30-c466-45fa-843e-3b81dca501fa"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 4 [GPIO2, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 0.6698+                            },+                            "uuid": "3d4be94f-c408-40e9-99aa-648c99bef24a"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 5 [GPIO3, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 0.6952+                            },+                            "uuid": "4f73395b-efff-424d-ad4d-0c007e8735d4"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 6 [GPIO4, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 0.7206+                            },+                            "uuid": "89c18b28-478b-41d3-90ea-7baa347047cd"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 7 [GPIO5, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 0.746+                            },+                            "uuid": "564a4d77-e541-49c1-9fe0-c4819e9f2298"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 8 [GPIO6, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 0.7714+                            },+                            "uuid": "d20b6a30-f0fd-44d6-afaa-3b067516c62f"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 9 [GPIO7, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 0.7968+                            },+                            "uuid": "0ed5817e-2cd3-4a58-9b02-387a3f799511"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 11 [GPIO8, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 0.8222+                            },+                            "uuid": "99645854-78c4-4593-ae84-053be295b4e3"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 12 [GPIO9, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 0.8476+                            },+                            "uuid": "80aa7319-8a74-443e-a68c-a4f7ec1a5e95"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 13 [GPIO10, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 0.873+                            },+                            "uuid": "21271499-35fd-4357-bdc3-f6edbcb05d4a"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 14 [GPIO11, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 0.8984+                            },+                            "uuid": "7049a657-1f1f-4866-a414-bc3d2d9b9312"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 15 [GPIO12, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 0.9238+                            },+                            "uuid": "8d26c49e-9c31-4c17-ac10-327ea39afeea"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 16 [GPIO13, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 0.9492+                            },+                            "uuid": "b1653510-0d92-4b88-bf32-3357e0a7816b"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 17 [GPIO14, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 0.9746+                            },+                            "uuid": "94e06e3e-7fb1-4ec5-b9f7-5ee93d061ad7"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 18 [GPIO15, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 1.0+                            },+                            "uuid": "08ff36c0-5424-4858-baf8-67f70ba6ca65"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 27 [GPIO16, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 1.0254+                            },+                            "uuid": "44a461c1-f16d-49f0-a237-54b0eb80322d"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 28 [GPIO17, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 1.0508+                            },+                            "uuid": "d09436e9-a3c5-4373-92c5-820e42039807"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 29 [GPIO18, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 1.0762+                            },+                            "uuid": "43251e60-5954-47df-ba7d-4fcd9e7e242e"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 30 [GPIO19, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 1.1016+                            },+                            "uuid": "0161ce2c-7f0b-41ae-ac53-724800e3fcb4"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 31 [GPIO20, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 1.127+                            },+                            "uuid": "f925b9e1-c3b9-4ef2-bd34-0dd4a4d221ec"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 32 [GPIO21, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 1.1524+                            },+                            "uuid": "fef6c333-923a-4f52-a1a0-07cebf04dd5e"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 34 [GPIO22, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 1.1778+                            },+                            "uuid": "97294d1f-4700-4ecb-acf0-296e855e049a"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 35 [GPIO23, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 1.2032+                            },+                            "uuid": "bf956d4b-9775-4d68-beef-5edc48266201"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 36 [GPIO24, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 1.2286+                            },+                            "uuid": "e2671601-9964-4cb6-b0c0-9db13563b6f2"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 37 [GPIO25, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 1.254+                            },+                            "uuid": "c742204e-f930-4487-bf6a-b50409084c50"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 38 [GPIO26/ADC0, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 1.3048+                            },+                            "uuid": "d7ce8b8b-8dc0-4794-a4ac-cfaca41f1723"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 39 [GPIO27/ADC1, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 1.3302+                            },+                            "uuid": "8080a682-94f0-43b1-9d23-435f28745aaa"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 40 [GPIO28/ADC2, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 1.3556+                            },+                            "uuid": "e7172aea-ec2b-4935-a4b9-4d81d21c7049"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 41 [GPIO29/ADC3, Bidirectional, Line]",+                            "pos": {+                                "x": 1.754,+                                "y": 1.381+                            },+                            "uuid": "8a3f4dfe-8683-4319-ba74-d9302bc36ae5"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol Y1 Pin 1 [1, Passive, Line]",+                            "pos": {+                                "x": 1.7619,+                                "y": 1.0+                            },+                            "uuid": "9d06fced-ec71-43b8-a259-38f660fba6e4"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol C4 Pin 1 [Passive, Line]",+                            "pos": {+                                "x": 1.8,+                                "y": 1.2619+                            },+                            "uuid": "b065636a-8c43-46cc-9185-e40957d74311"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol C4 Pin 2 [Passive, Line]",+                            "pos": {+                                "x": 1.8,+                                "y": 1.3381+                            },+                            "uuid": "3a016316-ea4e-4474-b718-8378c9595689"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol C5 Pin 1 [Passive, Line]",+                            "pos": {+                                "x": 1.8,+                                "y": 1.4119+                            },+                            "uuid": "7a5cf694-6609-4c5f-8373-9dcb62cfdb3b"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol C5 Pin 2 [Passive, Line]",+                            "pos": {+                                "x": 1.8,+                                "y": 1.4881+                            },+                            "uuid": "cded4b9d-b6f4-4cd8-8ed1-b6883d41f254"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol Y1 Pin 2 [2, Passive, Line]",+                            "pos": {+                                "x": 1.8381,+                                "y": 1.0+                            },+                            "uuid": "9f557536-eca7-4cc7-b58a-0aee7fbf284c"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol MP1 Pin 1 [MECH_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 2.2,+                                "y": 0.6+                            },+                            "uuid": "9344a340-1825-4e78-8b4a-ac1d07d5824e"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol MP3 Pin 1 [MECH_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 2.2,+                                "y": 0.74+                            },+                            "uuid": "121315f9-74bb-4587-92f3-77b156a1497e"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol MP2 Pin 1 [MECH_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 2.32,+                                "y": 0.6+                            },+                            "uuid": "708381c6-5eb0-483d-8b85-7ffd658bce6c"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Pin not connected",+                    "items": [+                        {+                            "description": "Symbol MP4 Pin 1 [MECH_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 2.32,+                                "y": 0.74+                            },+                            "uuid": "ce4c3a3b-1c28-4abe-821f-2e086c2d6d39"+                        }+                    ],+                    "severity": "error",+                    "type": "pin_not_connected"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol MC5 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.05,+                                "y": 1.95+                            },+                            "uuid": "286bd555-81b5-47a2-85f9-3d275798504b"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC5 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.05,+                                "y": 1.95+                            },+                            "uuid": "45abc2e9-ed17-4211-9d50-6d9503ce39b1"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC5 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.05,+                                "y": 1.95+                            },+                            "uuid": "45abc2e9-ed17-4211-9d50-6d9503ce39b1"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol MC6 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.15,+                                "y": 1.95+                            },+                            "uuid": "cd679cd1-f357-45e7-8542-4da1196243a6"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC6 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.15,+                                "y": 1.95+                            },+                            "uuid": "f84fab54-efed-4c9c-9421-15ae62d1cdfe"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC6 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.15,+                                "y": 1.95+                            },+                            "uuid": "f84fab54-efed-4c9c-9421-15ae62d1cdfe"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol MC7 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.25,+                                "y": 1.95+                            },+                            "uuid": "28e14bf7-c45c-44a0-a184-d88420db8f08"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC7 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.25,+                                "y": 1.95+                            },+                            "uuid": "279427d0-fe9b-4a04-b613-fb7a0495c9df"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC7 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.25,+                                "y": 1.95+                            },+                            "uuid": "279427d0-fe9b-4a04-b613-fb7a0495c9df"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol MC8 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.35,+                                "y": 1.95+                            },+                            "uuid": "cc4e247f-4a69-4722-b026-657094a2eb28"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC8 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.35,+                                "y": 1.95+                            },+                            "uuid": "1069a3e1-132d-4c9b-8aa4-9801cb5562d8"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC8 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.35,+                                "y": 1.95+                            },+                            "uuid": "1069a3e1-132d-4c9b-8aa4-9801cb5562d8"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol MC1 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.45,+                                "y": 1.7+                            },+                            "uuid": "8f8918c0-765e-4161-a1e4-5f90487ec2f3"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC1 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.45,+                                "y": 1.7+                            },+                            "uuid": "1629739a-2b65-47bf-8fa8-e60159a38df9"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC1 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.45,+                                "y": 1.7+                            },+                            "uuid": "1629739a-2b65-47bf-8fa8-e60159a38df9"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol MC2 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.55,+                                "y": 1.7+                            },+                            "uuid": "4a570150-316a-4507-89d0-c274d4a0bd76"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC2 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.55,+                                "y": 1.7+                            },+                            "uuid": "d5557b27-853c-4b6a-8259-bd2ae68b9ca2"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC2 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.55,+                                "y": 1.7+                            },+                            "uuid": "d5557b27-853c-4b6a-8259-bd2ae68b9ca2"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol MC3 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.65,+                                "y": 1.7+                            },+                            "uuid": "8b449cf6-4382-4c44-86e1-7117706c6e26"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC3 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.65,+                                "y": 1.7+                            },+                            "uuid": "ba1c3f95-520c-42cd-9b7c-2baa3ab75b88"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC3 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.65,+                                "y": 1.7+                            },+                            "uuid": "ba1c3f95-520c-42cd-9b7c-2baa3ab75b88"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol MC4 Pin 1 [SIG_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 0.75,+                                "y": 1.7+                            },+                            "uuid": "3684dc9b-31f5-4be1-b8d9-ab726cb6bf47"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC4 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.75,+                                "y": 1.7+                            },+                            "uuid": "b64cceb4-9951-4d25-a957-08e0905703b9"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MC4 [ADOM_SIGNAL_PIN]",+                            "pos": {+                                "x": 0.75,+                                "y": 1.7+                            },+                            "uuid": "b64cceb4-9951-4d25-a957-08e0905703b9"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "The current configuration does not include the footprint library 'Connector_USB'",+                    "items": [+                        {+                            "description": "Symbol J1 [USB_C_Receptacle_USB2.0_16P]",+                            "pos": {+                                "x": 0.9,+                                "y": 1.0+                            },+                            "uuid": "5a965628-acd9-4560-a11d-43e2b8573e8f"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'Connector'",+                    "items": [+                        {+                            "description": "Symbol J1 [USB_C_Receptacle_USB2.0_16P]",+                            "pos": {+                                "x": 0.9,+                                "y": 1.0+                            },+                            "uuid": "5a965628-acd9-4560-a11d-43e2b8573e8f"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol J1 Hidden pin A9 [VBUS, Passive, Line]",+                            "pos": {+                                "x": 1.0524,+                                "y": 0.8476+                            },+                            "uuid": "8db15e5f-5f0f-4284-9a9e-00648973066e"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol C1 Pin 1 [Passive, Line]",+                            "pos": {+                                "x": 1.15,+                                "y": 0.6119+                            },+                            "uuid": "69a2a52b-37c3-4190-8980-c251aca680cb"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'Capacitor_SMD'",+                    "items": [+                        {+                            "description": "Symbol C1 [C]",+                            "pos": {+                                "x": 1.15,+                                "y": 0.65+                            },+                            "uuid": "944d6a99-1ce2-475c-8484-31b1f8dfd93e"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'Device'",+                    "items": [+                        {+                            "description": "Symbol C1 [C]",+                            "pos": {+                                "x": 1.15,+                                "y": 0.65+                            },+                            "uuid": "944d6a99-1ce2-475c-8484-31b1f8dfd93e"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol C2 Pin 1 [Passive, Line]",+                            "pos": {+                                "x": 1.3,+                                "y": 0.6119+                            },+                            "uuid": "c6bbc6a3-a48c-47a4-904d-3c97f6d06268"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'Capacitor_SMD'",+                    "items": [+                        {+                            "description": "Symbol C2 [C]",+                            "pos": {+                                "x": 1.3,+                                "y": 0.65+                            },+                            "uuid": "710a8855-159c-431d-8c80-9dd5a9a50574"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'Device'",+                    "items": [+                        {+                            "description": "Symbol C2 [C]",+                            "pos": {+                                "x": 1.3,+                                "y": 0.65+                            },+                            "uuid": "710a8855-159c-431d-8c80-9dd5a9a50574"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol C3 Pin 1 [Passive, Line]",+                            "pos": {+                                "x": 1.45,+                                "y": 0.6119+                            },+                            "uuid": "1a6424e2-a05e-485d-890f-913e70acc2fb"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'Capacitor_SMD'",+                    "items": [+                        {+                            "description": "Symbol C3 [C]",+                            "pos": {+                                "x": 1.45,+                                "y": 0.65+                            },+                            "uuid": "24949223-3bad-40eb-b265-23699f16d14c"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'Device'",+                    "items": [+                        {+                            "description": "Symbol C3 [C]",+                            "pos": {+                                "x": 1.45,+                                "y": 0.65+                            },+                            "uuid": "24949223-3bad-40eb-b265-23699f16d14c"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol U1 Pin 1 [IOVDD, Power input, Line]",+                            "pos": {+                                "x": 1.4746,+                                "y": 0.5428+                            },+                            "uuid": "ec22a3c8-a3a1-4bca-93b9-143f9c982fe3"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol U1 [RP2040_AdomTour]",+                            "pos": {+                                "x": 1.5,+                                "y": 1.0+                            },+                            "uuid": "fccaf712-9455-4f15-97a2-d61a21d58069"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol U1 [RP2040_AdomTour]",+                            "pos": {+                                "x": 1.5,+                                "y": 1.0+                            },+                            "uuid": "fccaf712-9455-4f15-97a2-d61a21d58069"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol Y1 Pin 1 [1, Passive, Line]",+                            "pos": {+                                "x": 1.7619,+                                "y": 1.0+                            },+                            "uuid": "9d06fced-ec71-43b8-a259-38f660fba6e4"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'Crystal'",+                    "items": [+                        {+                            "description": "Symbol Y1 [Crystal]",+                            "pos": {+                                "x": 1.8,+                                "y": 1.0+                            },+                            "uuid": "2ac8af22-63d5-4b51-8938-0b5716435db4"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'Device'",+                    "items": [+                        {+                            "description": "Symbol Y1 [Crystal]",+                            "pos": {+                                "x": 1.8,+                                "y": 1.0+                            },+                            "uuid": "2ac8af22-63d5-4b51-8938-0b5716435db4"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol C4 Pin 1 [Passive, Line]",+                            "pos": {+                                "x": 1.8,+                                "y": 1.2619+                            },+                            "uuid": "b065636a-8c43-46cc-9185-e40957d74311"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'Capacitor_SMD'",+                    "items": [+                        {+                            "description": "Symbol C4 [C]",+                            "pos": {+                                "x": 1.8,+                                "y": 1.3+                            },+                            "uuid": "21c3f41f-caa5-425b-9d81-4b710c771966"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'Device'",+                    "items": [+                        {+                            "description": "Symbol C4 [C]",+                            "pos": {+                                "x": 1.8,+                                "y": 1.3+                            },+                            "uuid": "21c3f41f-caa5-425b-9d81-4b710c771966"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol C5 Pin 1 [Passive, Line]",+                            "pos": {+                                "x": 1.8,+                                "y": 1.4119+                            },+                            "uuid": "7a5cf694-6609-4c5f-8373-9dcb62cfdb3b"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'Capacitor_SMD'",+                    "items": [+                        {+                            "description": "Symbol C5 [C]",+                            "pos": {+                                "x": 1.8,+                                "y": 1.45+                            },+                            "uuid": "c53b57a6-d044-466c-87ab-e1fbee636ded"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'Device'",+                    "items": [+                        {+                            "description": "Symbol C5 [C]",+                            "pos": {+                                "x": 1.8,+                                "y": 1.45+                            },+                            "uuid": "c53b57a6-d044-466c-87ab-e1fbee636ded"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol MP1 Pin 1 [MECH_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 2.2,+                                "y": 0.6+                            },+                            "uuid": "9344a340-1825-4e78-8b4a-ac1d07d5824e"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MP1 [ADOM_MECHANICAL_PIN]",+                            "pos": {+                                "x": 2.2,+                                "y": 0.6+                            },+                            "uuid": "3ae1c1fd-568d-4b74-be05-31f71e2ba01b"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MP1 [ADOM_MECHANICAL_PIN]",+                            "pos": {+                                "x": 2.2,+                                "y": 0.6+                            },+                            "uuid": "3ae1c1fd-568d-4b74-be05-31f71e2ba01b"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol MP3 Pin 1 [MECH_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 2.2,+                                "y": 0.74+                            },+                            "uuid": "121315f9-74bb-4587-92f3-77b156a1497e"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MP3 [ADOM_MECHANICAL_PIN]",+                            "pos": {+                                "x": 2.2,+                                "y": 0.74+                            },+                            "uuid": "b5756453-6b43-4b08-b244-8dee8bf33875"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MP3 [ADOM_MECHANICAL_PIN]",+                            "pos": {+                                "x": 2.2,+                                "y": 0.74+                            },+                            "uuid": "b5756453-6b43-4b08-b244-8dee8bf33875"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol MP2 Pin 1 [MECH_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 2.32,+                                "y": 0.6+                            },+                            "uuid": "708381c6-5eb0-483d-8b85-7ffd658bce6c"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MP2 [ADOM_MECHANICAL_PIN]",+                            "pos": {+                                "x": 2.32,+                                "y": 0.6+                            },+                            "uuid": "baa35453-070b-4c49-b827-12ddf28d5c38"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MP2 [ADOM_MECHANICAL_PIN]",+                            "pos": {+                                "x": 2.32,+                                "y": 0.6+                            },+                            "uuid": "baa35453-070b-4c49-b827-12ddf28d5c38"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                },+                {+                    "description": "Symbol pin or wire end off connection grid",+                    "items": [+                        {+                            "description": "Symbol MP4 Pin 1 [MECH_PIN, Bidirectional, Line]",+                            "pos": {+                                "x": 2.32,+                                "y": 0.74+                            },+                            "uuid": "ce4c3a3b-1c28-4abe-821f-2e086c2d6d39"+                        }+                    ],+                    "severity": "warning",+                    "type": "endpoint_off_grid"+                },+                {+                    "description": "The current configuration does not include the footprint library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MP4 [ADOM_MECHANICAL_PIN]",+                            "pos": {+                                "x": 2.32,+                                "y": 0.74+                            },+                            "uuid": "fcf8cfb2-a566-4511-9ce6-9048d821b870"+                        }+                    ],+                    "severity": "warning",+                    "type": "footprint_link_issues"+                },+                {+                    "description": "The current configuration does not include the symbol library 'AdomRP2040'",+                    "items": [+                        {+                            "description": "Symbol MP4 [ADOM_MECHANICAL_PIN]",+                            "pos": {+                                "x": 2.32,+                                "y": 0.74+                            },+                            "uuid": "fcf8cfb2-a566-4511-9ce6-9048d821b870"+                        }+                    ],+                    "severity": "warning",+                    "type": "lib_symbol_issues"+                }+            ]+        }+    ],+    "source": ".tmpubz9Ir.kicad_sch"+}
rust/crates/kicad-core/tests/reports.rsadded+37
@@ -0,0 +1,37 @@+//! Parser tests against real KiCad 10 reports produced by service-kicad+//! (`service-kicad pcb drc` / `sch erc` on tour-pack-rp2040), so the shapes the+//! bridge summarises are the shapes kicad-cli actually writes.++use kicad_core::cli::{flatten_violations, summarize_report};++fn fixture(name: &str) -> serde_json::Value {+    let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name);+    serde_json::from_str(&std::fs::read_to_string(&p).expect("fixture readable")).expect("fixture is JSON")+}++#[test]+fn drc_report_summarises_and_flattens() {+    let rep = fixture("rp2040-drc.json");+    let s = summarize_report(&rep);+    let v = flatten_violations(&rep);+    assert_eq!(s["kicadVersion"], rep["kicad_version"]);+    let errors = s["errors"].as_u64().unwrap();+    let warnings = s["warnings"].as_u64().unwrap();+    assert!(errors + warnings > 0, "a real board report has findings: {s}");+    assert_eq!(s["unconnected"].as_u64().unwrap() as usize, rep["unconnected_items"].as_array().map(|a| a.len()).unwrap_or(0));+    assert!(v.len() >= (errors + warnings) as usize);+    for item in &v {+        assert!(item["severity"].is_string() && item["description"].is_string() && item["items"].is_array());+    }+    assert_eq!(s["clean"], serde_json::json!(errors == 0 && s["unconnected"] == 0));+}++#[test]+fn erc_report_walks_sheets() {+    let rep = fixture("rp2040-erc.json");+    let sheet_total: usize = rep["sheets"].as_array().unwrap().iter().map(|s| s["violations"].as_array().map(|a| a.len()).unwrap_or(0)).sum();+    let v = flatten_violations(&rep);+    assert_eq!(v.len(), sheet_total, "every sheet violation is flattened");+    let s = summarize_report(&rep);+    assert!(s["errors"].as_u64().unwrap() + s["warnings"].as_u64().unwrap() <= sheet_total as u64);+}