app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
rust: netlist, lint, libraries/settings/design rules, model_check/pcm/updates groups (33 verbs); net feature gate; service-kicad report fixtures
25 files changed
+11694−58
rust/Cargo.lock+265−1rust/README.md+1−1rust/crates/kicad-bridge/src/catalog.rs+12−3rust/crates/kicad-bridge/src/groups.rs+31rust/crates/kicad-bridge/src/main.rs+7−1rust/crates/kicad-bridge/src/util.rs+59rust/crates/kicad-bridge/src/verbs.rs+10−52rust/crates/kicad-bridge/src/verbs_libraries.rs+336rust/crates/kicad-bridge/src/verbs_lint.rs+176rust/crates/kicad-bridge/src/verbs_maint.rs+100rust/crates/kicad-bridge/src/verbs_netlist.rs+236rust/crates/kicad-core/Cargo.toml+10rust/crates/kicad-core/src/design_rules.rs+436rust/crates/kicad-core/src/lib.rs+8rust/crates/kicad-core/src/libraries.rs+1277rust/crates/kicad-core/src/lint.rs+1452rust/crates/kicad-core/src/model_check.rs+928rust/crates/kicad-core/src/netlist.rs+766rust/crates/kicad-core/src/pcm.rs+957rust/crates/kicad-core/src/settings.rs+185rust/crates/kicad-core/src/updates.rs+267rust/crates/kicad-core/tests/fixtures/rp2040-bom.csv+21rust/crates/kicad-core/tests/fixtures/rp2040-drc.json+1740rust/crates/kicad-core/tests/fixtures/rp2040-erc.json+2377rust/crates/kicad-core/tests/reports.rs+37rust/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(®), 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(®istered),+ "registeredSymbols": count(®istered),+ "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, ®istered),+ 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(®istered) {+ Ok(t) => extract_symbols_tolerant(&t).0.into_iter().map(|(n, _)| n.to_lowercase()).collect(),+ Err(_) => list_symbols(®istered).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(®, "(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(®), "", ""));+ 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 & 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 = ®["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);+}