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: misc group (ipc_api, export_molecule, errors, log_tail), first-run seeding in core, net feature forwarded
7 files changed
+3887−11
rust/crates/kicad-bridge/Cargo.toml+6−1rust/crates/kicad-bridge/src/verbs_misc.rs+123−7rust/crates/kicad-core/src/bridge_log.rs+452−1rust/crates/kicad-core/src/install.rs+2536rust/crates/kicad-core/src/kicad_common.rs+425−1rust/crates/kicad-core/src/lib.rs+1rust/crates/kicad-core/src/render.rs+344−1rust/crates/kicad-bridge/Cargo.toml+6−1@@ -14,5 +14,10 @@ path = "src/main.rs" serde = { workspace = true } serde_json = { workspace = true } tiny_http = { workspace = true }-kicad-core = { path = "../kicad-core" }+kicad-core = { path = "../kicad-core", default-features = false } kicad-platform = { path = "../kicad-platform" }++[features]+default = ["net"]+# Forwarded to kicad-core; off for a cross type-check from a box without the target C toolchain.+net = ["kicad-core/net"]
rust/crates/kicad-bridge/src/verbs_misc.rs+123−7@@ -1,11 +1,127 @@-//! Verb group "misc". Placeholder until the port lands.-use serde_json::Value;+//! Verb group "misc": kicad_ipc_api, kicad_export_molecule, kicad_errors, kicad_log_tail.+//! Ported from handlers/ipc_api.py, handlers/export_molecule.py and handlers/kicad_log.py.+//! The logic lives in kicad_core::{kicad_common, render, bridge_log}; this file only+//! resolves paths through the platform layer and the detection cache.+use serde_json::{json, Value}; -use crate::catalog::Verb;-use crate::util::State;+use crate::catalog::{Mechanism, Verb};+use crate::util::{norm, State};+use kicad_core::{bridge_log, kicad_common, render}; -pub static VERBS: &[Verb] = &[];+pub static VERBS: &[Verb] = &[+ Verb {+ name: "kicad_ipc_api",+ summary: "Read or flip KiCad's IPC API server switch (Preferences > Plugins) that live routing needs.",+ mechanism: Mechanism::File, risk: "config", timeout_sec: 15,+ input: "{\"enable\"?: bool}",+ example: "kicad_ipc_api {\"enable\":true}",+ hint: "kicad_ipc_api {} reports enabled; {\"enable\":true} writes kicad_common.json (backup kept). KiCad must be closed and relaunched to pick it up.",+ related: &["kicad_routing_state", "kicad_close", "kicad_launch"],+ pitfalls: &[+ "a running KiCad rewrites kicad_common.json on exit and can undo the change: close KiCad first",+ "does not restart KiCad for you",+ "the native build does not detect a running KiCad yet: kicadRunning is null, so close KiCad yourself before flipping",+ ],+ },+ Verb {+ name: "kicad_export_molecule",+ summary: "One-shot molecule export pack: STEP + silk renders + sch/pro discovery for the Adom molecule pipeline.",+ mechanism: Mechanism::Cli, risk: "write", timeout_sec: 700,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"outputDir\"?: \"C:/...\", \"name\"?: \"Board name\"}",+ example: "kicad_export_molecule {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+ hint: "Absolute board path; exports STEP (+ silkscreen top/bottom PNGs when kicad-cli pcb render exists), finds the sibling .kicad_sch/.kicad_pro, and returns data.nextSteps, the exact container commands (step2glb, then adom-molecule create) to finish.",+ related: &["kicad_export_step", "kicad_export_svg"],+ pitfalls: &[+ "adom-molecule requires glb+pcb+sch+pro: check data.missing before running the container half",+ "silk renders need KiCad 9+ (kicad-cli pcb render); older KiCads skip them gracefully",+ ],+ },+ Verb {+ name: "kicad_errors",+ summary: "Every KiCad error/warning DIALOG on screen, with its exact text (read-only, background).",+ mechanism: Mechanism::File, risk: "read", timeout_sec: 15,+ input: "{\"includeAll\"?: bool}",+ example: "kicad_errors {}",+ hint: "KiCad has no log file and its GUI errors go to message boxes, not stderr: this is where a failure reason actually lives. Pure text read: nothing clicked, raised or screenshotted.",+ related: &["kicad_log_tail", "kicad_dismiss_dialogs", "kicad_window_info"],+ pitfalls: &[+ "read-only: it reports dialogs, it does not clear them",+ "a dialog blocks the editor until dismissed",+ "the native build has no window enumeration yet (phase 3): dialogs is always empty and windowSource says so; use the Python bridge's kicad_errors or ab's desktop_list_windows + desktop_ui_tree meanwhile",+ ],+ },+ Verb {+ name: "kicad_log_tail",+ summary: "New KiCad log output since a byte offset: what KiCad said about ITSELF (read-only).",+ mechanism: Mechanism::File, risk: "read", timeout_sec: 15,+ input: "{\"sinceOffset\"?: 0, \"maxBytes\"?: 200000, \"errorsOnly\"?: bool, \"path\"?: \"C:/.../x.log\"}",+ example: "kicad_log_tail {\"sinceOffset\":0,\"errorsOnly\":true}",+ hint: "KiCad writes no log file; the bridge captures its stderr for instances IT launched (0.9.266+). Carry nextOffset to get only what is new. errorsOnly:true filters to error/warning.",+ related: &["kicad_launch", "kicad_diagnostics", "kicad_window_info"],+ pitfalls: &[+ "a KiCad the USER started writes nothing here: its stderr belongs to whoever launched it",+ "relaunch after upgrading the bridge or there is no capture yet",+ "the native build has no launch verb yet, so it only reads logs the Python bridge's kicad_launch wrote to <LOCALAPPDATA>/Adom Bridge/kicad-logs; available:false is the honest answer on a box that never launched through Python",+ ],+ },+]; -pub fn dispatch(_state: &mut State, _command: &str, _args: &Value) -> Option<Value> {- None+pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+ Some(match command {+ "kicad_ipc_api" => ipc_api(args),+ "kicad_export_molecule" => export_molecule(state, args),+ "kicad_errors" => errors(args),+ "kicad_log_tail" => log_tail(args),+ _ => return None,+ })+}++/// Python `_common_files` looks at EVERY <settings>/<version>/kicad_common.json and+/// takes the newest version, independent of which KiCad is detected; so does this.+fn ipc_api(args: &Value) -> Value {+ let root = kicad_common::settings_root();+ kicad_common::ipc_api(root.as_deref(), args, &kicad_common::now_utc_iso())+}++fn export_molecule(state: &mut State, args: &Value) -> Value {+ let info = state.kicad_info();+ let cli = info.primary().map(|i| std::path::PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file());+ let Some(cli) = cli else {+ let mut v = render::export_molecule(false, args, &|_a, _t| unreachable!());+ v["alternatives"] = crate::util::alternatives();+ return v;+ };+ let run = render::cli_runner(&cli);+ let mut v = render::export_molecule(true, args, &run);+ if v["success"] == json!(true) {+ v["cliVersion"] = json!(info.primary().map(|i| i.cli_version.clone()).unwrap_or_default());+ }+ v+}++/// The window walk is OS work that lands with the phase 3 window verbs. Until then the+/// answer is the Python shape with zero dialogs and a windowSource that says why.+fn errors(args: &Value) -> Value {+ let include_all = args.get("includeAll").and_then(Value::as_bool).unwrap_or(false);+ let caps = kicad_platform::native().capabilities();+ let source = format!(+ "unavailable: the native {} build has no window enumeration yet (phase 3, owner {}); no dialog was read",+ caps.os, caps.owner+ );+ let mut v = bridge_log::errors(&[], include_all, &source);+ v["windowEnumeration"] = json!(false);+ v["_hint"] = json!(format!(+ "{} NOTE: this native build cannot see windows yet, so count 0 means 'not looked', not 'no dialog'. Read dialogs through ab (desktop_list_windows, then desktop_ui_tree on the hwnd) or the Python bridge's kicad_errors until phase 3 lands.",+ bridge_log::ERRORS_HINT+ ));+ v+}++fn log_tail(args: &Value) -> Value {+ let dir = bridge_log::log_dir();+ let mut v = bridge_log::log_tail(dir.as_deref(), args);+ if let Some(d) = dir.as_deref() {+ v["logDir"] = json!(norm(d));+ }+ v }
rust/crates/kicad-core/src/bridge_log.rs+452−1@@ -1 +1,452 @@-//! Placeholder: filled in by the phase 1 port.+//! KiCad's own diagnostics, ported from handlers/kicad_log.py.+//!+//! KiCad writes NO log file. Its diagnostics go through wxLog to stderr, and a GUI+//! process on Windows has no console, so stderr goes to the void unless whoever+//! launched it supplies a handle. The Python bridge's launch verb captures that stream+//! into one file per launch under `<LOCALAPPDATA or ~/.local/share>/Adom Bridge/kicad-logs/`+//! with `KICAD_ENABLE_WXTRACE=1` set, and `kicad_log_tail` returns only what is NEW+//! since a byte offset the caller carries.+//!+//! The native bridge has no launch verb yet, so this module reads the same directory+//! the Python writes (a Python-launched KiCad on the same box still lands here) and+//! says so honestly when nothing is there. `new_log_path` and `trace_env` are ready+//! for the phase 3 launch verb.+//!+//! The dialog channel (`kicad_errors`): measured on KiCad 10, a board that fails to+//! parse produces a "KiCad PCB Editor Error" window with the exact reason while the+//! captured stderr for that launch is ZERO BYTES, because wxLogGui turns wxLogError+//! into a message box. So the dialogs ARE the log. The window walk is OS work (phase+//! 3); the classification of what it finds is pure and lives here as `dialog_entries`.++use std::path::{Path, PathBuf};++use serde_json::{json, Value};++/// One directory, one file per launch, newest wins.+pub const LOG_DIR_NAME: &str = "kicad-logs";+/// How many launch logs to keep (Python `_KEEP_LOGS`).+pub const KEEP_LOGS: usize = 10;++fn home() -> Option<PathBuf> {+ std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)+}++/// `<LOCALAPPDATA>/Adom Bridge/kicad-logs`, or `~/.local/share/Adom Bridge/kicad-logs`+/// where LOCALAPPDATA is unset (Python `log_dir`, without the mkdir: reading is read-only).+pub fn log_dir() -> Option<PathBuf> {+ let base = std::env::var_os("LOCALAPPDATA").map(PathBuf::from).or_else(|| home().map(|h| h.join(".local/share")))?;+ Some(base.join("Adom Bridge").join(LOG_DIR_NAME))+}++/// `~/.adom/bridge-logs/kicad.log`: the bridge's OWN stdout/stderr as ab captures it+/// (ab 2.0.44+). Not KiCad's output; reported so a caller knows where the second file is.+pub fn bridge_log_path() -> Option<PathBuf> {+ home().map(|h| h.join(".adom").join("bridge-logs").join("kicad.log"))+}++/// `*.log` files in a directory, newest mtime first.+fn logs_by_mtime(dir: &Path) -> Vec<PathBuf> {+ let Ok(rd) = std::fs::read_dir(dir) else {+ return Vec::new();+ };+ let mut logs: Vec<(std::time::SystemTime, PathBuf)> = rd+ .flatten()+ .map(|e| e.path())+ .filter(|p| p.is_file() && p.extension().map(|x| x == "log").unwrap_or(false))+ .filter_map(|p| std::fs::metadata(&p).and_then(|m| m.modified()).ok().map(|t| (t, p)))+ .collect();+ logs.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.cmp(&a.1)));+ logs.into_iter().map(|(_, p)| p).collect()+}++/// The newest launch log in a directory (Python `latest_log`).+pub fn latest_log(dir: &Path) -> Option<PathBuf> {+ logs_by_mtime(dir).into_iter().next()+}++/// A fresh per-launch log path `<dir>/<YYYYmmdd-HHMMSS>-<tag>.log`, pruning old logs so+/// the directory never holds more than KEEP_LOGS (Python `new_log_path`). Creates the+/// directory. `stamp` is the local-time stamp the caller formats (kept out of here so+/// tests are deterministic and no time-zone crate is needed).+pub fn new_log_path(dir: &Path, stamp: &str, tag: &str) -> std::io::Result<PathBuf> {+ std::fs::create_dir_all(dir)?;+ for old in logs_by_mtime(dir).into_iter().skip(KEEP_LOGS - 1) {+ let _ = std::fs::remove_file(old);+ }+ let mut safe = String::new();+ let mut last_dash = false;+ for ch in tag.chars() {+ if ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-' {+ safe.push(ch);+ last_dash = false;+ } else if !last_dash {+ safe.push('-');+ last_dash = true;+ }+ }+ let safe: String = safe.chars().take(40).collect();+ let safe = if safe.is_empty() { "kicad".to_string() } else { safe };+ Ok(dir.join(format!("{stamp}-{safe}.log")))+}++/// Environment additions that make KiCad actually say something (Python `trace_env`):+/// `KICAD_ENABLE_WXTRACE=1` always; `WXTRACE=<masks>` only when the caller wants the+/// firehose. Errors and warnings arrive without any mask once logging is enabled.+pub fn trace_env(masks: Option<&str>) -> Vec<(String, String)> {+ let mut v = vec![("KICAD_ENABLE_WXTRACE".to_string(), "1".to_string())];+ if let Some(m) = masks.filter(|m| !m.is_empty()) {+ v.push(("WXTRACE".to_string(), m.to_string()));+ }+ v+}++const LEVEL_WORDS: [&str; 6] = ["error", "warning", "trace", "info", "debug", "fatal"];++/// Python `_classify`: an explicit `Error:`/`Warning ` prefix wins; otherwise keyword+/// sniffing; otherwise "info".+pub fn classify(line: &str) -> &'static str {+ let t = line.trim_start();+ let low = t.to_ascii_lowercase();+ for w in LEVEL_WORDS {+ if let Some(rest) = low.strip_prefix(w) {+ if rest.starts_with(':') || rest.starts_with(' ') {+ return w;+ }+ }+ }+ let low = line.to_lowercase();+ if low.contains("error") || low.contains("failed") || low.contains("cannot") || low.contains("unable to") {+ return "error";+ }+ if low.contains("warning") || low.contains("deprecat") {+ return "warning";+ }+ "info"+}++const NO_LOG_HINT: &str = "No KiCad log yet. Logs exist only for KiCad instances a bridge launched with stderr captured (Python bridge 0.9.266+; the native bridge has no launch verb yet, so it reads what the Python bridge wrote). A KiCad the user started themselves writes nothing, because its stderr belongs to whoever launched it.";++/// kicad_log_tail (Python `handle_log_tail`): new output since a byte offset, read-only.+/// Args: sinceOffset (default 0), maxBytes (default 200000, newest kept), errorsOnly,+/// path (explicit file; default the newest launch log in `dir`).+pub fn log_tail(dir: Option<&Path>, args: &Value) -> Value {+ let explicit = args.get("path").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).map(PathBuf::from);+ let p = explicit.or_else(|| dir.and_then(latest_log));+ let bridge_log = bridge_log_path().filter(|p| p.is_file()).map(|p| p.to_string_lossy().replace('\\', "/"));+ let Some(p) = p.filter(|p| p.is_file()) else {+ return json!({+ "success": true, "available": false, "lines": [], "nextOffset": 0,+ "logDir": dir.map(|d| d.to_string_lossy().replace('\\', "/")),+ "bridgeLogPath": bridge_log,+ "_hint": NO_LOG_HINT,+ });+ };+ let since_raw = int_arg(args, "sinceOffset").unwrap_or(0);+ let max_bytes = int_arg(args, "maxBytes").filter(|v| *v > 0).unwrap_or(200_000) as usize;+ let size = std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0);+ let mut since = since_raw.max(0) as u64;+ if since > size {+ since = 0; // file rotated or truncated under us+ }+ let blob = match std::fs::read(&p) {+ Ok(b) => b,+ Err(e) => {+ return json!({"success": false, "errorCode": "log_unreadable", "error": format!("{}: {e}", p.display()),+ "_hint": "The log exists but cannot be read; check permissions."});+ }+ };+ let mut blob = &blob[(since as usize).min(blob.len())..];+ let truncated = blob.len() > max_bytes;+ if truncated {+ blob = &blob[blob.len() - max_bytes..];+ }+ let text = String::from_utf8_lossy(blob);+ let errors_only = args.get("errorsOnly").and_then(Value::as_bool).unwrap_or(false);+ let mut counts: serde_json::Map<String, Value> = serde_json::Map::new();+ let mut lines: Vec<Value> = Vec::new();+ for ln in text.lines().filter(|l| !l.trim().is_empty()) {+ let level = classify(ln);+ if errors_only && !matches!(level, "error" | "warning" | "fatal") {+ continue;+ }+ let n = counts.get(level).and_then(Value::as_u64).unwrap_or(0);+ counts.insert(level.into(), json!(n + 1));+ lines.push(json!({"level": level, "text": ln}));+ }+ json!({+ "success": true, "available": true,+ "logPath": p.to_string_lossy().replace('\\', "/"),+ "sinceOffset": since, "nextOffset": size, "truncated": truncated,+ "lineCount": lines.len(), "levels": counts, "lines": lines,+ "bridgeLogPath": bridge_log,+ "_hint": "Carry nextOffset into the next call and you get ONLY what happened since: tail, act, tail again, and the second read is your action's consequences. errorsOnly:true filters to error/warning.",+ })+}++/// Python `int(args.get(k) or 0)`: numbers, numeric strings; None otherwise.+fn int_arg(args: &Value, key: &str) -> Option<i64> {+ match args.get(key)? {+ Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),+ Value::String(s) => s.trim().parse::<i64>().ok(),+ Value::Bool(b) => Some(*b as i64),+ _ => None,+ }+}++/// Python `_ERR_TITLE`: `\b(error|warning|problem|failed|cannot)\b`, case-insensitive.+pub fn is_error_title(title: &str) -> bool {+ ["error", "warning", "problem", "failed", "cannot"].iter().any(|w| contains_word(title, w))+}++fn is_word(c: char) -> bool {+ c.is_alphanumeric() || c == '_'+}++fn contains_word(hay: &str, word: &str) -> bool {+ let low = hay.to_lowercase();+ let chars: Vec<char> = low.chars().collect();+ let w: Vec<char> = word.chars().collect();+ if w.is_empty() || chars.len() < w.len() {+ return false;+ }+ for i in 0..=chars.len() - w.len() {+ if chars[i..i + w.len()] == w[..] {+ let before_ok = i == 0 || !is_word(chars[i - 1]);+ let after_ok = i + w.len() == chars.len() || !is_word(chars[i + w.len()]);+ if before_ok && after_ok {+ return true;+ }+ }+ }+ false+}++/// One KiCad-owned top-level window as the OS layer will report it: handle, caption,+/// every text-bearing child (win32 GetWindowText, buttons excluded), and the UIA text+/// harvested when the children carried nothing (Windows TaskDialogs draw their text in+/// DirectUI with no per-string child controls).+#[derive(Clone, Debug, Default)]+pub struct DialogRow {+ pub hwnd: u64,+ pub title: String,+ pub texts: Vec<String>,+ pub uia_text: Option<String>,+}++/// Python `handle_errors` loop: which windows count, and each one's entry. The message+/// is the longest text (buttons are short); a window with no readable text says how to+/// read it. Read-only by construction: nothing is clicked, raised or screenshotted.+pub fn dialog_entries(rows: &[DialogRow], include_all: bool) -> Vec<Value> {+ let mut out = Vec::new();+ for row in rows {+ let title = row.title.trim();+ if title.is_empty() {+ continue;+ }+ let errorish = is_error_title(title);+ if !include_all && !errorish {+ continue;+ }+ let mut msg = row.texts.iter().max_by_key(|t| t.len()).cloned().unwrap_or_default();+ let mut via = "win32";+ if msg.is_empty() {+ if let Some(u) = row.uia_text.as_deref().filter(|u| !u.is_empty()) {+ msg = u.to_string();+ via = "uia";+ }+ }+ let mut entry = json!({+ "hwnd": row.hwnd,+ "title": title,+ "message": msg,+ "messageVia": if msg.is_empty() { Value::Null } else { json!(via) },+ "allText": row.texts.iter().take(12).collect::<Vec<_>>(),+ "level": if errorish { "error" } else { "info" },+ });+ if msg.is_empty() {+ entry["messageUnavailable"] = json!("taskdialog_directui_and_uia_unavailable");+ entry["readTextWith"] = json!(format!("desktop_ui_tree {{\"hwnd\":{},\"maxDepth\":8}}: read it IMMEDIATELY; these dialogs are transient", row.hwnd));+ }+ out.push(entry);+ }+ out+}++pub const ERRORS_HINT: &str = "KiCad has NO log file and in GUI mode its errors go to DIALOGS, not stderr, so this is where a GUI failure reason lives. Read-only: nothing is clicked or raised. THREE channels, use all three: (1) this verb; (2) kicad_log_tail for what bypasses wxLog (crashes, asserts, stdio); (3) kicad_screenshot_all when the first two say nothing and the result is still wrong: the pixels have no preconditions and remain the floor under any automation loop. Dismiss with kicad_dismiss_dialogs (which DOES foreground, so caption first).";++/// kicad_errors response in the Python shape: count, dialogs, windowSource, _hint.+pub fn errors(rows: &[DialogRow], include_all: bool, window_source: &str) -> Value {+ let dialogs = dialog_entries(rows, include_all);+ json!({"success": true, "count": dialogs.len(), "dialogs": dialogs, "windowSource": window_source, "_hint": ERRORS_HINT})+}++#[cfg(test)]+mod tests {+ use super::*;++ 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-log-{}-{name}", std::process::id()));+ let _ = std::fs::remove_dir_all(&p);+ std::fs::create_dir_all(&p).unwrap();+ Tmp(p)+ }++ #[test]+ fn classify_matches_python() {+ assert_eq!(classify("Error: cannot open"), "error");+ assert_eq!(classify(" warning something"), "warning");+ assert_eq!(classify("Trace: KICAD_LIBRARIES foo"), "trace");+ assert_eq!(classify("Fatal: boom"), "fatal");+ assert_eq!(classify("Errors: 3"), "error"); // prefix miss (no boundary), keyword hit+ assert_eq!(classify("Information only"), "info"); // 'Info' + 'r' is no boundary+ assert_eq!(classify("Failed to load board"), "error");+ assert_eq!(classify("Unable to resolve ${KIPRJMOD}"), "error");+ assert_eq!(classify("this API is deprecated"), "warning");+ assert_eq!(classify("loaded 12 footprints"), "info");+ }++ #[test]+ fn error_title_word_boundaries() {+ assert!(is_error_title("KiCad PCB Editor Error"));+ assert!(is_error_title("Warning"));+ assert!(is_error_title("Load failed: x"));+ assert!(is_error_title("Cannot open file"));+ assert!(!is_error_title("Errors and Warnings panel")); // plural: no boundary+ assert!(!is_error_title("KiCad 10.0 [demo/demo.kicad_pcb]"));+ assert!(!is_error_title("Problematic"));+ }++ #[test]+ fn tail_absent_is_honest() {+ let t = tmp("absent");+ let r = log_tail(Some(&t.0), &json!({}));+ assert_eq!(r["success"], json!(true));+ assert_eq!(r["available"], json!(false));+ assert_eq!(r["lines"], json!([]));+ assert_eq!(r["nextOffset"], json!(0));+ assert!(r["_hint"].as_str().unwrap().contains("No KiCad log yet"));+ let r = log_tail(None, &json!({}));+ assert_eq!(r["available"], json!(false));+ let r = log_tail(Some(&t.0), &json!({"path": t.0.join("nope.log").to_string_lossy()}));+ assert_eq!(r["available"], json!(false));+ }++ #[test]+ fn tail_offsets_filters_and_truncation() {+ let t = tmp("tail");+ let p = t.0.join("20260911-120000-launch.log");+ std::fs::write(&p, "Info: start\n\nError: cannot load board\nplain line\nWarning: deprecated thing\n").unwrap();+ let size = std::fs::metadata(&p).unwrap().len();++ let r = log_tail(Some(&t.0), &json!({}));+ assert_eq!(r["available"], json!(true));+ assert_eq!(r["logPath"], json!(p.to_string_lossy().replace('\\', "/")));+ assert_eq!(r["sinceOffset"], json!(0));+ assert_eq!(r["nextOffset"], json!(size));+ assert_eq!(r["truncated"], json!(false));+ assert_eq!(r["lineCount"], json!(4));+ assert_eq!(r["levels"], json!({"info": 2, "error": 1, "warning": 1}));+ assert_eq!(r["lines"][1], json!({"level": "error", "text": "Error: cannot load board"}));++ // Second read from nextOffset returns nothing new.+ let r2 = log_tail(Some(&t.0), &json!({"sinceOffset": size}));+ assert_eq!(r2["lineCount"], json!(0));+ assert_eq!(r2["sinceOffset"], json!(size));++ // Append, tail again: only the new line.+ let mut f = std::fs::OpenOptions::new().append(true).open(&p).unwrap();+ std::io::Write::write_all(&mut f, b"Fatal: crash\n").unwrap();+ let r3 = log_tail(Some(&t.0), &json!({"sinceOffset": size.to_string()}));+ assert_eq!(r3["lineCount"], json!(1));+ assert_eq!(r3["lines"][0]["level"], json!("fatal"));+ assert_eq!(r3["nextOffset"], json!(size + 13));++ // errorsOnly keeps error/warning/fatal.+ let r4 = log_tail(Some(&t.0), &json!({"errorsOnly": true}));+ assert_eq!(r4["lineCount"], json!(3));+ assert_eq!(r4["levels"], json!({"error": 1, "warning": 1, "fatal": 1}));++ // Offset past the end (rotation): whole file again.+ let r5 = log_tail(Some(&t.0), &json!({"sinceOffset": 999999}));+ assert_eq!(r5["sinceOffset"], json!(0));+ assert_eq!(r5["lineCount"], json!(5));++ // maxBytes keeps the newest bytes and flags truncation.+ let r6 = log_tail(Some(&t.0), &json!({"maxBytes": 13}));+ assert_eq!(r6["truncated"], json!(true));+ assert_eq!(r6["lineCount"], json!(1));+ assert_eq!(r6["lines"][0]["text"], json!("Fatal: crash"));+ assert_eq!(r6["nextOffset"], json!(size + 13));++ // Explicit path wins over the newest file.+ let other = t.0.join("00000000-000000-other.log");+ std::fs::write(&other, "Warning: only me\n").unwrap();+ let r7 = log_tail(Some(&t.0), &json!({"path": other.to_string_lossy()}));+ assert_eq!(r7["lineCount"], json!(1));+ assert_eq!(r7["logPath"], json!(other.to_string_lossy().replace('\\', "/")));+ }++ #[test]+ fn latest_log_and_prune() {+ let t = tmp("prune");+ for i in 0..12 {+ let p = t.0.join(format!("20260911-0000{i:02}-launch.log"));+ std::fs::write(&p, "x").unwrap();+ let when = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000 + i * 60);+ let f = std::fs::File::open(&p).unwrap();+ f.set_modified(when).unwrap();+ }+ std::fs::write(t.0.join("notes.txt"), "not a log").unwrap();+ assert_eq!(latest_log(&t.0).unwrap().file_name().unwrap().to_string_lossy(), "20260911-000011-launch.log");+ let p = new_log_path(&t.0, "20260911-130000", "launch demo/proj.kicad_pcb!!").unwrap();+ assert_eq!(p.file_name().unwrap().to_string_lossy(), "20260911-130000-launch-demo-proj.kicad_pcb-.log");+ let left = logs_by_mtime(&t.0);+ assert_eq!(left.len(), KEEP_LOGS - 1);+ assert!(t.0.join("20260911-000011-launch.log").exists());+ assert!(!t.0.join("20260911-000000-launch.log").exists());+ assert!(t.0.join("notes.txt").exists());+ assert_eq!(new_log_path(&t.0, "s", "").unwrap().file_name().unwrap().to_string_lossy(), "s-kicad.log");+ assert!(latest_log(&t.0.join("missing")).is_none());+ }++ #[test]+ fn trace_env_shape() {+ assert_eq!(trace_env(None), vec![("KICAD_ENABLE_WXTRACE".to_string(), "1".to_string())]);+ assert_eq!(trace_env(Some("KICAD_LIBRARIES,KICAD_ENV_VARS")).len(), 2);+ assert_eq!(trace_env(Some("")).len(), 1);+ }++ #[test]+ fn dialog_entries_match_python() {+ let rows = vec![+ DialogRow { hwnd: 1, title: "KiCad PCB Editor Error".into(), texts: vec!["OK".into(), "Error loading PCB 'x'.\nExpecting thru_hole".into()], uia_text: None },+ DialogRow { hwnd: 2, title: "KiCad 10.0 [demo]".into(), texts: vec!["Status".into()], uia_text: None },+ DialogRow { hwnd: 3, title: "Warning".into(), texts: vec![], uia_text: Some("Zone fill is out of date".into()) },+ DialogRow { hwnd: 4, title: "Problem".into(), texts: vec![], uia_text: None },+ DialogRow { hwnd: 5, title: " ".into(), texts: vec!["ignored".into()], uia_text: None },+ ];+ let d = dialog_entries(&rows, false);+ assert_eq!(d.len(), 3);+ assert_eq!(d[0]["message"], json!("Error loading PCB 'x'.\nExpecting thru_hole"));+ assert_eq!(d[0]["messageVia"], json!("win32"));+ assert_eq!(d[0]["level"], json!("error"));+ assert_eq!(d[0]["allText"].as_array().unwrap().len(), 2);+ assert_eq!(d[1]["hwnd"], json!(3));+ assert_eq!(d[1]["messageVia"], json!("uia"));+ assert_eq!(d[2]["messageVia"], Value::Null);+ assert_eq!(d[2]["messageUnavailable"], json!("taskdialog_directui_and_uia_unavailable"));+ assert!(d[2]["readTextWith"].as_str().unwrap().starts_with("desktop_ui_tree {\"hwnd\":4,"));+ let all = dialog_entries(&rows, true);+ assert_eq!(all.len(), 4);+ assert_eq!(all[1]["level"], json!("info"));+ let r = errors(&rows, false, "test");+ assert_eq!(r["count"], json!(3));+ assert_eq!(r["windowSource"], json!("test"));+ }+}
rust/crates/kicad-core/src/install.rsadded+2536@@ -0,0 +1,2536 @@+//! Installing libraries and parts, ported from the Python bridge's+//! `handlers/install_library.py`, `handlers/install_symbol.py`,+//! `handlers/install_footprint.py::handle_install_footprint`,+//! `handlers/install_library_bundle.py`, `adom_library.py::add_symbol` and+//! `heal_split`, `handlers/kicad_parse_check.py` (the verdict-on-text helpers),+//! `kicad_detect.ensure_win_user_config` (the lib-table self-heal),+//! `handlers/make_part_project.py` and `handlers/export_part.py`.+//!+//! Everything here is file work. The lib-table parser and writer, the KiCad path+//! variable expansion, the row-first Adom paths (wiki #71), the quote-aware symbol+//! extraction and KiCad's own verdict on a library come from `crate::libraries`;+//! the zip reader comes from `crate::pcm`. Only what those modules keep private+//! is copied here, each copy named after its source.+//!+//! Window-phase steps the Python did through the KiCad window (opening the Symbol+//! or Footprint Editor after an install, the stale-editor check, the bundle's+//! proof frame, the running-KiCad check, registering a generated project with the+//! focus guardian) are NOT done here and are reported as such, never faked.++use std::collections::BTreeMap;+use std::path::{Path, PathBuf};+use std::sync::atomic::{AtomicU64, Ordering};+use std::time::Duration;++use serde_json::{json, Map, Value};++use crate::libraries::{+ self, expand_kicad_uri, extract_symbols_tolerant, extract_top_level_symbols, kicad_parser_verdict, list_symbols,+ norm_path, write_atomic, LibCtx, LibEntry, LibTable, ADOM_FP_DIR_NAME, ADOM_FP_LIB_NAME, ADOM_LIB_FILE,+ ADOM_LIB_NAME,+};++pub const ADOM_FP_LIB_DESCRIPTION: &str = "Adom Bridge - custom footprints";+const DEFAULT_DESCRIPTION: &str = "Installed by Adom Bridge";+const NOT_INSTALLED_HINT: &str =+ "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.";+const PARSE_TIMEOUT: Duration = Duration::from_secs(60);++pub const MAX_ZIP_BYTES: u64 = 256 * 1024 * 1024;+pub const MAX_UNPACKED_BYTES: u64 = 512 * 1024 * 1024;++// ---------------------------------------------------------------------------+// Small helpers (std only)+// ---------------------------------------------------------------------------++static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);++/// A fresh, unique directory under the OS temp dir. Unique across threads of one+/// process (tests run in parallel) and across processes.+pub fn unique_temp_dir(prefix: &str) -> PathBuf {+ let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);+ let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);+ std::env::temp_dir().join(format!("{prefix}{}-{nanos}-{n}", std::process::id()))+}++/// Standard-alphabet base64, the way `base64.b64decode` reads it: whitespace is+/// ignored, `=` padding is optional, any other non-alphabet byte is an error.+pub fn b64_decode(s: &str) -> Result<Vec<u8>, String> {+ let mut out = Vec::with_capacity(s.len() * 3 / 4);+ let mut buf: u32 = 0;+ let mut bits: u32 = 0;+ let mut padded = false;+ for c in s.bytes() {+ let v = match c {+ b'A'..=b'Z' => c - b'A',+ b'a'..=b'z' => c - b'a' + 26,+ b'0'..=b'9' => c - b'0' + 52,+ b'+' => 62,+ b'/' => 63,+ b'=' => {+ padded = true;+ continue;+ }+ b' ' | b'\n' | b'\r' | b'\t' => continue,+ _ => return Err(format!("invalid base64 character {:?}", c as char)),+ };+ if padded {+ return Err("base64 data after padding".into());+ }+ buf = (buf << 6) | v as u32;+ bits += 6;+ if bits >= 8 {+ bits -= 8;+ out.push((buf >> bits) as u8);+ buf &= (1u32 << bits) - 1;+ }+ }+ Ok(out)+}++/// `shutil.copytree` after an `rmtree`: the destination is replaced wholesale.+pub fn copy_tree(src: &Path, dst: &Path) -> Result<(), String> {+ std::fs::create_dir_all(dst).map_err(|e| format!("cannot create {}: {e}", dst.display()))?;+ for entry in std::fs::read_dir(src).map_err(|e| format!("cannot read {}: {e}", src.display()))? {+ let entry = entry.map_err(|e| e.to_string())?;+ let from = entry.path();+ let to = dst.join(entry.file_name());+ if from.is_dir() {+ copy_tree(&from, &to)?;+ } else {+ std::fs::copy(&from, &to).map_err(|e| format!("cannot copy {} to {}: {e}", from.display(), to.display()))?;+ }+ }+ Ok(())+}++fn file_name(p: &Path) -> String {+ p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()+}++fn file_stem(p: &Path) -> String {+ p.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()+}++fn has_ext(p: &Path, ext: &str) -> bool {+ p.extension().map(|e| e.to_string_lossy().eq_ignore_ascii_case(ext)).unwrap_or(false)+}++/// Python's `repr(float)` for the values these generators produce: an integral+/// value prints with `.0`, anything else with Rust's shortest round-trip form,+/// which matches Python's for doubles.+fn py_float(x: f64) -> String {+ if x.fract() == 0.0 && x.abs() < 1e16 {+ format!("{x:.1}")+ } else {+ format!("{x}")+ }+}++fn round_to(x: f64, places: i32) -> f64 {+ let f = 10f64.powi(places);+ (x * f).round() / f+}++/// Sorted `*.kicad_mod` paths in a directory.+fn kicad_mods(dir: &Path) -> 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() && has_ext(p, "kicad_mod")).collect())+ .unwrap_or_default();+ v.sort();+ v+}++/// `(footprint "NAME"` -> NAME, the first occurrence anywhere in the text.+pub fn footprint_name_in(text: &str) -> Option<String> {+ let mut pos = 0;+ while let Some(i) = text[pos..].find("(footprint") {+ let start = pos + i + "(footprint".len();+ let rest = &text[start..];+ let trimmed = rest.trim_start();+ if trimmed.len() != rest.len() {+ if let Some(q) = trimmed.strip_prefix('"') {+ if let Some(end) = q.find('"') {+ if end > 0 {+ return Some(q[..end].to_string());+ }+ }+ }+ }+ pos = start;+ }+ None+}++/// Offset of the paren that closes the list opening at `open` (quote-aware), or+/// None when it never closes.+fn matching_close(text: &str, open: usize) -> Option<usize> {+ let mut depth: i32 = 0;+ let mut in_str = false;+ let mut esc = false;+ for (i, ch) in text[open..].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(open + i);+ }+ }+ _ => {}+ }+ }+ None+}++/// Every `(model "PATH"` occurrence as (offset of the opening quote, PATH).+fn model_refs(text: &str) -> Vec<(usize, String)> {+ let mut out = Vec::new();+ let mut pos = 0;+ while let Some(i) = text[pos..].find("(model") {+ let after = pos + i + "(model".len();+ let rest = &text[after..];+ let trimmed = rest.trim_start();+ if trimmed.len() != rest.len() {+ if let Some(q) = trimmed.strip_prefix('"') {+ let qpos = after + (rest.len() - trimmed.len());+ if let Some(end) = q.find('"') {+ out.push((qpos, q[..end].to_string()));+ }+ }+ }+ pos = after;+ }+ out+}++/// Python `_rewrite_model_refs`: every `(model "...")` whose BASENAME matches an+/// installed model file is pointed at the installed absolute path. Other refs+/// (stock `${KICAD*_3DMODEL_DIR}` ones) are left alone. Returns the count.+pub fn rewrite_model_refs(pretty_dir: &Path, installed_by_name: &BTreeMap<String, String>) -> usize {+ let mut rewritten = 0;+ for m in kicad_mods(pretty_dir) {+ let Ok(text) = std::fs::read_to_string(&m) else { continue };+ let mut new_text = String::with_capacity(text.len());+ let mut last = 0;+ for (qpos, reference) in model_refs(&text) {+ let base = reference.replace('\\', "/").rsplit('/').next().unwrap_or("").to_string();+ let Some(dest) = installed_by_name.get(&base) else { continue };+ rewritten += 1;+ new_text.push_str(&text[last..qpos]);+ new_text.push('"');+ new_text.push_str(dest);+ new_text.push('"');+ last = qpos + 1 + reference.len() + 1;+ }+ new_text.push_str(&text[last..]);+ if new_text != text {+ let _ = std::fs::write(&m, new_text);+ }+ }+ rewritten+}++/// Python `_add_model_to_footprint`: drop any existing `(model ...)` block and+/// insert a fresh reference before the footprint's closing paren.+pub fn add_model_to_footprint(footprint_path: &Path, model_path: &str) -> Result<(), String> {+ let mut text = std::fs::read_to_string(footprint_path).map_err(|e| format!("cannot read {}: {e}", footprint_path.display()))?;+ // Remove existing model blocks (quote-aware, back to front so offsets hold).+ let mut opens: Vec<usize> = Vec::new();+ let mut pos = 0;+ while let Some(i) = text[pos..].find("(model") {+ let at = pos + i;+ let after = &text[at + "(model".len()..];+ if after.starts_with(|c: char| c.is_ascii_whitespace()) {+ opens.push(at);+ }+ pos = at + "(model".len();+ }+ for open in opens.into_iter().rev() {+ if let Some(close) = matching_close(&text, open) {+ let mut start = open;+ while start > 0 && text.as_bytes()[start - 1].is_ascii_whitespace() {+ start -= 1;+ }+ text.replace_range(start..close + 1, "");+ }+ }+ let block = format!(+ "\n (model \"{model_path}\"\n (offset (xyz 0 0 0))\n (scale (xyz 1 1 1))\n (rotate (xyz 0 0 0))\n )"+ );+ if let Some(idx) = text.rfind(')') {+ text = format!("{}{}\n{}", &text[..idx], block, &text[idx..]);+ }+ std::fs::write(footprint_path, text).map_err(|e| format!("cannot write {}: {e}", footprint_path.display()))+}++// ---------------------------------------------------------------------------+// Bootstrap of a never-launched KiCad's per-user config (lib-table self-heal)+// ---------------------------------------------------------------------------++/// Python `kicad_detect.ensure_win_user_config`, the lib-table half. For each of+/// sym-lib-table and fp-lib-table under `config_dir`: a missing table is seeded+/// from KiCad's own template (`<base>/share/kicad/template/<name>`), else written+/// empty; an EXISTING table gets every template row it lacks merged in (v0.9.84+/// self-heal: earlier bootstraps could leave a user with no stock libraries).+/// User rows are never touched. Returns `{"sym-lib-table": {seeded, merged},+/// "fp-lib-table": {...}}`; a table that cannot be read or written carries `error`.+pub fn heal_lib_tables(config_dir: &Path, base_dir: Option<&str>) -> Value {+ let mut out = Map::new();+ let _ = std::fs::create_dir_all(config_dir);+ let template = base_dir.map(|b| Path::new(b).join("share").join("kicad").join("template"));+ for (fname, root) in [("sym-lib-table", "sym_lib_table"), ("fp-lib-table", "fp_lib_table")] {+ let dest = config_dir.join(fname);+ let mut rep = json!({"seeded": false, "merged": 0});+ if !dest.exists() {+ match libraries::ensure_lib_table(&dest, root, base_dir) {+ Ok(()) => rep["seeded"] = json!(true),+ Err(e) => rep["error"] = json!(e),+ }+ out.insert(fname.into(), rep);+ continue;+ }+ let Some(src) = template.as_ref().map(|t| t.join(fname)).filter(|p| p.is_file()) else {+ out.insert(fname.into(), rep);+ continue;+ };+ let (mut user_t, tmpl_t) = match (LibTable::parse_file(&dest), LibTable::parse_file(&src)) {+ (Ok(u), Ok(t)) => (u, t),+ (Err(e), _) | (_, Err(e)) => {+ rep["error"] = json!(e);+ out.insert(fname.into(), rep);+ continue;+ }+ };+ let mut added = 0;+ for e in tmpl_t.entries {+ if !user_t.has_library(&e.name) {+ user_t.entries.push(e);+ added += 1;+ }+ }+ if added > 0 {+ match user_t.write_file(&dest) {+ Ok(()) => rep["merged"] = json!(added),+ Err(e) => rep["error"] = json!(e),+ }+ }+ out.insert(fname.into(), rep);+ }+ Value::Object(out)+}++/// The dirs KiCad creates on its first launch, created ahead of it so an install+/// works on a never-launched KiCad: `<config>/`, `<user>/symbols`,+/// `<user>/footprints`, then the lib tables seeded and healed. Only runs when the+/// config or user dir is missing, exactly like the Python's `if not config_dir or+/// not user_dir` bootstrap; returns the two dirs.+pub fn ensure_user_config(ctx: &LibCtx) -> Result<(PathBuf, PathBuf), String> {+ let (Some(config), Some(user)) = (ctx.config_dir.clone(), ctx.user_dir.clone()) else {+ return Err("KiCad config/user directory not found".into());+ };+ if config.is_dir() && user.is_dir() {+ return Ok((config, user));+ }+ std::fs::create_dir_all(&config).map_err(|e| format!("cannot create {}: {e}", config.display()))?;+ std::fs::create_dir_all(user.join("symbols")).map_err(|e| format!("cannot create {}: {e}", user.display()))?;+ std::fs::create_dir_all(user.join("footprints")).map_err(|e| format!("cannot create {}: {e}", user.display()))?;+ heal_lib_tables(&config, ctx.base_dir.as_deref());+ Ok((config, user))+}++// ---------------------------------------------------------------------------+// KiCad's verdict on library TEXT (handlers/kicad_parse_check.py)+// ---------------------------------------------------------------------------++/// Python `wrap_symbol_blocks`: symbol blocks as a minimal library.+pub fn wrap_symbol_blocks(blocks: &[&str]) -> String {+ format!("(kicad_symbol_lib (version 20231120) (generator \"adom-parse-check\")\n{}\n)\n", blocks.join("\n"))+}++/// Python `verdict_text`: the verdict for library text that has not been written+/// anywhere yet (a temp file that is deleted afterwards).+pub fn verdict_text(cli: Option<&Path>, lib_text: &str) -> 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"});+ };+ let dir = unique_temp_dir("adom-candidate-");+ let _ = std::fs::create_dir_all(&dir);+ let tmp = dir.join("candidate.kicad_sym");+ if let Err(e) = std::fs::write(&tmp, lib_text) {+ let _ = std::fs::remove_dir_all(&dir);+ return json!({"available": true, "ok": false, "symbols": [], "units": 0, "error": format!("cannot stage candidate: {e}")});+ }+ let v = kicad_parser_verdict(Some(cli), &tmp, PARSE_TIMEOUT);+ let _ = std::fs::remove_dir_all(&dir);+ v+}++/// Python `refused_symbols`: which of these (name, block) symbols KiCad refuses+/// on their own. `{available, accepted[], refused{name: error}}`.+pub fn refused_symbols(cli: Option<&Path>, blocks: &[(String, String)]) -> Value {+ if cli.filter(|c| c.is_file()).is_none() {+ return json!({"available": false, "accepted": [], "refused": {}});+ }+ let mut accepted = Vec::new();+ let mut refused = Map::new();+ for (name, blk) in blocks {+ let v = verdict_text(cli, &wrap_symbol_blocks(&[blk.as_str()]));+ if v["ok"].as_bool().unwrap_or(false) {+ accepted.push(name.clone());+ } else {+ let err = v["error"].as_str().filter(|s| !s.is_empty()).unwrap_or("refused");+ refused.insert(name.clone(), json!(err));+ }+ }+ json!({"available": true, "accepted": accepted, "refused": refused})+}++// ---------------------------------------------------------------------------+// The library's direct children in order (adom_library._parse_library_nodes)+// ---------------------------------------------------------------------------++/// One direct child of `(kicad_symbol_lib ...)`: a symbol (name set) or an+/// attribute such as `(version ...)` or `(embedded_fonts no)`.+#[derive(Clone, Debug)]+struct LibNode {+ name: Option<String>,+ block: String,+}++impl LibNode {+ fn is_symbol(&self) -> bool {+ self.name.is_some()+ }+}++/// Copied from `crate::libraries::quote_aware_children` (private there): the+/// spans of the root's direct child lists. Errors on any imbalance.+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))+}++/// Copied from `crate::libraries::symbol_block_name` (private there).+fn symbol_block_name(block: &str) -> Option<String> {+ let t = block.trim_start().strip_prefix('(')?.trim_start();+ let t = t.strip_prefix("symbol")?;+ let t2 = t.trim_start();+ if t2.len() == t.len() {+ return None;+ }+ let t = t2.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+}++/// Python `_parse_library_nodes`: the library's direct children in order.+/// A non-library root or trailing text after the closer is an error.+fn parse_library_nodes(text: &str) -> Result<Vec<LibNode>, 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:?}"));+ }+ Ok(children.into_iter().map(|(a, b)| LibNode { name: symbol_block_name(&text[a..b]), block: text[a..b].to_string() }).collect())+}++fn attr_head(block: &str) -> &str {+ block.trim_start_matches(['(', ' ', '\t']).split_whitespace().next().unwrap_or("")+}++// ---------------------------------------------------------------------------+// add_symbol (adom_library.add_symbol)+// ---------------------------------------------------------------------------++/// Merge the symbol(s) of a whole `.kicad_sym` text into the Adom library that+/// KiCad reads (row first). Existing symbols of the same name are replaced in+/// place; new ones go after the last symbol (or after the header attrs of an+/// empty library). Two gates before any write: the rebuilt text must parse back+/// with at least as many nodes, and KiCad's own parser must accept first the+/// incoming symbols alone and then the merged library (no kicad-cli: no verdict,+/// said out loud). One-generation `Adom.kicad_sym.bak` beside the library.+///+/// Returns `{success, added[], lib_path, kicadParser}` or `{success:false, error,+/// errorCode?, kicadParser?, _hint?}`.+pub fn add_symbol(ctx: &LibCtx, symbol_text: &str) -> Value {+ let ensure = libraries::ensure_adom_library(ctx, true);+ if let Some(err) = ensure.get("error").and_then(Value::as_str) {+ return json!({"success": false, "error": err});+ }+ let Some((_table, lib_path)) = libraries::adom_paths(ctx) else {+ return json!({"success": false, "error": "KiCad paths not found"});+ };++ let incoming = match extract_top_level_symbols(symbol_text) {+ Ok(v) => v,+ Err(e) => return json!({"success": false, "error": format!("The provided .kicad_sym is not a valid symbol library: {e}")}),+ };+ if incoming.is_empty() {+ return json!({"success": false, "error": "No symbols found in the provided file content"});+ }++ let existing_text = match std::fs::read_to_string(&lib_path) {+ Ok(t) => t,+ Err(e) => return json!({"success": false, "error": format!("cannot read {}: {e}", norm_path(&lib_path))}),+ };+ let nodes = match parse_library_nodes(&existing_text) {+ Ok(n) => n,+ Err(e) => {+ return json!({"success": false,+ "error": format!("REFUSING to merge: the existing library at {} is corrupt ({e}). Repair or restore it first - merging into a broken file can only spread the damage.", norm_path(&lib_path)),+ "_hint": "kicad_lint_library {libraryPath} now reports structural balance; a .bak written by a previous merge sits beside the library if one exists."})+ }+ };++ let incoming_by_name: BTreeMap<&str, &str> = incoming.iter().map(|(n, b)| (n.as_str(), b.as_str())).collect();+ let added_names: Vec<String> = incoming.iter().map(|(n, _)| n.clone()).collect();++ let mut out_nodes: Vec<LibNode> = Vec::with_capacity(nodes.len() + incoming.len());+ let mut replaced: Vec<&str> = Vec::new();+ let mut last_symbol_idx: Option<usize> = None;+ for node in &nodes {+ match node.name.as_deref().and_then(|n| incoming_by_name.get(n).map(|b| (n, *b))) {+ Some((n, blk)) => {+ out_nodes.push(LibNode { name: Some(n.to_string()), block: blk.to_string() });+ replaced.push(n);+ }+ None => out_nodes.push(node.clone()),+ }+ if node.is_symbol() {+ last_symbol_idx = Some(out_nodes.len() - 1);+ }+ }+ let new_syms: Vec<LibNode> = incoming+ .iter()+ .filter(|(n, _)| !replaced.iter().any(|r| r == n))+ .map(|(n, b)| LibNode { name: Some(n.clone()), block: b.clone() })+ .collect();+ let insert_at = match last_symbol_idx {+ Some(i) => i + 1,+ None => {+ let mut at = 0;+ for n in &out_nodes {+ if !n.is_symbol() && matches!(attr_head(&n.block), "version" | "generator" | "generator_version") {+ at += 1;+ } else {+ break;+ }+ }+ at+ }+ };+ let tail = out_nodes.split_off(insert_at);+ out_nodes.extend(new_syms);+ out_nodes.extend(tail);++ let mut new_text = String::from("(kicad_symbol_lib\n");+ for n in &out_nodes {+ new_text.push('\t');+ new_text.push_str(n.block.trim());+ new_text.push('\n');+ }+ new_text.push_str(")\n");++ // THE GATE (#54): never write a library that does not parse back clean.+ match parse_library_nodes(&new_text) {+ Err(e) => {+ return json!({"success": false,+ "error": format!("internal merge error: the rebuilt library failed its own balance check ({e}); the library on disk was NOT touched. This is a bridge bug - report it with the input file.")})+ }+ Ok(rebuilt) if rebuilt.len() < nodes.len() => {+ return json!({"success": false,+ "error": format!("internal merge error: the rebuilt library lost nodes ({} < {}); the library on disk was NOT touched. This is a bridge bug - report it.", rebuilt.len(), nodes.len())})+ }+ Ok(_) => {}+ }++ // THE KICAD GATE (wiki #71): the incoming symbols alone, then the merge.+ let cli = ctx.kicad_cli.as_deref();+ let incoming_blocks: Vec<&str> = incoming.iter().map(|(_, b)| b.as_str()).collect();+ let incoming_v = verdict_text(cli, &wrap_symbol_blocks(&incoming_blocks));+ if incoming_v["available"].as_bool().unwrap_or(false) && !incoming_v["ok"].as_bool().unwrap_or(false) {+ return json!({"success": false, "errorCode": "kicad_refuses_symbol", "kicadParser": incoming_v,+ "error": format!("KiCad's own parser refuses the uploaded symbol(s) {:?}: {}. The library on disk was NOT touched, because one refused symbol makes KiCad drop the whole library.", added_names, incoming_v["error"].as_str().unwrap_or("")),+ "_hint": "Fix the .kicad_sym so `kicad-cli sym export svg` accepts it (common: bare `hide yes` inside (effects ...) instead of `(hide yes)`, or a pre-20231120 shape). kicad_lint_library reports KiCad's verdict as kicadParser."});+ }+ let merged_v = verdict_text(cli, &new_text);+ if merged_v["available"].as_bool().unwrap_or(false) && !merged_v["ok"].as_bool().unwrap_or(false) {+ return json!({"success": false, "errorCode": "kicad_refuses_merged_library", "kicadParser": merged_v,+ "error": format!("KiCad's parser accepts the upload alone but refuses the merged library: {}. Nothing was written. The existing library is probably already refused by KiCad; run kicad_adom_library_status (kicadParser) and kicad_adom_library_heal mode:prune to drop the culprit.", merged_v["error"].as_str().unwrap_or(""))});+ }++ // One-generation backup beside the library, then the replace.+ let _ = std::fs::write(lib_path.with_extension("kicad_sym.bak"), &existing_text);+ if let Err(e) = write_atomic(&lib_path, new_text.as_bytes()) {+ return json!({"success": false, "error": e});+ }++ let parser = if merged_v["available"].as_bool().unwrap_or(false) {+ json!({"available": true, "ok": true, "symbols": merged_v["symbols"], "units": merged_v["units"]})+ } else {+ json!({"available": false, "ok": null, "note": "kicad-cli not found; KiCad's verdict was not obtained"})+ };+ json!({"success": true, "added": added_names, "lib_path": norm_path(&lib_path), "kicadParser": parser})+}++// ---------------------------------------------------------------------------+// kicad_install_library (handlers/install_library.py)+// ---------------------------------------------------------------------------++fn s_arg(args: &Value, key: &str) -> String {+ args.get(key).and_then(Value::as_str).unwrap_or("").to_string()+}++/// Python `_single_part_hint`: a one-part (or file-named) `.kicad_sym` handed to+/// install_library usually meant install_symbol (wiki #72). Say so, do not refuse.+pub fn single_part_hint(source: &Path, lib_name: &str) -> Option<Value> {+ if source.is_dir() || !has_ext(source, "kicad_sym") {+ return None;+ }+ let names = list_symbols(source);+ if names.is_empty() || names.len() > 3 {+ return None;+ }+ if names.len() == 1 || names.iter().any(|n| n == lib_name) {+ let shown = names.join(", ");+ return Some(json!({+ "singlePartFile": true,+ "symbolNames": names,+ "_hint": format!(+ "NOTE: this file holds {} symbol(s) ({shown}), so KiCad now has a whole library named '{lib_name}' containing only that part, and the shared Adom library was NOT touched. If you meant 'put this part into the Adom library', the verb is kicad_install_symbol '{{\"filePath\":\"{}\"}}' (then kicad_show_library Adom). Both are valid; only one matches what a shared-library caller usually means.",+ names.len(), norm_path(source)),+ }));+ }+ None+}++fn merge_into(target: &mut Value, extra: &Value) {+ if let (Some(dst), Some(src)) = (target.as_object_mut(), extra.as_object()) {+ for (k, v) in src {+ dst.insert(k.clone(), v.clone());+ }+ }+}++/// `kicad_install_library {libraryPath, libraryType?: symbol|footprint, libraryName?,+/// description?, model3dPath?, modelsPath?}`.+pub fn install_library(ctx: &LibCtx, installed: bool, args: &Value) -> Value {+ if !installed {+ return json!({"success": false, "error": "KiCad not installed", "_hint": NOT_INSTALLED_HINT});+ }+ let lib_path = s_arg(args, "libraryPath");+ let lib_type = args.get("libraryType").and_then(Value::as_str).unwrap_or("symbol").to_string();+ if lib_path.is_empty() {+ return json!({"success": false, "error": "No libraryPath specified"});+ }+ if lib_type == "footprint" {+ return install_footprint_library(ctx, args);+ }++ let mut lib_name = s_arg(args, "libraryName");+ let description = args.get("description").and_then(Value::as_str).unwrap_or(DEFAULT_DESCRIPTION).to_string();+ let source = PathBuf::from(&lib_path);+ if !source.exists() {+ return json!({"success": false, "error": format!("Library source not found: {lib_path}"),+ "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path."});+ }+ if lib_name.is_empty() {+ lib_name = file_stem(&source);+ }+ let wrong_tool = single_part_hint(&source, &lib_name);++ // wiki #71: ask KiCad's own parser before registering a .kicad_sym.+ if source.is_file() && has_ext(&source, "kicad_sym") {+ let v = kicad_parser_verdict(ctx.kicad_cli.as_deref(), &source, PARSE_TIMEOUT);+ if v["available"].as_bool().unwrap_or(false) && !v["ok"].as_bool().unwrap_or(false) {+ return json!({"success": false, "errorCode": "kicad_refuses_library", "kicadParser": v.clone(),+ "error": format!("KiCad's own parser refuses {}: {}. Not copied, not registered: KiCad would list this library empty.", file_name(&source), v["error"].as_str().unwrap_or("")),+ "_hint": "Fix the file until `kicad-cli sym export svg` accepts it; kicad_lint_library shows the same verdict as kicadParser."});+ }+ }+ if lib_type != "symbol" {+ return json!({"success": false, "error": format!("Unknown library type: {lib_type}. Use 'symbol' or 'footprint'")});+ }++ let (config_dir, user_dir) = match ensure_user_config(ctx) {+ Ok(d) => d,+ Err(_) => {+ return json!({"success": false, "error": "KiCad config directory not found and could not be bootstrapped",+ "_hint": "Run kicad_list_versions to confirm KiCad is installed."})+ }+ };++ let dest_dir = user_dir.join("symbols");+ if let Err(e) = std::fs::create_dir_all(&dest_dir) {+ return json!({"success": false, "error": format!("cannot create {}: {e}", norm_path(&dest_dir))});+ }+ let dest = dest_dir.join(file_name(&source));+ let copied = if source.is_dir() {+ if dest.exists() {+ let _ = std::fs::remove_dir_all(&dest);+ }+ copy_tree(&source, &dest)+ } else {+ std::fs::copy(&source, &dest).map(|_| ()).map_err(|e| format!("cannot copy to {}: {e}", norm_path(&dest)))+ };+ if let Err(e) = copied {+ return json!({"success": false, "error": e});+ }++ let table_path = config_dir.join("sym-lib-table");+ let mut table = match LibTable::parse_file(&table_path) {+ Ok(t) => t,+ Err(e) => return json!({"success": false, "error": format!("Failed to parse sym-lib-table: {e}")}),+ };+ let uri = norm_path(&dest);+ if table.has_library(&lib_name) {+ // Idempotent: an already-registered library is SUCCESS (the file was refreshed above).+ let mut out = json!({"success": true, "alreadyRegistered": true,+ "output": format!("Library '{lib_name}' already registered in sym-lib-table (file refreshed at {uri})."),+ "_hint": format!("Library was already present: nothing to change. This is a successful no-op.{}",+ wrong_tool.as_ref().and_then(|w| w["_hint"].as_str()).map(|h| format!(" {h}")).unwrap_or_default())});+ if let Some(w) = &wrong_tool {+ out["singlePartFile"] = w["singlePartFile"].clone();+ out["symbolNames"] = w["symbolNames"].clone();+ }+ return out;+ }+ table.add_library(LibEntry::new(&lib_name, "KiCad", &uri, "", &description));+ if let Err(e) = table.write_file(&table_path) {+ return json!({"success": false, "error": format!("cannot write sym-lib-table: {e}")});+ }+ let mut out = json!({+ "success": true,+ "output": format!("Library '{lib_name}' installed to {uri} and registered in sym-lib-table"),+ "data": {"name": lib_name, "path": uri, "table": "sym-lib-table"},+ "_hint": format!("Library '{lib_name}' is now registered. For next-time confidence, run `kicad_lint_library '{{\"libraryPath\":\"<path>\"}}'` BEFORE install to catch malformed files, outdated formats, or wrong-filetype-renamed-with-.kicad_sym surprises in ~100ms, much faster than discovering a broken library inside the GUI later. Any KiCad windows already open need to be closed + reopened to pick up the new library entry (sym-lib-table is read at startup).{}",+ wrong_tool.as_ref().and_then(|w| w["_hint"].as_str()).map(|h| format!(" {h}")).unwrap_or_default()),+ });+ if let Some(w) = &wrong_tool {+ out["singlePartFile"] = w["singlePartFile"].clone();+ out["symbolNames"] = w["symbolNames"].clone();+ }+ out+}++/// Python `_install_footprint`: a `.kicad_mod` (wrapped in `<name>.pretty`) or a+/// whole `.pretty` dir into `<user>/footprints`, optional 3D models (one file via+/// `model3dPath`, or a directory via `modelsPath` / the bundle's `3dmodels/`+/// sibling, with the COPIED footprints' matching refs rewritten), registered in+/// fp-lib-table.+pub fn install_footprint_library(ctx: &LibCtx, args: &Value) -> Value {+ let lib_path = s_arg(args, "libraryPath");+ let mut lib_name = s_arg(args, "libraryName");+ let description = args.get("description").and_then(Value::as_str).unwrap_or(DEFAULT_DESCRIPTION).to_string();+ let model_3d_path = s_arg(args, "model3dPath");++ let source = PathBuf::from(&lib_path);+ if !source.exists() {+ return json!({"success": false, "error": format!("Footprint source not found: {lib_path}"),+ "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path."});+ }+ let (config_dir, user_dir) = match ensure_user_config(ctx) {+ Ok(d) => d,+ Err(_) => {+ return json!({"success": false, "error": "KiCad config/user directory not found and could not be bootstrapped",+ "_hint": "Is KiCad installed? Run kicad_list_versions. If it's freshly installed and never launched, this bootstrap should have created %APPDATA%/kicad/<ver>/."})+ }+ };++ let footprints_dir = user_dir.join("footprints");+ if let Err(e) = std::fs::create_dir_all(&footprints_dir) {+ return json!({"success": false, "error": format!("cannot create {}: {e}", norm_path(&footprints_dir))});+ }++ let pretty_dir: PathBuf;+ let mut dest_file: Option<PathBuf> = None;+ if source.is_dir() && has_ext(&source, "pretty") {+ if lib_name.is_empty() {+ lib_name = file_stem(&source);+ }+ pretty_dir = footprints_dir.join(file_name(&source));+ if pretty_dir.exists() {+ let _ = std::fs::remove_dir_all(&pretty_dir);+ }+ if let Err(e) = copy_tree(&source, &pretty_dir) {+ return json!({"success": false, "error": e});+ }+ } else if source.is_file() && has_ext(&source, "kicad_mod") {+ if lib_name.is_empty() {+ lib_name = file_stem(&source);+ }+ pretty_dir = footprints_dir.join(format!("{lib_name}.pretty"));+ if let Err(e) = std::fs::create_dir_all(&pretty_dir) {+ return json!({"success": false, "error": format!("cannot create {}: {e}", norm_path(&pretty_dir))});+ }+ // KiCad requires the file name to match the footprint name inside it.+ let fp_text = std::fs::read_to_string(&source).unwrap_or_default();+ let correct_name = footprint_name_in(&fp_text).map(|n| format!("{n}.kicad_mod")).unwrap_or_else(|| file_name(&source));+ let dest = pretty_dir.join(&correct_name);+ if let Err(e) = std::fs::copy(&source, &dest) {+ return json!({"success": false, "error": format!("cannot copy to {}: {e}", norm_path(&dest))});+ }+ dest_file = Some(dest);+ } else {+ return json!({"success": false,+ "error": format!("Invalid footprint source: expected .kicad_mod file or .pretty directory, got {}", file_name(&source))});+ }++ // Single 3D model (model3dPath).+ let mut model_installed_path = String::new();+ if !model_3d_path.is_empty() {+ let model_source = PathBuf::from(&model_3d_path);+ if model_source.exists() {+ let models_dir = user_dir.join("3dmodels").join(format!("{lib_name}.3dshapes"));+ let _ = std::fs::create_dir_all(&models_dir);+ let model_dest = models_dir.join(file_name(&model_source));+ if std::fs::copy(&model_source, &model_dest).is_ok() {+ model_installed_path = norm_path(&model_dest);+ if let Some(dest) = &dest_file {+ let _ = add_model_to_footprint(dest, &model_installed_path);+ }+ }+ }+ }++ // Bundle models: modelsPath, or the 3dmodels/ sibling of a source .pretty.+ let mut models_installed = 0usize;+ let mut refs_rewritten = 0usize;+ let mut models_src = s_arg(args, "modelsPath");+ if models_src.is_empty() && source.is_dir() && has_ext(&source, "pretty") {+ if let Some(sibling) = source.parent().map(|p| p.join("3dmodels")).filter(|p| p.is_dir()) {+ models_src = norm_path(&sibling);+ }+ }+ if !models_src.is_empty() {+ let models_src_dir = PathBuf::from(&models_src);+ if models_src_dir.is_dir() {+ let models_dir = user_dir.join("3dmodels").join(format!("{lib_name}.3dshapes"));+ let _ = std::fs::create_dir_all(&models_dir);+ let mut files: Vec<PathBuf> =+ std::fs::read_dir(&models_src_dir).map(|rd| rd.flatten().map(|e| e.path()).collect()).unwrap_or_default();+ files.sort();+ let mut installed_by_name = BTreeMap::new();+ for mf in files {+ if !mf.is_file() {+ continue;+ }+ let dest_model = models_dir.join(file_name(&mf));+ if std::fs::copy(&mf, &dest_model).is_ok() {+ installed_by_name.insert(file_name(&mf), norm_path(&dest_model));+ models_installed += 1;+ }+ }+ if !installed_by_name.is_empty() {+ refs_rewritten = rewrite_model_refs(&pretty_dir, &installed_by_name);+ }+ }+ }++ // Register in fp-lib-table.+ let table_path = config_dir.join("fp-lib-table");+ let mut table = match LibTable::parse_file(&table_path) {+ Ok(t) => t,+ Err(e) => return json!({"success": false, "error": format!("Failed to parse fp-lib-table: {e}")}),+ };+ let uri = norm_path(&pretty_dir);+ let registered = if table.has_library(&lib_name) {+ false+ } else {+ table.add_library(LibEntry::new(&lib_name, "KiCad", &uri, "", &description));+ if let Err(e) = table.write_file(&table_path) {+ return json!({"success": false, "error": format!("cannot write fp-lib-table: {e}")});+ }+ true+ };++ let mut output = format!(+ "Footprint library '{lib_name}' installed to {uri}{}",+ if registered { " and registered in fp-lib-table" } else { " (already registered)" }+ );+ let mut data = json!({"name": lib_name, "path": uri, "table": "fp-lib-table"});+ if !model_installed_path.is_empty() {+ data["model3dPath"] = json!(model_installed_path);+ output.push_str(&format!(". 3D model installed to {model_installed_path}"));+ }+ if models_installed > 0 {+ data["modelsInstalled"] = json!(models_installed);+ data["modelRefsRewritten"] = json!(refs_rewritten);+ output.push_str(&format!(+ ". {models_installed} 3D model(s) installed to 3dmodels/{lib_name}.3dshapes; {refs_rewritten} footprint model ref(s) rewritten to the installed paths"+ ));+ }+ json!({+ "success": true,+ "output": output,+ "data": data,+ "_hint": format!("Footprint library '{lib_name}' is now in fp-lib-table. For next-time confidence, run `kicad_lint_library '{{\"libraryPath\":\"<path-to-.pretty-dir>\"}}'` BEFORE install to validate each .kicad_mod inside in ~100ms (catches malformed files, outdated formats). Any open KiCad windows need to be closed + reopened to pick up the new fp-lib-table entry."),+ })+}++// ---------------------------------------------------------------------------+// kicad_install_symbol (handlers/install_symbol.py)+// ---------------------------------------------------------------------------++/// The text of an upload: `filePath` on this machine, else base64 `fileContent`.+fn read_upload(args: &Value, what: &str) -> Result<(String, String), Value> {+ let file_name_arg = s_arg(args, "fileName");+ let file_content = s_arg(args, "fileContent");+ let file_path = s_arg(args, "filePath").trim().to_string();+ if file_path.is_empty() && file_name_arg.is_empty() {+ return Err(json!({"success": false, "error": "No fileName specified"}));+ }+ if file_path.is_empty() && file_content.is_empty() {+ return Err(json!({"success": false, "error": "No fileContent specified (or pass filePath)"}));+ }+ if !file_path.is_empty() {+ let p = PathBuf::from(&file_path);+ if !p.exists() {+ return Err(json!({"success": false, "error": format!("filePath not found: {file_path}"),+ "_hint": "Absolute path on THIS machine: push it first (ab send_files) if it lives elsewhere."}));+ }+ let text = std::fs::read_to_string(&p).map_err(|e| json!({"success": false, "error": format!("Failed to read filePath: {e}")}))?;+ let name = if file_name_arg.is_empty() { file_name(&p) } else { file_name_arg };+ return Ok((text, name));+ }+ let bytes = b64_decode(&file_content).map_err(|e| json!({"success": false, "error": format!("Failed to decode fileContent: {e}")}))?;+ let text = String::from_utf8(bytes).map_err(|e| json!({"success": false, "error": format!("Failed to decode fileContent: {what} is not UTF-8: {e}")}))?;+ Ok((text, file_name_arg))+}++/// `kicad_install_symbol {fileName, fileContent | filePath, symbolName?, openEditor?,+/// quietInstall?, libraryName?}`: merge into the Adom library KiCad reads.+/// The editor step is window-phase and not attempted; `editorOpened` is false.+pub fn install_symbol(ctx: &LibCtx, installed: bool, args: &Value) -> Value {+ if !installed {+ return json!({"success": false, "error": "KiCad not installed", "_hint": NOT_INSTALLED_HINT});+ }+ let quiet = args.get("quietInstall").and_then(Value::as_bool).unwrap_or(false);+ let open_editor = !quiet && args.get("openEditor").map(|v| !matches!(v, Value::Bool(false) | Value::Null)).unwrap_or(true);+ let (symbol_text, _name) = match read_upload(args, "symbol") {+ Ok(t) => t,+ Err(e) => return e,+ };++ let result = add_symbol(ctx, &symbol_text);+ if !result["success"].as_bool().unwrap_or(false) {+ let mut out = json!({"success": false, "error": result["error"].as_str().unwrap_or("Unknown error")});+ for k in ["errorCode", "kicadParser", "_hint"] {+ if let Some(v) = result.get(k).filter(|v| !v.is_null()) {+ out[k] = v.clone();+ }+ }+ return out;+ }+ let added: Vec<String> = result["added"].as_array().map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default();+ let lib_path = result["lib_path"].as_str().unwrap_or("").to_string();++ let mut output_parts = vec![format!("Symbol(s) {} added to '{ADOM_LIB_NAME}' library", added.join(", "))];+ if open_editor {+ output_parts.push("Symbol Editor: not opened by this build (window control arrives in phase 3; call kicad_show_symbol)".into());+ }+ let requested_lib = s_arg(args, "libraryName").trim().to_string();+ let ignored_lib = !requested_lib.is_empty() && requested_lib != ADOM_LIB_NAME;+ if ignored_lib {+ output_parts.push(format!(+ "NOTE: libraryName '{requested_lib}' was ignored - every Adom symbol installs into the single global '{ADOM_LIB_NAME}' library"+ ));+ }+ let mut out = json!({+ "success": true,+ "output": output_parts.join(" | "),+ "kicadParser": result["kicadParser"],+ "installedPath": lib_path,+ "symbolNames": added,+ "libraryName": ADOM_LIB_NAME,+ "editorOpened": false,+ });+ if ignored_lib {+ out["ignoredArgs"] = json!({"libraryName": requested_lib});+ out["_hint"] = json!(format!(+ "libraryName is not honoured by this verb: all Adom symbols live in '{ADOM_LIB_NAME}'. Drop the argument, or use kicad_install_library if you genuinely need a separate library file."+ ));+ }+ out+}++// ---------------------------------------------------------------------------+// kicad_install_footprint (handlers/install_footprint.py)+// ---------------------------------------------------------------------------++/// Replace the first `(model "...")` path, or append a fresh model block before+/// the footprint's closing paren (Python's inline logic, byte for byte).+fn wire_model_into_footprint(footprint_text: &str, model_uri: &str) -> String {+ if footprint_text.contains("(model ") {+ if let Some((qpos, old)) = model_refs(footprint_text).into_iter().next() {+ let mut t = String::with_capacity(footprint_text.len() + model_uri.len());+ // Rewrite the head `(model "old"` as `(model "new"`.+ let head_start = footprint_text[..qpos].rfind("(model").unwrap_or(qpos);+ t.push_str(&footprint_text[..head_start]);+ t.push_str(&format!("(model \"{model_uri}\""));+ t.push_str(&footprint_text[qpos + 1 + old.len() + 1..]);+ return t;+ }+ }+ let body = footprint_text.trim_end();+ let close = body.rfind(')').unwrap_or(body.len());+ format!(+ "{} (model \"{model_uri}\"\n (offset (xyz 0 0 0))\n (scale (xyz 1 1 1))\n (rotate (xyz 0 0 0))\n )\n)\n",+ &body[..close]+ )+}++/// The Adom.pretty KiCad reads: ROW FIRST (wiki #71). If fp-lib-table already+/// registers an Adom row with a literal uri, that directory; else+/// `<user>/footprints/Adom.pretty`. A uri with `${...}` is not expanded here+/// (the Python did not either).+pub fn adom_pretty_dir(config_dir: &Path, user_dir: &Path) -> PathBuf {+ let default = user_dir.join("footprints").join(ADOM_FP_DIR_NAME);+ if let Ok(t) = LibTable::parse_file(&config_dir.join("fp-lib-table")) {+ if let Some(row) = t.get(ADOM_FP_LIB_NAME) {+ if !row.uri.is_empty() && !row.uri.contains("${") {+ return PathBuf::from(row.uri.replace('\\', "/"));+ }+ }+ }+ default+}++/// `kicad_install_footprint {fileName, fileContent | filePath, footprintName?,+/// openEditor?, quietInstall?, model?: {fileName, fileContent} | {filePath}}`.+/// The editor step and the stale-editor check are window-phase and not attempted.+pub fn install_footprint(ctx: &LibCtx, installed: bool, args: &Value) -> Value {+ if !installed {+ return json!({"success": false, "error": "KiCad not installed", "_hint": NOT_INSTALLED_HINT});+ }+ let quiet = args.get("quietInstall").and_then(Value::as_bool).unwrap_or(false);+ let open_editor = !quiet && args.get("openEditor").map(|v| !matches!(v, Value::Bool(false) | Value::Null)).unwrap_or(true);+ let (mut footprint_text, file_name_arg) = match read_upload(args, "footprint") {+ Ok(t) => t,+ Err(e) => return e,+ };+ let mut footprint_name = s_arg(args, "footprintName");+ if footprint_name.is_empty() {+ footprint_name = footprint_name_in(&footprint_text).unwrap_or_else(|| file_stem(Path::new(&file_name_arg)));+ }++ let (config_dir, user_dir) = match ensure_user_config(ctx) {+ Ok(d) => d,+ Err(_) => return json!({"success": false, "error": "KiCad user/config directory not found (bootstrap failed)"}),+ };+ let fp_dir = user_dir.join("footprints");+ let _ = std::fs::create_dir_all(&fp_dir);+ let pretty_dir = adom_pretty_dir(&config_dir, &user_dir);+ if let Err(e) = std::fs::create_dir_all(&pretty_dir) {+ return json!({"success": false, "error": format!("cannot create {}: {e}", norm_path(&pretty_dir))});+ }++ // Optional 3D model: install + wire into the footprint.+ let model = args.get("model").cloned().unwrap_or(Value::Null);+ let mut model_installed = String::new();+ let model_src_path = s_arg(&model, "filePath").trim().to_string();+ let has_inline = !s_arg(&model, "fileName").is_empty() && !s_arg(&model, "fileContent").is_empty();+ if !model_src_path.is_empty() || has_inline {+ let (model_bytes, model_file_name) = if !model_src_path.is_empty() {+ let mp = PathBuf::from(&model_src_path);+ if !mp.exists() {+ return json!({"success": false, "error": format!("model.filePath not found: {model_src_path}"),+ "_hint": "Absolute path on THIS machine: push it first (ab send_files)."});+ }+ match std::fs::read(&mp) {+ Ok(b) => (b, file_name(&mp)),+ Err(e) => return json!({"success": false, "error": format!("Failed to read model.filePath: {e}")}),+ }+ } else {+ match b64_decode(&s_arg(&model, "fileContent")) {+ Ok(b) => (b, s_arg(&model, "fileName")),+ Err(e) => return json!({"success": false, "error": format!("Failed to decode model.fileContent: {e}")}),+ }+ };+ let models_dir = user_dir.join("3dmodels").join("Adom");+ let _ = std::fs::create_dir_all(&models_dir);+ let model_path = models_dir.join(file_name(Path::new(&model_file_name)));+ if let Err(e) = std::fs::write(&model_path, &model_bytes) {+ return json!({"success": false, "error": format!("cannot write {}: {e}", norm_path(&model_path))});+ }+ model_installed = norm_path(&model_path);+ footprint_text = wire_model_into_footprint(&footprint_text, &model_installed);+ }++ // The file must be named after the footprint inside it.+ let dest_file = pretty_dir.join(format!("{footprint_name}.kicad_mod"));+ let existed_before = dest_file.exists();+ // Identical content: leave the file alone (an mtime bump past KiCad's cache+ // load makes the opener report a stale cache on every tour after the first).+ let unchanged = existed_before && std::fs::read_to_string(&dest_file).map(|t| t == footprint_text).unwrap_or(false);+ if !unchanged {+ if let Err(e) = std::fs::write(&dest_file, &footprint_text) {+ return json!({"success": false, "error": format!("cannot write {}: {e}", norm_path(&dest_file))});+ }+ }++ // Register Adom in fp-lib-table.+ let table_path = config_dir.join("fp-lib-table");+ let mut table = match LibTable::parse_file(&table_path) {+ Ok(t) => t,+ Err(e) => return json!({"success": false, "error": format!("Failed to parse fp-lib-table: {e}")}),+ };+ let mut registered = false;+ if !table.has_library(ADOM_FP_LIB_NAME) {+ table.add_library(LibEntry::new(ADOM_FP_LIB_NAME, "KiCad", &norm_path(&pretty_dir), "", ADOM_FP_LIB_DESCRIPTION));+ if let Err(e) = table.write_file(&table_path) {+ return json!({"success": false, "error": format!("cannot write fp-lib-table: {e}")});+ }+ registered = true;+ }++ let mut output_parts = vec![format!("Footprint '{footprint_name}' installed to '{ADOM_FP_LIB_NAME}' library")];+ if registered {+ output_parts.push("registered in fp-lib-table".into());+ }+ if !model_installed.is_empty() {+ output_parts.push(format!("3D model installed + referenced: {model_installed}"));+ }+ if open_editor {+ output_parts.push("Footprint Editor: not opened by this build (window control arrives in phase 3; call kicad_show_footprint)".into());+ }+ json!({+ "success": true,+ "output": output_parts.join(" | "),+ "installedPath": norm_path(&dest_file),+ "existedBefore": existed_before,+ "unchanged": unchanged,+ "footprintName": footprint_name,+ "libraryName": ADOM_FP_LIB_NAME,+ "editorOpened": false,+ "model3d": if model_installed.is_empty() { Value::Null } else { json!(model_installed) },+ })+}++// ---------------------------------------------------------------------------+// kicad_install_library_bundle (handlers/install_library_bundle.py)+// ---------------------------------------------------------------------------++fn name_ok(name: &str) -> bool {+ // ^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$+ let mut chars = name.chars();+ let Some(first) = chars.next() else { return false };+ if !first.is_ascii_alphanumeric() {+ return false;+ }+ let rest: Vec<char> = chars.collect();+ rest.len() <= 63 && rest.iter().all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '_' | '-'))+}++/// Python `_safe_extract`: unpack with a zip-slip guard and an unpacked-size cap.+/// Returns the number of files extracted.+pub fn safe_extract(zip_bytes: &[u8], dest: &Path) -> Result<usize, String> {+ let zf = crate::pcm::ZipFile::parse(zip_bytes)?;+ let mut total: u64 = 0;+ let mut count = 0usize;+ for e in &zf.entries {+ let name = e.name.as_str();+ if name.is_empty() || name.ends_with('/') {+ continue;+ }+ let norm = name.replace('\\', "/");+ let drive = norm.len() >= 2 && norm.as_bytes()[0].is_ascii_alphabetic() && norm.as_bytes()[1] == b':';+ if norm.starts_with('/') || drive || norm.split('/').any(|seg| seg == "..") {+ return Err(format!("unsafe zip entry: {name}"));+ }+ total += e.size;+ if total > MAX_UNPACKED_BYTES {+ return Err("bundle unpacks past the size cap".into());+ }+ let target = dest.join(&norm);+ if let Some(parent) = target.parent() {+ std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;+ }+ let data = zf.read(e)?;+ std::fs::write(&target, data).map_err(|e| format!("cannot write {}: {e}", target.display()))?;+ count += 1;+ }+ Ok(count)+}++fn walk_files(root: &Path, out: &mut Vec<PathBuf>) {+ if let Ok(rd) = std::fs::read_dir(root) {+ for e in rd.flatten() {+ let p = e.path();+ if p.is_dir() {+ walk_files(&p, out);+ } else {+ out.push(p);+ }+ }+ }+}++/// Python `_find_bundle_parts`: the shallowest `*.kicad_sym`, its sibling+/// `*.pretty` dir (first, sorted) and a sibling `3dmodels/` dir.+pub fn find_bundle_parts(root: &Path) -> Option<(PathBuf, Option<PathBuf>, Option<PathBuf>)> {+ let mut all = Vec::new();+ walk_files(root, &mut all);+ let mut syms: Vec<PathBuf> = all.into_iter().filter(|p| has_ext(p, "kicad_sym")).collect();+ syms.sort_by_key(|p| (p.components().count(), norm_path(p)));+ let sym = syms.into_iter().next()?;+ let base = sym.parent().map(Path::to_path_buf).unwrap_or_else(|| root.to_path_buf());+ let mut dirs: Vec<PathBuf> =+ std::fs::read_dir(&base).map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect()).unwrap_or_default();+ dirs.sort();+ let pretty = dirs.into_iter().find(|d| has_ext(d, "pretty"));+ let models = base.join("3dmodels");+ Some((sym, pretty, if models.is_dir() { Some(models) } else { None }))+}++/// Python `_top_symbol_names`: every `(symbol "NAME"` anywhere in the file, minus+/// `_N_N` sub-units (a whole-text scan, unlike the line-anchored list_symbols).+pub fn top_symbol_names(sym_path: &Path) -> Vec<String> {+ let Ok(text) = std::fs::read_to_string(sym_path) else { return Vec::new() };+ let mut names = Vec::new();+ let mut pos = 0;+ while let Some(i) = text[pos..].find("(symbol") {+ let after = pos + i + "(symbol".len();+ let rest = &text[after..];+ let trimmed = rest.trim_start();+ if trimmed.len() != rest.len() {+ if let Some(q) = trimmed.strip_prefix('"') {+ if let Some(end) = q.find('"') {+ let name = &q[..end];+ if !libraries::is_subunit_name(name) {+ names.push(name.to_string());+ }+ }+ }+ }+ pos = after;+ }+ names+}++/// `kicad_install_library_bundle {zipPath, libraryName?, capture?}`.+/// The proof frame (`capture:true`) and the running-KiCad check are window-phase:+/// `kicadRunning` is null, `restartNeeded` true, `proofSkipped` says why.+pub fn install_library_bundle(ctx: &LibCtx, installed: bool, args: &Value) -> Value {+ if !installed {+ return json!({"success": false, "error": "KiCad not installed", "_hint": "Install KiCad first (kicad_upgrade can do it), then retry."});+ }+ let zip_path = s_arg(args, "zipPath");+ if zip_path.is_empty() {+ return json!({"success": false, "error": "No zipPath specified",+ "_hint": "Stage the bundle zip with write_file / write_file_chunk first, then pass its path."});+ }+ let source = PathBuf::from(&zip_path);+ if !source.is_file() {+ return json!({"success": false, "error": format!("Bundle zip not found: {zip_path}"),+ "_hint": "The path must exist on THIS machine. Stage it with write_file / write_file_chunk first."});+ }+ if std::fs::metadata(&source).map(|m| m.len()).unwrap_or(0) > MAX_ZIP_BYTES {+ return json!({"success": false, "error": "Bundle zip is larger than the 256MB cap"});+ }+ let lib_name = {+ let n = s_arg(args, "libraryName");+ if n.is_empty() { file_stem(&source) } else { n }.trim().to_string()+ };+ if !name_ok(&lib_name) {+ return json!({"success": false, "error": format!("Library name not usable as a KiCad library id: {lib_name:?}"),+ "_hint": "Letters, digits, space, dot, dash, underscore; up to 64 chars."});+ }++ let extract_dir = unique_temp_dir("adom-lib-bundle-");+ let _ = std::fs::create_dir_all(&extract_dir);+ let result = bundle_inner(ctx, args, &source, &lib_name, &extract_dir);+ let _ = std::fs::remove_dir_all(&extract_dir);+ result+}++fn bundle_inner(ctx: &LibCtx, args: &Value, source: &Path, lib_name: &str, extract_dir: &Path) -> Value {+ let bytes = match std::fs::read(source) {+ Ok(b) => b,+ Err(e) => return json!({"success": false, "error": format!("Could not unpack the bundle: {e}")}),+ };+ let extracted = match safe_extract(&bytes, extract_dir) {+ Ok(n) => n,+ Err(e) => return json!({"success": false, "error": format!("Could not unpack the bundle: {e}")}),+ };+ if extracted == 0 {+ return json!({"success": false, "error": "The bundle zip is empty"});+ }+ let Some((sym, pretty, models)) = find_bundle_parts(extract_dir) else {+ return json!({"success": false, "error": "No .kicad_sym found in the bundle",+ "_hint": "Expected an Adom wiki Cloud Library bundle: <Lib>.kicad_sym + <Lib>.pretty/ + 3dmodels/."});+ };+ let top_names = top_symbol_names(&sym);+ let mut report = json!({"libraryName": lib_name, "symbols": top_names.len(),+ "footprints": 0, "modelsInstalled": 0, "modelRefsRewritten": 0});++ let sym_res = install_library(ctx, true, &json!({+ "libraryPath": norm_path(&sym), "libraryType": "symbol", "libraryName": lib_name,+ "description": "Adom wiki Cloud Library",+ }));+ if !sym_res["success"].as_bool().unwrap_or(false) {+ return json!({"success": false, "error": format!("Symbol install failed: {}", sym_res["error"].as_str().unwrap_or("")),+ "step": "symbols", "detail": sym_res});+ }+ if let Some(pretty) = &pretty {+ let fp_res = install_footprint_library(ctx, &json!({+ "libraryPath": norm_path(pretty), "libraryName": lib_name,+ "modelsPath": models.as_ref().map(|m| norm_path(m)).unwrap_or_default(),+ "description": "Adom wiki Cloud Library",+ }));+ if !fp_res["success"].as_bool().unwrap_or(false) {+ return json!({"success": false, "error": format!("Footprint install failed: {}", fp_res["error"].as_str().unwrap_or("")),+ "step": "footprints", "detail": fp_res, "symbolsInstalled": true});+ }+ report["footprints"] = json!(kicad_mods(pretty).len());+ report["modelsInstalled"] = fp_res["data"].get("modelsInstalled").cloned().unwrap_or(json!(0));+ report["modelRefsRewritten"] = fp_res["data"].get("modelRefsRewritten").cloned().unwrap_or(json!(0));+ }++ // The native build has no process scan yet (same as kicad_pcm_install):+ // a running KiCad is not detected, so the safe answer is given.+ report["kicadRunning"] = Value::Null;+ report["restartNeeded"] = json!(true);++ let mut parts = vec![format!("Library '{lib_name}' installed: {} symbols", report["symbols"])];+ if report["footprints"].as_u64().unwrap_or(0) > 0 {+ parts.push(format!("{} footprints", report["footprints"]));+ }+ if report["modelsInstalled"].as_u64().unwrap_or(0) > 0 {+ parts.push(format!("{} 3D models", report["modelsInstalled"]));+ }+ let summary = parts.join(", ") + ". KiCad reads library tables at startup, so a KiCad that was already open must be restarted to see it.";++ let mut result = json!({+ "success": true,+ "output": summary,+ "data": report,+ "_hint": "The bundle is installed and registered in both lib tables. This build cannot tell whether KiCad is running (kicadRunning is null): KiCad reads sym-/fp-lib-table at startup only, so if it was open during the install the user must restart it before the library appears. Never close their KiCad for them.",+ });+ if args.get("capture").map(|v| !matches!(v, Value::Bool(false) | Value::Null)).unwrap_or(false) {+ result["proofSkipped"] = json!("capture is window-phase: this build opens no Symbol Editor and takes no frame; call kicad_show_library once the window verbs land");+ }+ result+}++// ---------------------------------------------------------------------------+// kicad_adom_library_heal (adom_library.heal_split)+// ---------------------------------------------------------------------------++/// Reconcile a split Adom library (wiki #71). `merge` (default) copies every+/// top-level symbol the orphan has and the registered file lacks into the+/// registered file through `add_symbol` (balance gate, .bak); `repoint` rewrites+/// the row's uri to the orphan; `prune` asks KiCad about each symbol alone and+/// rebuilds the registered file from the accepted ones (`.pruned.bak` kept).+/// `dry_run` previews. Human-chosen: the bridge cannot know which file is real.+pub fn heal_split(ctx: &LibCtx, mode: &str, dry_run: bool) -> Value {+ let rep = libraries::library_split_report(ctx);+ if mode == "prune" {+ let reg = rep["registeredPath"].as_str().unwrap_or("").to_string();+ if reg.is_empty() || !Path::new(®).exists() {+ return json!({"success": false, "error": "no registered Adom library file to prune", "report": rep});+ }+ let text = match std::fs::read_to_string(®) {+ Ok(t) => t,+ Err(e) => return json!({"success": false, "error": format!("cannot read {reg}: {e}")}),+ };+ let cli = ctx.kicad_cli.as_deref();+ let whole = kicad_parser_verdict(cli, Path::new(®), PARSE_TIMEOUT);+ if !whole["available"].as_bool().unwrap_or(false) {+ return json!({"success": false, "error": whole["error"], "kicadParser": whole});+ }+ let (blocks, notes) = extract_symbols_tolerant(&text);+ let names: Vec<&str> = blocks.iter().map(|(n, _)| n.as_str()).collect();+ if whole["ok"].as_bool().unwrap_or(false) {+ return json!({"success": true, "healed": false, "mode": "prune", "dryRun": dry_run,+ "kicadParser": whole, "dropped": [], "kept": names,+ "_hint": "KiCad already loads this library; nothing to prune."});+ }+ let per = refused_symbols(cli, &blocks);+ let accepted: Vec<&str> = per["accepted"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();+ let kept: Vec<&(String, String)> = blocks.iter().filter(|(n, _)| accepted.contains(&n.as_str())).collect();+ let kept_names: Vec<&str> = kept.iter().map(|(n, _)| n.as_str()).collect();+ let refused_count = per["refused"].as_object().map(|o| o.len()).unwrap_or(0);+ let mut out = json!({"success": true, "mode": "prune", "dryRun": dry_run, "registeredPath": reg,+ "kicadParserBefore": whole, "dropped": per["refused"], "kept": kept_names, "sourceNotes": notes});+ if dry_run {+ out["healed"] = json!(false);+ out["_hint"] = json!(format!("Would rebuild {reg} with {} symbol(s) KiCad accepts and drop {refused_count} it refuses (names in dropped). Nothing written.", kept.len()));+ return out;+ }+ let kept_blocks: Vec<&str> = kept.iter().map(|(_, b)| b.as_str()).collect();+ let new_text = wrap_symbol_blocks(&kept_blocks).replace("\"adom-parse-check\"", "\"adom-desktop\"");+ let after = verdict_text(cli, &new_text);+ if !after["ok"].as_bool().unwrap_or(false) {+ out["success"] = json!(false);+ out["healed"] = json!(false);+ out["kicadParserAfter"] = after;+ out["error"] = json!("the pruned library is STILL refused by KiCad; nothing written (report this with the file)");+ return out;+ }+ let backup = format!("{reg}.pruned.bak");+ if let Err(e) = std::fs::write(&backup, &text) {+ return json!({"success": false, "error": format!("cannot write backup {backup}: {e}")});+ }+ if let Err(e) = write_atomic(Path::new(®), new_text.as_bytes()) {+ return json!({"success": false, "error": e});+ }+ out["healed"] = json!(true);+ out["kicadParserAfter"] = after;+ out["backup"] = json!(backup);+ out["_hint"] = json!(format!("Rebuilt with {} symbol(s) KiCad accepts; dropped {refused_count} (see dropped, with KiCad's reason each). Original saved beside it. KiCad sees the change after its next library load.", kept.len()));+ return out;+ }++ if !rep["split"].as_bool().unwrap_or(false) {+ let mut out = json!({"success": true, "healed": false, "reason": "no split"});+ merge_into(&mut out, &rep);+ return out;+ }+ let registered = PathBuf::from(rep["registeredPath"].as_str().unwrap_or(""));+ let orphan = PathBuf::from(rep["bridgeDefaultPath"].as_str().unwrap_or(""));+ if mode == "repoint" {+ let Some((table_path, _)) = libraries::adom_paths(ctx) else {+ return json!({"success": false, "error": "no Adom row to repoint"});+ };+ let mut table = match LibTable::parse_file(&table_path) {+ Ok(t) => t,+ Err(e) => return json!({"success": false, "error": format!("Failed to parse sym-lib-table: {e}")}),+ };+ let Some(row) = table.entries.iter_mut().find(|e| e.name == ADOM_LIB_NAME) else {+ return json!({"success": false, "error": "no Adom row to repoint"});+ };+ if !dry_run {+ row.uri = norm_path(&orphan);+ if let Err(e) = table.write_file(&table_path) {+ return json!({"success": false, "error": format!("cannot write sym-lib-table: {e}")});+ }+ }+ return json!({"success": true, "healed": !dry_run, "mode": "repoint", "dryRun": dry_run,+ "rowNowPointsAt": norm_path(&orphan),+ "_hint": "KiCad reads the repointed file after its next library load (restart)."});+ }+ if mode != "merge" {+ return json!({"success": false, "error": format!("unknown mode {mode:?}"), "allowed": ["merge", "repoint", "prune"]});+ }+ let orphan_text = match std::fs::read_to_string(&orphan) {+ Ok(t) => t,+ Err(e) => return json!({"success": false, "error": format!("cannot read orphan: {e}")}),+ };+ // TOLERANT SOURCE READ: symbols after the library's closer (the old appender+ // bug) are still offered, each balance-checked on its own.+ let (src_symbols, unparseable) = extract_symbols_tolerant(&orphan_text);+ let have: Vec<String> = list_symbols(®istered).into_iter().map(|n| n.to_lowercase()).collect();+ let to_add: Vec<&(String, String)> = src_symbols.iter().filter(|(n, _)| !have.contains(&n.to_lowercase())).collect();+ let skipped: Vec<&str> = src_symbols.iter().filter(|(n, _)| have.contains(&n.to_lowercase())).map(|(n, _)| n.as_str()).collect();+ if dry_run {+ return json!({"success": true, "healed": false, "mode": "merge", "dryRun": true,+ "wouldAdd": to_add.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), "alreadyPresent": skipped,+ "unparseableInSource": unparseable,+ "registeredPath": norm_path(®istered), "orphanPath": norm_path(&orphan)});+ }+ let mut added: Vec<String> = Vec::new();+ let mut failed: Vec<Value> = Vec::new();+ for (name, text) in to_add {+ // add_symbol takes a WHOLE .kicad_sym; row-first paths land it in the registered file.+ let wrapped = format!("(kicad_symbol_lib (version 20231120) (generator \"adom-heal\")\n{text}\n)\n");+ let r = add_symbol(ctx, &wrapped);+ if r["success"].as_bool().unwrap_or(false) {+ added.push(name.clone());+ } else {+ failed.push(json!({"symbol": name, "error": r["error"]}));+ }+ }+ let after = libraries::library_split_report(ctx);+ json!({"success": failed.is_empty(), "healed": true, "mode": "merge", "dryRun": false,+ "added": added, "alreadyPresent": skipped, "failed": failed,+ "unparseableInSource": unparseable,+ "registeredSymbolsAfter": after["registeredSymbols"],+ "_hint": "Merged into the file KiCad reads. A running KiCad sees them after its next library load (kicad_rescan_libraries force:true, or restart). The orphan file was not modified or deleted."})+}++// ---------------------------------------------------------------------------+// Part lookups shared by make_part_project and export_part+// ---------------------------------------------------------------------------++/// The Adom.kicad_sym KiCad reads (row first, wiki #71), falling back to the+/// bridge default `<user>/symbols/Adom.kicad_sym` when no row resolves.+fn adom_sym_lib(ctx: &LibCtx, user_dir: &Path) -> PathBuf {+ libraries::adom_paths(ctx).map(|(_, p)| p).unwrap_or_else(|| user_dir.join("symbols").join(ADOM_LIB_FILE))+}++/// The Adom.pretty KiCad reads (row first), with `${...}` uris expanded.+fn adom_pretty(ctx: &LibCtx, user_dir: &Path) -> PathBuf {+ let default = user_dir.join("footprints").join(ADOM_FP_DIR_NAME);+ let Some(cfg) = &ctx.config_dir else { return default };+ if let Ok(t) = LibTable::parse_file(&cfg.join("fp-lib-table")) {+ if let Some(row) = t.get(ADOM_FP_LIB_NAME) {+ if let Some(p) = expand_kicad_uri(&row.uri, ctx) {+ return PathBuf::from(p);+ }+ }+ }+ default+}++/// Documents/ for this user: the grandparent of Documents/KiCad/<ver>, which is+/// what every platform's user_dir is.+fn documents_dir(user_dir: &Path) -> PathBuf {+ user_dir.parent().and_then(Path::parent).map(Path::to_path_buf).unwrap_or_else(|| user_dir.to_path_buf())+}++fn find_symbol_block(lib_text: &str, name: &str) -> Result<Option<String>, String> {+ Ok(extract_top_level_symbols(lib_text)?.into_iter().find(|(n, _)| n == name).map(|(_, b)| b))+}++/// `[-\d.]+` at the start of `s`, parsed. Returns (value, consumed).+fn num_token(s: &str) -> Option<(f64, usize)> {+ let n = s.bytes().take_while(|b| b.is_ascii_digit() || *b == b'-' || *b == b'.').count();+ if n == 0 {+ return None;+ }+ s[..n].parse::<f64>().ok().map(|v| (v, n))+}++fn skip_ws(s: &str) -> &str {+ s.trim_start()+}++/// Python `_pins`: `(pin TYPE SHAPE (at X Y ANGLE))` connection points.+fn pins(sym_block: &str) -> Vec<(f64, f64, f64)> {+ let mut out = Vec::new();+ let mut pos = 0;+ while let Some(i) = sym_block[pos..].find("(pin") {+ let after = pos + i + "(pin".len();+ pos = after;+ let s = &sym_block[after..];+ if !s.starts_with(|c: char| c.is_ascii_whitespace()) {+ continue;+ }+ let s = skip_ws(s);+ let t1 = s.split_whitespace().next().unwrap_or("");+ if t1.is_empty() {+ continue;+ }+ let s = skip_ws(&s[t1.len()..]);+ let t2 = s.split_whitespace().next().unwrap_or("");+ if t2.is_empty() {+ continue;+ }+ let s = skip_ws(&s[t2.len()..]);+ let Some(s) = s.strip_prefix("(at") else { continue };+ if !s.starts_with(|c: char| c.is_ascii_whitespace()) {+ continue;+ }+ let s = skip_ws(s);+ let Some((x, n)) = num_token(s) else { continue };+ let s = skip_ws(&s[n..]);+ let Some((y, n)) = num_token(s) else { continue };+ let s = skip_ws(&s[n..]);+ let Some((a, n)) = num_token(s) else { continue };+ if s[n..].starts_with(')') {+ out.push((x, y, a));+ }+ }+ out+}++/// Python `_pads`: `(pad "N" ... (at X Y` with no parens in between.+fn pads(fp_text: &str) -> Vec<(f64, f64)> {+ let mut out = Vec::new();+ let mut pos = 0;+ while let Some(i) = fp_text[pos..].find("(pad") {+ let after = pos + i + "(pad".len();+ pos = after;+ let s = &fp_text[after..];+ if !s.starts_with(|c: char| c.is_ascii_whitespace()) {+ continue;+ }+ let s = skip_ws(s);+ let Some(q) = s.strip_prefix('"') else { continue };+ let Some(end) = q.find('"') else { continue };+ let s = &q[end + 1..];+ let stop = s.find(['(', ')']).unwrap_or(s.len());+ let s = &s[stop..];+ let Some(s) = s.strip_prefix("(at") else { continue };+ if !s.starts_with(|c: char| c.is_ascii_whitespace()) {+ continue;+ }+ let s = skip_ws(s);+ let Some((x, n)) = num_token(s) else { continue };+ let s = skip_ws(&s[n..]);+ let Some((y, _)) = num_token(s) else { continue };+ out.push((x, y));+ }+ out+}++/// Every `(start|end|at|center X Y` pair in a footprint.+fn coords(fp_text: &str) -> Vec<(f64, f64)> {+ let mut out = Vec::new();+ let mut pos = 0;+ while let Some(i) = fp_text[pos..].find('(') {+ let after = pos + i + 1;+ pos = after;+ let s = &fp_text[after..];+ let Some(rest) = ["start", "end", "at", "center"].iter().find_map(|w| s.strip_prefix(w)) else { continue };+ if !rest.starts_with(|c: char| c.is_ascii_whitespace()) {+ continue;+ }+ let s = skip_ws(rest);+ let Some((x, n)) = num_token(s) else { continue };+ let s = skip_ws(&s[n..]);+ let Some((y, _)) = num_token(s) else { continue };+ out.push((x, y));+ }+ out+}++fn reference_property(block: &str) -> Option<String> {+ let mut pos = 0;+ while let Some(i) = block[pos..].find("(property") {+ let after = pos + i + "(property".len();+ pos = after;+ let s = skip_ws(&block[after..]);+ let Some(s) = s.strip_prefix("\"Reference\"") else { continue };+ let s = skip_ws(s);+ let Some(q) = s.strip_prefix('"') else { continue };+ let Some(end) = q.find('"') else { continue };+ return Some(q[..end].to_string());+ }+ None+}++fn replace_first(text: &str, from: &str, to: &str) -> String {+ match text.find(from) {+ Some(i) => format!("{}{}{}", &text[..i], to, &text[i + from.len()..]),+ None => text.to_string(),+ }+}++fn uuid(n: u64) -> String {+ format!("00000000-0000-4000-8000-{n:012}")+}++// ---------------------------------------------------------------------------+// kicad_make_part_project (handlers/make_part_project.py)+// ---------------------------------------------------------------------------++/// Generate `<proj>.kicad_pro/.kicad_sch/.kicad_pcb` around one installed Adom+/// part: the symbol alone with two stub wires, the footprint alone inside tight+/// edge cuts with two copper stubs, the footprint's `(model ...)` kept.+/// Registering the project with the focus guardian is window-phase and skipped.+pub fn make_part_project(ctx: &LibCtx, args: &Value) -> Value {+ let part = args.get("partName").or_else(|| args.get("symbolName")).and_then(Value::as_str).unwrap_or("").trim().to_string();+ if part.is_empty() {+ return json!({"success": false, "error": "partName required",+ "_hint": "The symbol's name in the Adom library: kicad_install_symbol first."});+ }+ let fp_name = {+ let f = s_arg(args, "footprintName");+ if f.trim().is_empty() { part.clone() } else { f.trim().to_string() }+ };+ let proj = {+ let p = s_arg(args, "projectName").trim().to_string();+ if !p.is_empty() {+ p+ } else {+ // re.sub(r"[^a-z0-9-]+", "-", part.lower()).strip("-") + "-demo"+ let mut s = String::new();+ let mut in_run = false;+ for c in part.to_lowercase().chars() {+ if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' {+ s.push(c);+ in_run = false;+ } else if !in_run {+ s.push('-');+ in_run = true;+ }+ }+ format!("{}-demo", s.trim_matches('-'))+ }+ };++ let Some(user_dir) = ctx.user_dir.clone() else {+ return json!({"success": false, "error": "KiCad user directory not found",+ "_hint": "kicad_status reports install state; run once with KiCad installed."});+ };+ let sym_lib = adom_sym_lib(ctx, &user_dir);+ if !sym_lib.exists() {+ return json!({"success": false, "error": format!("Adom library not found at {}", norm_path(&sym_lib)),+ "_hint": "kicad_install_symbol installs the part into the Adom library first."});+ }+ let lib_text = std::fs::read_to_string(&sym_lib).unwrap_or_default();+ let block = match find_symbol_block(&lib_text, &part) {+ Ok(Some(b)) => b,+ Ok(None) => {+ return json!({"success": false, "error": format!("Symbol '{part}' not in the Adom library"),+ "_hint": "kicad_install_symbol with the part's .kicad_sym first (wiki part pages ship it)."})+ }+ Err(e) => return json!({"success": false, "error": format!("Adom library at {} does not parse: {e}", norm_path(&sym_lib))}),+ };+ let fp_path = adom_pretty(ctx, &user_dir).join(format!("{fp_name}.kicad_mod"));+ if !fp_path.exists() {+ return json!({"success": false, "error": format!("Footprint '{fp_name}' not in Adom.pretty"),+ "_hint": "kicad_install_footprint (with model:{...} for the 3D step) first."});+ }+ let fp_text = std::fs::read_to_string(&fp_path).unwrap_or_default();++ let out_dir = match s_arg(args, "outputDir").trim() {+ "" => documents_dir(&user_dir).join(&proj),+ d => PathBuf::from(d),+ };+ if let Err(e) = std::fs::create_dir_all(&out_dir) {+ return json!({"success": false, "error": format!("Failed to write project files: cannot create {}: {e}", norm_path(&out_dir))});+ }++ // The part names its own reference prefix.+ let refdes = format!("{}1", reference_property(&block).map(|r| r.trim().to_string()).filter(|r| !r.is_empty()).unwrap_or_else(|| "U".into()));++ // ---------------- schematic: JUST the part ----------------+ let sheet = uuid(1);+ let (ax, ay) = (127.0f64, 80.0f64);+ let lib_entry = format!(" {}", replace_first(&block, &format!("(symbol \"{part}\""), &format!("(symbol \"Adom:{part}\"")));+ let mut wires = String::new();+ let mut wire_n = 300u64;+ for (px, py, pa) in pins(&block).into_iter().take(2) {+ let (cx, cy) = (ax + px, ay - py);+ let dx = -pa.to_radians().cos();+ let dy = pa.to_radians().sin();+ let (ex, ey) = (round_to(cx + 6.0 * dx, 3), round_to(cy + 6.0 * dy, 3));+ wires.push_str(&format!(+ " (wire (pts (xy {} {}) (xy {} {})) (stroke (width 0) (type default)) (uuid \"{}\"))\n",+ py_float(round_to(cx, 3)), py_float(round_to(cy, 3)), py_float(ex), py_float(ey), uuid(wire_n)+ ));+ wire_n += 1;+ }+ let schematic = format!(+ "(kicad_sch (version 20231120) (generator \"eeschema\") (generator_version \"8.0\")\n (uuid \"{sheet}\") (paper \"A4\")\n (title_block (title \"{part} demo - generated by the Adom KiCad Bridge\") (company \"Adom\"))\n (lib_symbols\n{lib_entry}\n )\n{wires} (symbol (lib_id \"Adom:{part}\") (at {ax} {ay} 0) (unit 1)\n (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no) (uuid \"{u201}\")\n (property \"Reference\" \"{refdes}\" (at {ax} {ay_ref} 0) (effects (font (size 1.27 1.27))))\n (property \"Value\" \"{part}\" (at {ax} {ay_val} 0) (effects (font (size 1.27 1.27))))\n (property \"Footprint\" \"Adom:{fp_name}\" (at {ax} {ay} 0) (effects (font (size 1.27 1.27)) (hide yes)))\n (instances (project \"{proj}\" (path \"/{sheet}\" (reference \"{refdes}\") (unit 1))))\n )\n (sheet_instances (path \"/\" (page \"1\")))\n)\n",+ ax = py_float(ax), ay = py_float(ay), ay_ref = py_float(ay - 7.5), ay_val = py_float(ay + 7.0), u201 = uuid(201)+ );++ // ---------------- board: JUST the part, centered ----------------+ let part_at = (108.0f64, 68.0f64);+ let cs = coords(&fp_text);+ let half_w = cs.iter().map(|(x, _)| x.abs()).fold(1.0f64, f64::max);+ let half_h = cs.iter().map(|(_, y)| y.abs()).fold(1.0f64, f64::max);+ let margin = (2.0f64).max(half_h * 0.8);+ let x1 = round_to(part_at.0 - half_w - margin, 2);+ let y1 = round_to(part_at.1 - half_h.max(1.0) - margin, 2);+ let x2 = round_to(part_at.0 + half_w + margin, 2);+ let y2 = round_to(part_at.1 + half_h.max(1.0) + margin, 2);+ let fp_placed = replace_first(&fp_text, &format!("(footprint \"{fp_name}\""), &format!("(footprint \"Adom:{fp_name}\""));+ let fp_placed = replace_first(&fp_placed, "(layer \"F.Cu\")", &format!("(layer \"F.Cu\")\n (at {} {})", py_float(part_at.0), py_float(part_at.1)));+ let fp_placed = replace_first(&fp_placed, "(fp_text reference \"REF**\"", &format!("(fp_text reference \"{refdes}\""));+ let fp_placed = replace_first(&fp_placed, "(property \"Reference\" \"REF**\"", &format!("(property \"Reference\" \"{refdes}\""));++ let mut edge = String::new();+ let pts = [(x1, y1), (x2, y1), (x2, y2), (x1, y2), (x1, y1)];+ for i in 0..4 {+ let ((ex1, ey1), (ex2, ey2)) = (pts[i], pts[i + 1]);+ edge.push_str(&format!(+ " (gr_line (start {} {}) (end {} {}) (stroke (width 0.15) (type solid)) (layer \"Edge.Cuts\") (uuid \"{}\"))\n",+ py_float(ex1), py_float(ey1), py_float(ex2), py_float(ey2), uuid(400 + i as u64)+ ));+ }++ // Two short copper stubs off the pads keep the board looking real.+ let mut segments = String::new();+ let pd = pads(&fp_text);+ if pd.len() >= 2 && (pd[0].1 - pd[1].1).abs() < 0.01 {+ let mut two = vec![pd[0], pd[1]];+ two.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));+ let (left, right) = (two[0], two[1]);+ let py_abs = part_at.1 + left.1;+ segments.push_str(&format!(+ " (segment (start {} {}) (end {} {}) (width 0.3) (layer \"F.Cu\"))\n (segment (start {} {}) (end {} {}) (width 0.3) (layer \"F.Cu\"))\n",+ py_float(round_to(part_at.0 + left.0, 3)), py_float(round_to(py_abs, 3)),+ py_float(round_to(x1 + 0.8, 3)), py_float(round_to(py_abs, 3)),+ py_float(round_to(part_at.0 + right.0, 3)), py_float(round_to(py_abs, 3)),+ py_float(round_to(x2 - 0.8, 3)), py_float(round_to(py_abs, 3)),+ ));+ }+ let fps = format!(" {}", fp_placed.trim_end().replace('\n', "\n "));+ let board = format!(+ "(kicad_pcb (version 20240108) (generator \"pcbnew\") (generator_version \"8.0\")\n (general (thickness 1.6)) (paper \"A4\")\n (layers\n (0 \"F.Cu\" signal) (31 \"B.Cu\" signal)\n (36 \"B.SilkS\" user \"B.Silkscreen\") (37 \"F.SilkS\" user \"F.Silkscreen\")\n (38 \"B.Mask\" user) (39 \"F.Mask\" user) (40 \"B.Paste\" user) (41 \"F.Paste\" user)\n (44 \"Edge.Cuts\" user) (45 \"Margin\" user) (49 \"F.Fab\" user) (50 \"B.Fab\" user)\n (46 \"B.CrtYd\" user \"B.Courtyard\") (47 \"F.CrtYd\" user \"F.Courtyard\")\n )\n (setup (pad_to_mask_clearance 0))\n{edge}{fps}\n{segments})\n"+ );+ let pro = format!("{{\"board\":{{}},\"meta\":{{\"filename\":\"{proj}.kicad_pro\",\"version\":1}},\"schematic\":{{}},\"sheets\":[]}}");++ let mut files = Map::new();+ for (name, content) in [(format!("{proj}.kicad_pro"), &pro), (format!("{proj}.kicad_sch"), &schematic), (format!("{proj}.kicad_pcb"), &board)] {+ let p = out_dir.join(&name);+ if let Err(e) = std::fs::write(&p, content) {+ return json!({"success": false, "error": format!("Failed to write project files: {e}")});+ }+ let key = name.rsplit('.').next().unwrap_or("").to_string();+ files.insert(key, json!(norm_path(&p)));+ }+ // Stale single-instance locks from a previous open of the same project.+ if let Ok(rd) = std::fs::read_dir(&out_dir) {+ for e in rd.flatten() {+ if has_ext(&e.path(), "lck") {+ let _ = std::fs::remove_file(e.path());+ }+ }+ }+ for (name, content) in [("kicad_sch", &schematic), ("kicad_pcb", &board)] {+ if content.matches('(').count() != content.matches(')').count() {+ return json!({"success": false, "error": format!("internal: unbalanced s-expression in {proj}.{name}")});+ }+ }+ json!({+ "success": true,+ "projectName": proj,+ "projectFile": files["kicad_pro"],+ "schematicFile": files["kicad_sch"],+ "boardFile": files["kicad_pcb"],+ "partName": part,+ "footprintName": fp_name,+ "reference": refdes,+ "output": format!("Generated {proj}: the part alone as {refdes}, single-component by design (schematic + part-dominant board with edge cuts, copper stubs and the part's 3D model)."),+ "_hint": "Project surfaces now have something real to show: kicad_show_project {filePath: projectFile}, kicad_show_schematic {filePath: schematicFile}, kicad_show_2d_board / kicad_show_3d_board {filePath: boardFile}. The footprint keeps its (model ...) reference, so the 3D board renders the actual part. Regenerating overwrites in place.",+ "_next": ["kicad_show_project", "kicad_show_schematic", "kicad_show_2d_board", "kicad_show_3d_board"],+ })+}++// ---------------------------------------------------------------------------+// kicad_export_part (handlers/export_part.py)+// ---------------------------------------------------------------------------++/// One-off single-part library exports: a `.kicad_sym` with ONLY that symbol, a+/// `.pretty` with ONLY that footprint, the bare 3D model file.+pub fn export_part(ctx: &LibCtx, args: &Value) -> Value {+ let part = args.get("partName").or_else(|| args.get("symbolName")).and_then(Value::as_str).unwrap_or("").trim().to_string();+ if part.is_empty() {+ return json!({"success": false, "error": "partName required"});+ }+ let fp_name = {+ let f = s_arg(args, "footprintName");+ if f.trim().is_empty() { part.clone() } else { f.trim().to_string() }+ };+ let what: Vec<String> = match args.get("what") {+ Some(Value::String(s)) => vec![s.to_lowercase()],+ Some(Value::Array(a)) if !a.is_empty() => a.iter().filter_map(Value::as_str).map(|s| s.to_lowercase()).collect(),+ _ => vec!["symbol".into(), "footprint".into(), "model".into()],+ };+ let Some(user_dir) = ctx.user_dir.clone() else {+ return json!({"success": false, "error": "KiCad user directory not found"});+ };+ let out_dir = match s_arg(args, "outputDir").trim() {+ "" => documents_dir(&user_dir).join("adom-part-exports").join(&part),+ d => PathBuf::from(d),+ };+ if let Err(e) = std::fs::create_dir_all(&out_dir) {+ return json!({"success": false, "error": format!("cannot create {}: {e}", norm_path(&out_dir))});+ }+ let mut exports = Map::new();+ let mut errors: Vec<String> = Vec::new();+ let pretty_src = adom_pretty(ctx, &user_dir);++ if what.iter().any(|w| w == "symbol") {+ let sym_lib = adom_sym_lib(ctx, &user_dir);+ match std::fs::read_to_string(&sym_lib).map_err(|e| e.to_string()).and_then(|t| extract_top_level_symbols(&t)) {+ Err(e) => errors.push(format!("symbol export: {e}")),+ Ok(blocks) => match blocks.into_iter().find(|(n, _)| *n == part) {+ None => errors.push(format!("symbol '{part}' not in {}", norm_path(&sym_lib))),+ Some((_, blk)) => {+ let one = format!("(kicad_symbol_lib\n (version 20231120)\n (generator \"adom-export-part\")\n{}\n)\n", blk.trim_end());+ let p = out_dir.join(format!("{part}-symbol-only.kicad_sym"));+ match std::fs::write(&p, &one) {+ Err(e) => errors.push(format!("symbol export: {e}")),+ Ok(()) => {+ let names: Vec<String> = extract_top_level_symbols(&one).map(|v| v.into_iter().map(|(n, _)| n).collect()).unwrap_or_default();+ let single = names.len() == 1 && names[0] == part;+ exports.insert("symbol".into(), json!({"path": norm_path(&p), "symbolsInLibrary": names, "single": single}));+ }+ }+ }+ },+ }+ }++ if what.iter().any(|w| w == "footprint") {+ let src = pretty_src.join(format!("{fp_name}.kicad_mod"));+ if !src.exists() {+ errors.push(format!("footprint '{fp_name}' not in Adom.pretty"));+ } else {+ let pretty = out_dir.join(format!("{part}-footprint-only.pretty"));+ let dst = pretty.join(format!("{fp_name}.kicad_mod"));+ match std::fs::create_dir_all(&pretty).map_err(|e| e.to_string()).and_then(|_| std::fs::copy(&src, &dst).map(|_| ()).map_err(|e| e.to_string())) {+ Err(e) => errors.push(format!("footprint export: {e}")),+ Ok(()) => {+ let mods: Vec<String> = kicad_mods(&pretty).iter().map(|p| file_name(p)).collect();+ let single = mods.len() == 1 && mods[0] == format!("{fp_name}.kicad_mod");+ exports.insert("footprint".into(), json!({"path": norm_path(&dst), "library": norm_path(&pretty), "footprintsInLibrary": mods, "single": single}));+ }+ }+ }+ }++ if what.iter().any(|w| w == "model") {+ let models_dir = user_dir.join("3dmodels").join("Adom");+ let mut src: Option<PathBuf> = None;+ for ext in [".step", ".stp", ".wrl"] {+ let c = models_dir.join(format!("{fp_name}{ext}"));+ if c.exists() {+ src = Some(c);+ break;+ }+ }+ if src.is_none() {+ // Fall back to the model the footprint references.+ let fp = pretty_src.join(format!("{fp_name}.kicad_mod"));+ if let Ok(t) = std::fs::read_to_string(&fp) {+ if let Some((_, reference)) = model_refs(&t).into_iter().next() {+ let p = PathBuf::from(&reference);+ if p.exists() {+ src = Some(p);+ }+ }+ }+ }+ match src {+ None => errors.push(format!("no 3D model for '{fp_name}' in 3dmodels/Adom")),+ Some(src) => {+ let ext = src.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();+ let dst = out_dir.join(format!("{part}-model-only{ext}"));+ match std::fs::copy(&src, &dst) {+ Err(e) => errors.push(format!("model export: {e}")),+ Ok(_) => {+ let bytes = std::fs::metadata(&dst).map(|m| m.len()).unwrap_or(0);+ exports.insert("model".into(), json!({"path": norm_path(&dst), "bytes": bytes}));+ }+ }+ }+ }+ }++ let ok = exports.len() == what.len() && errors.is_empty();+ let mut kinds: Vec<&String> = exports.keys().collect();+ kinds.sort();+ let output = format!(+ "one-off exports for {part}: {}{}",+ kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>().join(", "),+ if errors.is_empty() { String::new() } else { format!(" | {} error(s)", errors.len()) }+ );+ json!({+ "success": ok,+ "partName": part,+ "outputDir": norm_path(&out_dir),+ "exports": exports,+ "errors": errors,+ "output": output,+ "_hint": "Each file is a standalone single-entry library: the .kicad_sym holds ONLY this symbol (open with kicad_show_symbol after installing, or any KiCad), the .pretty holds ONLY this footprint, the model file is the bare 3D body. The reverse of install: proves per-asset round-trip. Pull them with ab pull_file.",+ })+}++// ---------------------------------------------------------------------------+// Tests+// ---------------------------------------------------------------------------++#[cfg(test)]+mod tests {+ use super::*;++ const SYM_R: &str = "(kicad_symbol_lib\n\t(version 20231120)\n\t(generator \"kicad_symbol_editor\")\n\t(symbol \"R_Adom\"\n\t\t(property \"Reference\" \"R\" (at 0 0 0))\n\t\t(property \"Value\" \"R\" (at 0 0 0))\n\t\t(symbol \"R_Adom_0_1\" (rectangle (start -1 -2) (end 1 2)))\n\t\t(symbol \"R_Adom_1_1\"\n\t\t\t(pin passive line\n\t\t\t\t(at 0 3.81 270) (length 1.27) (name \"~\") (number \"1\"))\n\t\t\t(pin passive line (at 0 -3.81 90) (length 1.27) (name \"~\") (number \"2\"))\n\t\t)\n\t)\n\t(embedded_fonts no)\n)\n";++ const SYM_C: &str = "(kicad_symbol_lib (version 20231120) (generator \"x\")\n (symbol \"C_Adom\"\n (property \"Reference\" \"C\" (at 0 0 0))\n (property \"Value\" \"PA13(JTMS/SWDIO)\" (at 0 0 0))\n (symbol \"C_Adom_0_1\" (rectangle (start -1 -2) (end 1 2)))\n )\n)\n";++ const MOD_R: &str = "(footprint \"R_0805_Adom\"\n\t(version 20240108)\n\t(generator \"pcbnew\")\n\t(layer \"F.Cu\")\n\t(property \"Reference\" \"REF**\"\n\t\t(at 0 -1.65 0)\n\t\t(layer \"F.SilkS\")\n\t)\n\t(attr smd)\n\t(fp_rect\n\t\t(start -1.68 -0.95)\n\t\t(end 1.68 0.95)\n\t\t(layer \"F.CrtYd\")\n\t)\n\t(pad \"1\" smd roundrect\n\t\t(at -0.9125 0)\n\t\t(size 1.025 1.4)\n\t\t(layers \"F.Cu\" \"F.Paste\" \"F.Mask\")\n\t)\n\t(pad \"2\" smd roundrect\n\t\t(at 0.9125 0)\n\t\t(size 1.025 1.4)\n\t\t(layers \"F.Cu\" \"F.Paste\" \"F.Mask\")\n\t)\n\t(model \"3dmodels/R_0805.step\"\n\t\t(offset (xyz 0 0 0))\n\t\t(scale (xyz 1 1 1))\n\t\t(rotate (xyz 0 0 0))\n\t)\n)\n";++ struct Box_ {+ root: PathBuf,+ ctx: LibCtx,+ }++ impl Drop for Box_ {+ fn drop(&mut self) {+ let _ = std::fs::remove_dir_all(&self.root);+ }+ }++ /// A fake box: <root>/kicad (install with a template), <root>/cfg, <root>/Documents/KiCad/10.0.+ fn fake_box(name: &str) -> Box_ {+ let root = unique_temp_dir(&format!("adom-install-{name}-"));+ let _ = std::fs::remove_dir_all(&root);+ let base = root.join("kicad");+ let tmpl = base.join("share/kicad/template");+ std::fs::create_dir_all(&tmpl).unwrap();+ std::fs::write(tmpl.join("sym-lib-table"), "(sym_lib_table\n (version 7)\n (lib (name \"Device\")(type \"KiCad\")(uri \"${KICAD10_SYMBOL_DIR}/Device.kicad_sym\")(options \"\")(descr \"Generic devices\"))\n (lib (name \"Connector\")(type \"KiCad\")(uri \"${KICAD10_SYMBOL_DIR}/Connector.kicad_sym\")(options \"\")(descr \"\"))\n)\n").unwrap();+ std::fs::write(tmpl.join("fp-lib-table"), "(fp_lib_table\n (version 7)\n (lib (name \"Resistor_SMD\")(type \"KiCad\")(uri \"${KICAD10_FOOTPRINT_DIR}/Resistor_SMD.pretty\")(options \"\")(descr \"\"))\n)\n").unwrap();+ let cfg = root.join("cfg");+ let user = root.join("Documents/KiCad/10.0");+ let ctx = LibCtx {+ version: "10.0".into(),+ base_dir: Some(norm_path(&base)),+ config_dir: Some(cfg),+ user_dir: Some(user),+ kicad_cli: None,+ env_overrides: BTreeMap::new(),+ };+ Box_ { root, ctx }+ }++ fn b64(s: &str) -> String {+ const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+ let bytes = s.as_bytes();+ let mut out = String::new();+ for chunk in bytes.chunks(3) {+ let n = chunk.len();+ let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];+ let v = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;+ out.push(T[(v >> 18) as usize & 63] as char);+ out.push(T[(v >> 12) as usize & 63] as char);+ out.push(if n > 1 { T[(v >> 6) as usize & 63] as char } else { '=' });+ out.push(if n > 2 { T[v as usize & 63] as char } else { '=' });+ }+ out+ }++ #[test]+ fn base64_round_trip_and_errors() {+ let text = "(kicad_symbol_lib (symbol \"A\"))\n";+ assert_eq!(b64_decode(&b64(text)).unwrap(), text.as_bytes());+ assert_eq!(b64_decode("aGk=").unwrap(), b"hi");+ assert_eq!(b64_decode("aGk").unwrap(), b"hi");+ assert_eq!(b64_decode("aG\nk=").unwrap(), b"hi");+ assert!(b64_decode("a*k=").is_err());+ }++ #[test]+ fn heal_lib_tables_seeds_and_merges() {+ let bx = fake_box("heal");+ let cfg = bx.ctx.config_dir.clone().unwrap();+ let base = bx.ctx.base_dir.clone().unwrap();+ // Missing tables: seeded from the template.+ let r = heal_lib_tables(&cfg, Some(&base));+ assert_eq!(r["sym-lib-table"]["seeded"], json!(true));+ assert_eq!(r["fp-lib-table"]["seeded"], json!(true));+ let t = LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap();+ assert!(t.has_library("Device") && t.has_library("Connector"));+ // An existing table missing stock rows: merged, user rows untouched.+ std::fs::write(cfg.join("sym-lib-table"), "(sym_lib_table\n (version 7)\n (lib (name \"Mine\")(type \"KiCad\")(uri \"C:/x/Mine.kicad_sym\")(options \"\")(descr \"mine\"))\n (lib (name \"Device\")(type \"KiCad\")(uri \"D:/custom/Device.kicad_sym\")(options \"\")(descr \"override\"))\n)\n").unwrap();+ let r = heal_lib_tables(&cfg, Some(&base));+ assert_eq!(r["sym-lib-table"]["merged"], json!(1));+ assert_eq!(r["fp-lib-table"]["merged"], json!(0));+ let t = LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap();+ assert_eq!(t.entries.len(), 3);+ assert_eq!(t.entries[0].name, "Mine");+ assert_eq!(t.get("Device").unwrap().uri, "D:/custom/Device.kicad_sym");+ assert_eq!(t.entries[2].name, "Connector");+ // No template at all: still seeds an empty table, never errors.+ let cfg2 = bx.root.join("cfg2");+ let r = heal_lib_tables(&cfg2, None);+ assert_eq!(r["sym-lib-table"]["seeded"], json!(true));+ assert!(std::fs::read_to_string(cfg2.join("fp-lib-table")).unwrap().starts_with("(fp_lib_table"));+ }++ #[test]+ fn install_symbol_merges_into_adom_with_backup() {+ let bx = fake_box("sym");+ // Never-launched KiCad: nothing exists yet.+ let r = install_symbol(&bx.ctx, true, &json!({"fileName": "R.kicad_sym", "fileContent": b64(SYM_R)}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["symbolNames"], json!(["R_Adom"]));+ assert_eq!(r["libraryName"], json!("Adom"));+ assert_eq!(r["editorOpened"], json!(false));+ assert_eq!(r["kicadParser"]["available"], json!(false));+ let lib = PathBuf::from(r["installedPath"].as_str().unwrap());+ assert!(lib.ends_with("Documents/KiCad/10.0/symbols/Adom.kicad_sym"), "{}", lib.display());+ assert_eq!(list_symbols(&lib), vec!["R_Adom".to_string()]);+ // Bootstrap seeded the stock rows and registered Adom.+ let t = LibTable::parse_file(&bx.ctx.config_dir.clone().unwrap().join("sym-lib-table")).unwrap();+ assert!(t.has_library("Device") && t.has_library("Adom"));+ assert!(bx.ctx.user_dir.clone().unwrap().join("footprints").is_dir());+ // Second symbol appends after the last symbol, before (embedded_fonts).+ let r = install_symbol(&bx.ctx, true, &json!({"filePath": {+ let p = bx.root.join("C.kicad_sym"); std::fs::write(&p, SYM_C).unwrap(); norm_path(&p)+ }, "libraryName": "AdomTest", "openEditor": false}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["ignoredArgs"], json!({"libraryName": "AdomTest"}));+ assert!(r["_hint"].as_str().unwrap().contains("not honoured"));+ assert!(!r["output"].as_str().unwrap().contains("Symbol Editor"));+ let text = std::fs::read_to_string(&lib).unwrap();+ assert_eq!(list_symbols(&lib), vec!["R_Adom".to_string(), "C_Adom".to_string()]);+ assert!(text.trim_end().ends_with("(embedded_fonts no)\n)"), "{text}");+ assert!(lib.with_extension("kicad_sym.bak").is_file());+ // Re-install replaces in place, count unchanged.+ let r = install_symbol(&bx.ctx, true, &json!({"fileName": "R.kicad_sym", "fileContent": b64(SYM_R), "quietInstall": true}));+ assert_eq!(r["success"], json!(true));+ assert_eq!(list_symbols(&lib).len(), 2);+ // Errors.+ assert_eq!(install_symbol(&bx.ctx, false, &json!({}))["error"], json!("KiCad not installed"));+ assert_eq!(install_symbol(&bx.ctx, true, &json!({}))["error"], json!("No fileName specified"));+ assert_eq!(install_symbol(&bx.ctx, true, &json!({"fileName": "x"}))["error"], json!("No fileContent specified (or pass filePath)"));+ assert!(install_symbol(&bx.ctx, true, &json!({"filePath": "/nope/x.kicad_sym"}))["error"].as_str().unwrap().starts_with("filePath not found"));+ let bad = install_symbol(&bx.ctx, true, &json!({"fileName": "x", "fileContent": b64("(kicad_pcb (x))")}));+ assert!(bad["error"].as_str().unwrap().contains("not a valid symbol library"));+ let empty = install_symbol(&bx.ctx, true, &json!({"fileName": "x", "fileContent": b64("(kicad_symbol_lib (version 1))")}));+ assert_eq!(empty["error"], json!("No symbols found in the provided file content"));+ }++ #[test]+ fn install_symbol_honours_the_row_first_rule() {+ let bx = fake_box("rowfirst");+ let cfg = bx.ctx.config_dir.clone().unwrap();+ let elsewhere = bx.root.join("OneDrive/Adom.kicad_sym");+ std::fs::create_dir_all(elsewhere.parent().unwrap()).unwrap();+ std::fs::write(&elsewhere, "(kicad_symbol_lib (version 20231120) (generator \"x\")\n)\n").unwrap();+ let mut t = LibTable::empty("sym_lib_table");+ t.add_library(LibEntry::new("Adom", "KiCad", &norm_path(&elsewhere), "", ""));+ std::fs::create_dir_all(&cfg).unwrap();+ t.write_file(&cfg.join("sym-lib-table")).unwrap();+ let r = install_symbol(&bx.ctx, true, &json!({"fileName": "R.kicad_sym", "fileContent": b64(SYM_R)}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["installedPath"], json!(norm_path(&elsewhere)));+ assert_eq!(list_symbols(&elsewhere), vec!["R_Adom".to_string()]);+ assert!(!bx.ctx.user_dir.clone().unwrap().join("symbols/Adom.kicad_sym").exists());+ }++ #[test]+ fn add_symbol_refuses_a_corrupt_library() {+ let bx = fake_box("corrupt");+ let user = bx.ctx.user_dir.clone().unwrap();+ std::fs::create_dir_all(user.join("symbols")).unwrap();+ std::fs::create_dir_all(bx.ctx.config_dir.clone().unwrap()).unwrap();+ std::fs::write(user.join("symbols/Adom.kicad_sym"), "(kicad_symbol_lib (version 1)\n)\n(symbol \"Stray\" (x))\n").unwrap();+ let r = add_symbol(&bx.ctx, SYM_R);+ assert_eq!(r["success"], json!(false));+ assert!(r["error"].as_str().unwrap().starts_with("REFUSING to merge"), "{r}");+ }++ #[test]+ fn install_footprint_into_adom_pretty_with_model() {+ let bx = fake_box("fp");+ let step = bx.root.join("R_0805.step");+ std::fs::write(&step, b"ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n").unwrap();+ let r = install_footprint(&bx.ctx, true, &json!({+ "fileName": "whatever.kicad_mod", "fileContent": b64(MOD_R),+ "model": {"filePath": norm_path(&step)}+ }));+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["footprintName"], json!("R_0805_Adom"));+ assert_eq!(r["libraryName"], json!("Adom"));+ assert_eq!(r["existedBefore"], json!(false));+ assert_eq!(r["unchanged"], json!(false));+ assert_eq!(r["editorOpened"], json!(false));+ let installed = PathBuf::from(r["installedPath"].as_str().unwrap());+ assert!(installed.ends_with("footprints/Adom.pretty/R_0805_Adom.kicad_mod"));+ let model = r["model3d"].as_str().unwrap().to_string();+ assert!(model.ends_with("3dmodels/Adom/R_0805.step"));+ let text = std::fs::read_to_string(&installed).unwrap();+ assert!(text.contains(&format!("(model \"{model}\"")), "{text}");+ assert!(!text.contains("3dmodels/R_0805.step"));+ let t = LibTable::parse_file(&bx.ctx.config_dir.clone().unwrap().join("fp-lib-table")).unwrap();+ assert!(t.has_library("Adom") && t.has_library("Resistor_SMD"));+ assert!(r["output"].as_str().unwrap().contains("registered in fp-lib-table"));+ // Same content again: unchanged, not rewritten.+ let r2 = install_footprint(&bx.ctx, true, &json!({"filePath": norm_path(&installed), "openEditor": false}));+ assert_eq!(r2["existedBefore"], json!(true));+ assert_eq!(r2["unchanged"], json!(true));+ assert_eq!(r2["model3d"], Value::Null);+ // A footprint with no model gets one injected before the closing paren.+ let bare = "(footprint \"Bare\"\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect (at 0 0) (size 1 1))\n)\n";+ let r3 = install_footprint(&bx.ctx, true, &json!({"fileName": "Bare.kicad_mod", "fileContent": b64(bare),+ "model": {"fileName": "Bare.wrl", "fileContent": b64("#VRML V2.0 utf8\n")}}));+ assert_eq!(r3["success"], json!(true), "{r3}");+ let text = std::fs::read_to_string(r3["installedPath"].as_str().unwrap()).unwrap();+ assert!(text.ends_with(" (rotate (xyz 0 0 0))\n )\n)\n"), "{text}");+ assert!(text.contains("3dmodels/Adom/Bare.wrl"));+ // Missing model path is an error before anything is written.+ let r4 = install_footprint(&bx.ctx, true, &json!({"fileName": "X.kicad_mod", "fileContent": b64(bare), "model": {"filePath": "/nope.step"}}));+ assert_eq!(r4["success"], json!(false));+ assert!(!bx.ctx.user_dir.clone().unwrap().join("footprints/Adom.pretty/X.kicad_mod").exists());+ }++ #[test]+ fn install_footprint_row_first_directory() {+ let bx = fake_box("fprow");+ let cfg = bx.ctx.config_dir.clone().unwrap();+ let elsewhere = bx.root.join("OneDrive/Adom.pretty");+ let mut t = LibTable::empty("fp_lib_table");+ t.add_library(LibEntry::new("Adom", "KiCad", &norm_path(&elsewhere), "", ""));+ std::fs::create_dir_all(&cfg).unwrap();+ t.write_file(&cfg.join("fp-lib-table")).unwrap();+ let r = install_footprint(&bx.ctx, true, &json!({"fileName": "r.kicad_mod", "fileContent": b64(MOD_R)}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert!(elsewhere.join("R_0805_Adom.kicad_mod").is_file());+ assert!(!bx.ctx.user_dir.clone().unwrap().join("footprints/Adom.pretty").exists());+ }++ #[test]+ fn install_library_symbol_and_single_part_hint() {+ let bx = fake_box("lib");+ let src = bx.root.join("MyParts.kicad_sym");+ std::fs::write(&src, SYM_R).unwrap();+ let r = install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&src)}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["data"]["name"], json!("MyParts"));+ assert_eq!(r["data"]["table"], json!("sym-lib-table"));+ assert!(r["data"]["path"].as_str().unwrap().ends_with("symbols/MyParts.kicad_sym"));+ assert_eq!(r["singlePartFile"], json!(true));+ assert_eq!(r["symbolNames"], json!(["R_Adom"]));+ assert!(r["_hint"].as_str().unwrap().contains("kicad_install_symbol"));+ let t = LibTable::parse_file(&bx.ctx.config_dir.clone().unwrap().join("sym-lib-table")).unwrap();+ assert_eq!(t.get("MyParts").unwrap().descr, "Installed by Adom Bridge");+ // Idempotent.+ let r = install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&src)}));+ assert_eq!(r["success"], json!(true));+ assert_eq!(r["alreadyRegistered"], json!(true));+ // Errors.+ assert_eq!(install_library(&bx.ctx, true, &json!({}))["error"], json!("No libraryPath specified"));+ assert_eq!(install_library(&bx.ctx, false, &json!({"libraryPath": "x"}))["error"], json!("KiCad not installed"));+ assert!(install_library(&bx.ctx, true, &json!({"libraryPath": "/nope.kicad_sym"}))["error"].as_str().unwrap().starts_with("Library source not found"));+ assert!(install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&src), "libraryType": "weird"}))["error"].as_str().unwrap().starts_with("Unknown library type"));+ }++ #[test]+ fn install_library_footprint_pretty_with_bundle_models() {+ let bx = fake_box("libfp");+ let bundle = bx.root.join("bundle");+ let pretty = bundle.join("Cloud.pretty");+ std::fs::create_dir_all(&pretty).unwrap();+ std::fs::create_dir_all(bundle.join("3dmodels")).unwrap();+ std::fs::write(pretty.join("R_0805_Adom.kicad_mod"), MOD_R).unwrap();+ std::fs::write(pretty.join("Other.kicad_mod"), "(footprint \"Other\" (model \"${KICAD10_3DMODEL_DIR}/x.step\"))").unwrap();+ std::fs::write(bundle.join("3dmodels/R_0805.step"), b"ISO-10303-21;").unwrap();+ let r = install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&pretty), "libraryType": "footprint", "description": "Adom wiki Cloud Library"}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["data"]["name"], json!("Cloud"));+ assert_eq!(r["data"]["modelsInstalled"], json!(1));+ assert_eq!(r["data"]["modelRefsRewritten"], json!(1));+ let user = bx.ctx.user_dir.clone().unwrap();+ let installed = user.join("footprints/Cloud.pretty/R_0805_Adom.kicad_mod");+ let text = std::fs::read_to_string(&installed).unwrap();+ let model = norm_path(&user.join("3dmodels/Cloud.3dshapes/R_0805.step"));+ assert!(text.contains(&format!("(model \"{model}\"")), "{text}");+ // The stock ref and the SOURCE are untouched.+ assert!(std::fs::read_to_string(user.join("footprints/Cloud.pretty/Other.kicad_mod")).unwrap().contains("${KICAD10_3DMODEL_DIR}"));+ assert!(std::fs::read_to_string(pretty.join("R_0805_Adom.kicad_mod")).unwrap().contains("3dmodels/R_0805.step"));+ let t = LibTable::parse_file(&bx.ctx.config_dir.clone().unwrap().join("fp-lib-table")).unwrap();+ assert_eq!(t.get("Cloud").unwrap().descr, "Adom wiki Cloud Library");+ // A single .kicad_mod is wrapped in <name>.pretty and renamed after its declared name.+ let one = bx.root.join("misnamed.kicad_mod");+ std::fs::write(&one, MOD_R).unwrap();+ let step = bx.root.join("body.step");+ std::fs::write(&step, b"ISO-10303-21;").unwrap();+ let r = install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&one), "libraryType": "footprint", "libraryName": "Solo", "model3dPath": norm_path(&step)}));+ assert_eq!(r["success"], json!(true), "{r}");+ let f = user.join("footprints/Solo.pretty/R_0805_Adom.kicad_mod");+ assert!(f.is_file());+ let text = std::fs::read_to_string(&f).unwrap();+ assert!(text.contains("3dmodels/Solo.3dshapes/body.step"), "{text}");+ assert!(!text.contains("3dmodels/R_0805.step"));+ assert!(r["data"]["model3dPath"].as_str().unwrap().ends_with("Solo.3dshapes/body.step"));+ let r = install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&bx.root.join("body.step")), "libraryType": "footprint"}));+ assert!(r["error"].as_str().unwrap().starts_with("Invalid footprint source"));+ }++ /// A stored (method 0) zip built by hand: enough for the reader in pcm.rs.+ fn stored_zip(files: &[(&str, &[u8])]) -> Vec<u8> {+ fn crc32(data: &[u8]) -> u32 {+ let mut c = 0xFFFF_FFFFu32;+ for &b in data {+ c ^= b as u32;+ for _ in 0..8 {+ c = if c & 1 != 0 { (c >> 1) ^ 0xEDB8_8320 } else { c >> 1 };+ }+ }+ !c+ }+ let mut out = Vec::new();+ let mut cd = Vec::new();+ for (name, data) in files {+ let off = out.len() as u32;+ let crc = crc32(data);+ out.extend_from_slice(b"PK\x03\x04");+ out.extend_from_slice(&[20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);+ out.extend_from_slice(&crc.to_le_bytes());+ out.extend_from_slice(&(data.len() as u32).to_le_bytes());+ out.extend_from_slice(&(data.len() as u32).to_le_bytes());+ out.extend_from_slice(&(name.len() as u16).to_le_bytes());+ out.extend_from_slice(&0u16.to_le_bytes());+ out.extend_from_slice(name.as_bytes());+ out.extend_from_slice(data);+ cd.extend_from_slice(b"PK\x01\x02");+ cd.extend_from_slice(&[20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);+ cd.extend_from_slice(&crc.to_le_bytes());+ cd.extend_from_slice(&(data.len() as u32).to_le_bytes());+ cd.extend_from_slice(&(data.len() as u32).to_le_bytes());+ cd.extend_from_slice(&(name.len() as u16).to_le_bytes());+ cd.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);+ cd.extend_from_slice(&off.to_le_bytes());+ cd.extend_from_slice(name.as_bytes());+ }+ let cd_off = out.len() as u32;+ out.extend_from_slice(&cd);+ out.extend_from_slice(b"PK\x05\x06");+ out.extend_from_slice(&[0, 0, 0, 0]);+ out.extend_from_slice(&(files.len() as u16).to_le_bytes());+ out.extend_from_slice(&(files.len() as u16).to_le_bytes());+ out.extend_from_slice(&(cd.len() as u32).to_le_bytes());+ out.extend_from_slice(&cd_off.to_le_bytes());+ out.extend_from_slice(&[0, 0]);+ out+ }++ #[test]+ fn bundle_installs_symbols_footprints_and_models() {+ let bx = fake_box("bundle");+ let zip = stored_zip(&[+ ("Cloud/Cloud.kicad_sym", SYM_R.as_bytes()),+ ("Cloud/Cloud.pretty/R_0805_Adom.kicad_mod", MOD_R.as_bytes()),+ ("Cloud/3dmodels/R_0805.step", b"ISO-10303-21;"),+ ("Cloud/3dmodels/", b""),+ ]);+ let zp = bx.root.join("Cloud.zip");+ std::fs::write(&zp, &zip).unwrap();+ let r = install_library_bundle(&bx.ctx, true, &json!({"zipPath": norm_path(&zp), "capture": true}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["data"]["libraryName"], json!("Cloud"));+ assert_eq!(r["data"]["symbols"], json!(1));+ assert_eq!(r["data"]["footprints"], json!(1));+ assert_eq!(r["data"]["modelsInstalled"], json!(1));+ assert_eq!(r["data"]["modelRefsRewritten"], json!(1));+ assert_eq!(r["data"]["kicadRunning"], Value::Null);+ assert_eq!(r["data"]["restartNeeded"], json!(true));+ assert!(r["proofSkipped"].as_str().unwrap().contains("window-phase"));+ let user = bx.ctx.user_dir.clone().unwrap();+ assert!(user.join("symbols/Cloud.kicad_sym").is_file());+ assert!(user.join("footprints/Cloud.pretty/R_0805_Adom.kicad_mod").is_file());+ assert!(user.join("3dmodels/Cloud.3dshapes/R_0805.step").is_file());+ let cfg = bx.ctx.config_dir.clone().unwrap();+ assert!(LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap().has_library("Cloud"));+ assert!(LibTable::parse_file(&cfg.join("fp-lib-table")).unwrap().has_library("Cloud"));+ // Guards.+ assert_eq!(install_library_bundle(&bx.ctx, true, &json!({}))["error"], json!("No zipPath specified"));+ assert!(install_library_bundle(&bx.ctx, true, &json!({"zipPath": norm_path(&zp), "libraryName": "-bad"}))["error"].as_str().unwrap().contains("not usable"));+ let evil = bx.root.join("evil.zip");+ std::fs::write(&evil, stored_zip(&[("../escape.kicad_sym", b"x")])).unwrap();+ let r = install_library_bundle(&bx.ctx, true, &json!({"zipPath": norm_path(&evil)}));+ assert!(r["error"].as_str().unwrap().contains("unsafe zip entry"), "{r}");+ let nosym = bx.root.join("nosym.zip");+ std::fs::write(&nosym, stored_zip(&[("readme.txt", b"x")])).unwrap();+ assert_eq!(install_library_bundle(&bx.ctx, true, &json!({"zipPath": norm_path(&nosym)}))["error"], json!("No .kicad_sym found in the bundle"));+ let notzip = bx.root.join("not.zip");+ std::fs::write(¬zip, b"hello").unwrap();+ assert!(install_library_bundle(&bx.ctx, true, &json!({"zipPath": norm_path(¬zip)}))["error"].as_str().unwrap().starts_with("Could not unpack the bundle"));+ }++ #[test]+ fn heal_merge_repoint_and_no_split() {+ let bx = fake_box("healsplit");+ let cfg = bx.ctx.config_dir.clone().unwrap();+ let user = bx.ctx.user_dir.clone().unwrap();+ // No split yet: nothing to do.+ std::fs::create_dir_all(&cfg).unwrap();+ let r = heal_split(&bx.ctx, "merge", false);+ assert_eq!(r["success"], json!(true));+ assert_eq!(r["healed"], json!(false));+ assert_eq!(r["reason"], json!("no split"));+ assert_eq!(r["split"], json!(false));+ // Now split: the row points elsewhere, the default file has an orphan symbol+ // plus a trailing block after the closer (the old appender bug).+ let reg = bx.root.join("elsewhere/Adom.kicad_sym");+ std::fs::create_dir_all(reg.parent().unwrap()).unwrap();+ std::fs::write(®, "(kicad_symbol_lib (version 20231120) (generator \"x\")\n\t(symbol \"R_Adom\" (property \"Reference\" \"R\" (at 0 0 0)))\n)\n").unwrap();+ let mut t = LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap();+ t.remove_library("Adom");+ t.add_library(LibEntry::new("Adom", "KiCad", &norm_path(®), "", ""));+ t.write_file(&cfg.join("sym-lib-table")).unwrap();+ std::fs::create_dir_all(user.join("symbols")).unwrap();+ let orphan = user.join("symbols/Adom.kicad_sym");+ std::fs::write(&orphan, format!("{}(symbol \"Trail\" (property \"Reference\" \"T\" (at 0 0 0)))\n(symbol \"Broken\" (x\n", SYM_C)).unwrap();+ let dry = heal_split(&bx.ctx, "merge", true);+ assert_eq!(dry["success"], json!(true), "{dry}");+ assert_eq!(dry["dryRun"], json!(true));+ assert_eq!(dry["wouldAdd"], json!(["C_Adom", "Trail"]));+ assert_eq!(dry["unparseableInSource"], json!(["Broken"]));+ assert_eq!(list_symbols(®).len(), 1);+ let r = heal_split(&bx.ctx, "merge", false);+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["healed"], json!(true));+ assert_eq!(r["added"], json!(["C_Adom", "Trail"]));+ assert_eq!(r["failed"], json!([]));+ assert_eq!(r["registeredSymbolsAfter"], json!(3));+ assert_eq!(list_symbols(®), vec!["R_Adom".to_string(), "C_Adom".to_string(), "Trail".to_string()]);+ assert!(reg.with_extension("kicad_sym.bak").is_file());+ // The orphan is untouched.+ assert!(std::fs::read_to_string(&orphan).unwrap().contains("Broken"));+ // Unknown mode.+ let r = heal_split(&bx.ctx, "nuke", false);+ assert_eq!(r["success"], json!(false));+ assert_eq!(r["allowed"], json!(["merge", "repoint", "prune"]));+ // Repoint: dry run leaves the row, the real run rewrites it.+ let r = heal_split(&bx.ctx, "repoint", true);+ assert_eq!(r["healed"], json!(false));+ assert_eq!(LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap().get("Adom").unwrap().uri, norm_path(®));+ let r = heal_split(&bx.ctx, "repoint", false);+ assert_eq!(r["healed"], json!(true));+ assert_eq!(r["rowNowPointsAt"], json!(norm_path(&orphan)));+ assert_eq!(LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap().get("Adom").unwrap().uri, norm_path(&orphan));+ // Prune without kicad-cli: an honest refusal, nothing written.+ let r = heal_split(&bx.ctx, "prune", false);+ assert_eq!(r["success"], json!(false));+ assert_eq!(r["kicadParser"]["available"], json!(false));+ assert!(!orphan.with_extension("kicad_sym.pruned.bak").exists());+ }++ #[test]+ fn make_part_project_and_export_part() {+ let bx = fake_box("proj");+ assert_eq!(install_symbol(&bx.ctx, true, &json!({"fileName": "R.kicad_sym", "fileContent": b64(SYM_R), "openEditor": false}))["success"], json!(true));+ let step = bx.root.join("R_0805_Adom.step");+ std::fs::write(&step, b"ISO-10303-21;").unwrap();+ assert_eq!(install_footprint(&bx.ctx, true, &json!({"fileName": "r.kicad_mod", "fileContent": b64(MOD_R), "openEditor": false, "model": {"filePath": norm_path(&step)}}))["success"], json!(true));++ let r = make_part_project(&bx.ctx, &json!({"partName": "R_Adom", "footprintName": "R_0805_Adom"}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["projectName"], json!("r-adom-demo"));+ assert_eq!(r["reference"], json!("R1"));+ let proj = PathBuf::from(r["projectFile"].as_str().unwrap());+ assert!(proj.ends_with("Documents/r-adom-demo/r-adom-demo.kicad_pro"), "{}", proj.display());+ let sch = std::fs::read_to_string(r["schematicFile"].as_str().unwrap()).unwrap();+ assert!(sch.contains("(symbol \"Adom:R_Adom\""));+ assert!(sch.contains("(lib_id \"Adom:R_Adom\") (at 127.0 80.0 0)"));+ assert!(sch.contains("(property \"Reference\" \"R1\" (at 127.0 72.5 0)"));+ assert!(sch.contains("(property \"Footprint\" \"Adom:R_0805_Adom\""));+ // Two pins (0, 3.81, 270) and (0, -3.81, 90): stub wires going away from the body.+ assert!(sch.contains("(wire (pts (xy 127.0 76.19) (xy 127.0 70.19))"), "{sch}");+ assert!(sch.contains("(wire (pts (xy 127.0 83.81) (xy 127.0 89.81))"), "{sch}");+ assert_eq!(sch.matches('(').count(), sch.matches(')').count());+ let pcb = std::fs::read_to_string(r["boardFile"].as_str().unwrap()).unwrap();+ assert!(pcb.contains("(footprint \"Adom:R_0805_Adom\""));+ assert!(pcb.contains("(layer \"F.Cu\")\n (at 108.0 68.0)"));+ assert!(pcb.contains("(property \"Reference\" \"R1\""));+ assert!(pcb.contains("(model \""));+ assert!(pcb.contains("(gr_line (start 104.32 65.05) (end 111.68 65.05)"), "{pcb}");+ assert!(pcb.contains("(segment (start 107.088 68.0) (end 105.12 68.0)"), "{pcb}");+ assert!(pcb.contains("(segment (start 108.912 68.0) (end 110.88 68.0)"), "{pcb}");+ assert_eq!(pcb.matches('(').count(), pcb.matches(')').count());+ let pro = std::fs::read_to_string(&proj).unwrap();+ assert_eq!(pro, "{\"board\":{},\"meta\":{\"filename\":\"r-adom-demo.kicad_pro\",\"version\":1},\"schematic\":{},\"sheets\":[]}");+ // Errors.+ assert_eq!(make_part_project(&bx.ctx, &json!({}))["error"], json!("partName required"));+ assert_eq!(make_part_project(&bx.ctx, &json!({"partName": "Nope"}))["error"], json!("Symbol 'Nope' not in the Adom library"));+ assert_eq!(make_part_project(&bx.ctx, &json!({"partName": "R_Adom", "footprintName": "Nope"}))["error"], json!("Footprint 'Nope' not in Adom.pretty"));++ let r = export_part(&bx.ctx, &json!({"partName": "R_Adom", "footprintName": "R_0805_Adom"}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["errors"], json!([]));+ assert!(r["outputDir"].as_str().unwrap().ends_with("Documents/adom-part-exports/R_Adom"));+ assert_eq!(r["exports"]["symbol"]["single"], json!(true));+ assert_eq!(r["exports"]["symbol"]["symbolsInLibrary"], json!(["R_Adom"]));+ assert_eq!(r["exports"]["footprint"]["single"], json!(true));+ assert!(r["exports"]["footprint"]["path"].as_str().unwrap().ends_with("R_Adom-footprint-only.pretty/R_0805_Adom.kicad_mod"));+ assert!(r["exports"]["model"]["path"].as_str().unwrap().ends_with("R_Adom-model-only.step"));+ assert_eq!(r["exports"]["model"]["bytes"], json!(13));+ assert_eq!(r["output"], json!("one-off exports for R_Adom: footprint, model, symbol"));+ let one = std::fs::read_to_string(r["exports"]["symbol"]["path"].as_str().unwrap()).unwrap();+ assert!(one.starts_with("(kicad_symbol_lib\n (version 20231120)\n (generator \"adom-export-part\")\n"));+ // Partial: only the symbol, and a missing part reports errors with success:false.+ let r = export_part(&bx.ctx, &json!({"partName": "R_Adom", "what": "symbol", "outputDir": norm_path(&bx.root.join("exp"))}));+ assert_eq!(r["success"], json!(true));+ assert_eq!(r["exports"].as_object().unwrap().len(), 1);+ let r = export_part(&bx.ctx, &json!({"partName": "Nope"}));+ assert_eq!(r["success"], json!(false));+ assert_eq!(r["errors"].as_array().unwrap().len(), 3);+ assert_eq!(export_part(&bx.ctx, &json!({}))["error"], json!("partName required"));+ }++ #[test]+ fn footprint_text_helpers() {+ assert_eq!(footprint_name_in(MOD_R).as_deref(), Some("R_0805_Adom"));+ assert_eq!(footprint_name_in("(module \"X\")"), None);+ assert_eq!(pads(MOD_R), vec![(-0.9125, 0.0), (0.9125, 0.0)]);+ assert_eq!(pins(SYM_R), vec![(0.0, 3.81, 270.0), (0.0, -3.81, 90.0)]);+ assert!(coords(MOD_R).contains(&(-1.68, -0.95)));+ let refs = model_refs(MOD_R);+ assert_eq!(refs.len(), 1);+ assert_eq!(refs[0].1, "3dmodels/R_0805.step");+ assert_eq!(py_float(127.0), "127.0");+ assert_eq!(py_float(72.5), "72.5");+ assert_eq!(py_float(-0.9125), "-0.9125");+ assert!(name_ok("Adom Cloud_1.0-x") && !name_ok(" x") && !name_ok("") && !name_ok(&"a".repeat(65)));+ let w = wire_model_into_footprint("(footprint \"X\"\n (model \"old.step\" (offset (xyz 0 0 0)))\n)\n", "C:/new.step");+ assert!(w.contains("(model \"C:/new.step\" (offset"), "{w}");+ assert!(!w.contains("old.step"));+ }+}
rust/crates/kicad-core/src/kicad_common.rs+425−1@@ -1 +1,425 @@-//! Placeholder: filled in by the phase 1 port.+//! kicad_common.json: the IPC API server switch (port of handlers/ipc_api.py and the+//! read side in capabilities.py `_api_server_enabled`), plus the first-run settings+//! seeding from kicad_detect.ensure_win_user_config (the JSON half only; the lib-table+//! half lives with the library group).+//!+//! KiCad reads kicad_common.json at start, so flipping `api.enable_server` needs KiCad+//! closed and relaunched. Nothing here restarts KiCad; `needsRestart` says so.++use std::path::{Path, PathBuf};++use serde_json::{json, Map, Value};++/// Every `<root>/<version>/kicad_common.json` under a settings root, newest KiCad+/// version first (Python `_common_files`). A directory name that is not dotted+/// integers sorts last, like the Python `(0,)` key.+pub fn common_files(root: &Path) -> Vec<PathBuf> {+ let Ok(rd) = std::fs::read_dir(root) else {+ return Vec::new();+ };+ let mut found: Vec<PathBuf> = rd+ .flatten()+ .map(|e| e.path())+ .filter(|p| p.is_dir())+ .map(|p| p.join("kicad_common.json"))+ .filter(|p| p.is_file())+ .collect();+ found.sort_by(|a, b| version_key(b).cmp(&version_key(a)).then_with(|| a.cmp(b)));+ found+}++fn version_key(p: &Path) -> Vec<u32> {+ let name = p.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+ let parts: Option<Vec<u32>> = name.split('.').map(|x| x.parse::<u32>().ok()).collect();+ parts.unwrap_or_else(|| vec![0])+}++/// Where the per-version settings dirs live (`%APPDATA%/kicad` on Windows,+/// `~/.config/kicad` on Linux, `~/Library/Preferences/kicad` on macOS): the parent of+/// whatever the platform reports as the config dir of any version.+pub fn settings_root() -> Option<PathBuf> {+ kicad_platform::native().config_dir("0.0").and_then(|p| p.parent().map(Path::to_path_buf))+}++/// Read side (capabilities.py `_api_server_enabled`): `api.enable_server` from+/// `<config_dir>/kicad_common.json`. None when the file or the field is missing or+/// the file does not parse (older KiCad, or never launched).+pub fn api_server_enabled(config_dir: Option<&Path>) -> Option<bool> {+ let common = config_dir?.join("kicad_common.json");+ let text = std::fs::read_to_string(common).ok()?;+ let data: Value = serde_json::from_str(&text).ok()?;+ let api = data.get("api")?.as_object()?;+ let v = api.get("enable_server")?;+ Some(truthy(v))+}++/// Python `bool(x)` on a JSON value.+fn truthy(v: &Value) -> bool {+ match v {+ Value::Null => false,+ Value::Bool(b) => *b,+ Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(true),+ Value::String(s) => !s.is_empty(),+ Value::Array(a) => !a.is_empty(),+ Value::Object(o) => !o.is_empty(),+ }+}++/// kicad_ipc_api {enable?: bool} against a settings root (Python `handle_ipc_api`).+/// Same keys as the Python: file, kicadSettingsVersion, enabled, changed, needsRestart,+/// and on a write backup, changedAt. `now_utc` is the ISO timestamp (injected so tests+/// are deterministic).+pub fn ipc_api(root: Option<&Path>, args: &Value, now_utc: &str) -> Value {+ let files = root.map(common_files).unwrap_or_default();+ let Some(target) = files.first() else {+ return json!({+ "success": false, "errorCode": "settings_not_found",+ "error": "No kicad_common.json found; start KiCad once so it writes its settings",+ "_hint": "KiCad creates <settings>/<version>/kicad_common.json on first launch.",+ });+ };+ let text = match std::fs::read_to_string(target) {+ Ok(t) => t,+ Err(e) => return json!({"success": false, "errorCode": "settings_unreadable", "error": format!("{}: {e}", target.display())}),+ };+ let mut settings: Value = match serde_json::from_str(&text) {+ Ok(v) => v,+ Err(e) => return json!({"success": false, "errorCode": "settings_unreadable", "error": format!("{}: {e}", target.display())}),+ };+ if !settings.is_object() {+ return json!({"success": false, "errorCode": "settings_unreadable", "error": format!("{}: top level is not a JSON object", target.display())});+ }+ let before = settings.get("api").and_then(Value::as_object).and_then(|a| a.get("enable_server")).map(truthy).unwrap_or(false);+ let version = target.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+ let enable = match args.get("enable") {+ None | Some(Value::Null) => None,+ Some(Value::Bool(b)) => Some(*b),+ Some(_) => return json!({"success": false, "errorCode": "invalid_arg", "error": "enable must be true or false"}),+ };+ let mut result = json!({+ "success": true,+ "file": target.to_string_lossy().replace('\\', "/"),+ "kicadSettingsVersion": version,+ "enabled": before,+ "changed": false,+ "needsRestart": false,+ });+ match enable {+ Some(e) if e != before => {+ let backup = target.with_file_name(format!("{}.adom-bak", target.file_name().unwrap_or_default().to_string_lossy()));+ if let Err(e) = std::fs::copy(target, &backup) {+ return json!({"success": false, "errorCode": "backup_failed", "error": format!("cannot back up {}: {e}", target.display()),+ "_hint": "The settings file was not touched. Check the directory is writable."});+ }+ let obj = settings.as_object_mut().unwrap();+ if !obj.get("api").map(Value::is_object).unwrap_or(false) {+ obj.insert("api".into(), Value::Object(Map::new()));+ }+ obj["api"]["enable_server"] = json!(e);+ let tmp = target.with_file_name(format!("{}.adom-tmp", target.file_name().unwrap_or_default().to_string_lossy()));+ let body = serde_json::to_string_pretty(&settings).unwrap_or_default() + "\n";+ if let Err(err) = std::fs::write(&tmp, body).and_then(|_| std::fs::rename(&tmp, target)) {+ let _ = std::fs::remove_file(&tmp);+ return json!({"success": false, "errorCode": "settings_write_failed", "error": format!("cannot write {}: {err}", target.display()),+ "backup": backup.to_string_lossy().replace('\\', "/"),+ "_hint": "The original is intact (atomic replace). Check the directory is writable and KiCad is not holding the file."});+ }+ result["enabled"] = json!(e);+ result["changed"] = json!(true);+ result["needsRestart"] = json!(true);+ result["backup"] = json!(backup.to_string_lossy().replace('\\', "/"));+ result["changedAt"] = json!(now_utc);+ result["kicadRunning"] = Value::Null;+ result["_hint"] = json!("Written to kicad_common.json. KiCad reads it at start: kicad_close, then launch again, then kicad_routing_state will connect. A running KiCad that saves its preferences on exit overwrites this change, so close KiCad first when in doubt (the native build cannot tell whether KiCad is running: kicadRunning is null).");+ }+ _ => {+ result["_hint"] = json!(format!(+ "IPC API server is {}. Pass enable:true|false to change it (KiCad must then be restarted).",+ if before { "enabled" } else { "disabled" }+ ));+ }+ }+ result+}++/// UTC timestamp in the Python format `%Y-%m-%dT%H:%M:%SZ`, no chrono needed.+pub fn now_utc_iso() -> String {+ let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);+ let days = secs / 86_400;+ let rem = secs % 86_400;+ let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);+ // Civil-from-days (Howard Hinnant), proleptic Gregorian.+ let z = days as i64 + 719_468;+ let era = z.div_euclid(146_097);+ let doe = z.rem_euclid(146_097);+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 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}T{h:02}:{m:02}:{s:02}Z")+}++/// What first-run seeding did: which files it wrote and which it left alone.+#[derive(Clone, Debug, Default, PartialEq, Eq)]+pub struct Seeded {+ pub written: Vec<String>,+ pub skipped: Vec<String>,+ pub errors: Vec<String>,+}++/// The three documents kicad_detect.ensure_win_user_config seeds. KiCad merges its+/// own defaults over whatever is written, so a partial document is safe.+pub fn first_run_documents() -> Vec<(&'static str, Value)> {+ vec![+ (+ "kicad_common.json",+ json!({+ "meta": {"filename": "kicad_common", "version": 3},+ "graphics": {"canvas_type": 1},+ "api": {"enable_server": true},+ "do_not_show_again": {+ "zone_fill_warning": true,+ "env_var_overwrite_warning": true,+ "scaled_3d_models_warning": true,+ "data_collection_prompt": true,+ "update_check_prompt": true,+ "migrate_wrl_prompt": true,+ },+ }),+ ),+ (+ "kicad.json",+ json!({+ "meta": {"filename": "kicad", "version": 0},+ "system": {"check_for_kicad_updates": false},+ "pcm": {"check_for_updates": false},+ }),+ ),+ (+ "eeschema.json",+ json!({+ "meta": {"filename": "eeschema", "version": 0},+ "system": {"never_show_rescue_dialog": true},+ "appearance": {+ "show_illegal_symbol_lib_dialog": false,+ "show_sexpr_file_convert_warning": false,+ "show_sheet_filename_case_sensitivity_dialog": false,+ },+ }),+ ),+ ]+}++/// Seed the first-run settings files into a per-version config dir, each written ONLY+/// if absent (never on a settings file the user already has). Creates the dir when+/// missing. Never panics; failures land in `Seeded.errors`.+pub fn seed_first_run(config_dir: &Path) -> Seeded {+ let mut out = Seeded::default();+ if let Err(e) = std::fs::create_dir_all(config_dir) {+ out.errors.push(format!("{}: {e}", config_dir.display()));+ return out;+ }+ for (name, doc) in first_run_documents() {+ let path = config_dir.join(name);+ if path.exists() {+ out.skipped.push(name.to_string());+ continue;+ }+ let body = serde_json::to_string_pretty(&doc).unwrap_or_default() + "\n";+ match std::fs::write(&path, body) {+ Ok(()) => out.written.push(name.to_string()),+ Err(e) => out.errors.push(format!("{name}: {e}")),+ }+ }+ out+}++#[cfg(test)]+mod tests {+ use super::*;++ 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-common-{}-{name}", std::process::id()));+ let _ = std::fs::remove_dir_all(&p);+ std::fs::create_dir_all(&p).unwrap();+ Tmp(p)+ }+ fn read_json(p: &Path) -> Value {+ serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap()+ }++ #[test]+ fn seeds_only_absent_files() {+ let t = tmp("seed");+ let cfg = t.0.join("kicad").join("10.0");+ // Pre-existing user file must never be touched.+ std::fs::create_dir_all(&cfg).unwrap();+ std::fs::write(cfg.join("kicad.json"), "{\"system\": {\"check_for_kicad_updates\": true}}\n").unwrap();++ let s = seed_first_run(&cfg);+ assert_eq!(s.written, vec!["kicad_common.json".to_string(), "eeschema.json".to_string()]);+ assert_eq!(s.skipped, vec!["kicad.json".to_string()]);+ assert!(s.errors.is_empty());++ let common = read_json(&cfg.join("kicad_common.json"));+ assert_eq!(common["api"]["enable_server"], json!(true));+ assert_eq!(common["graphics"]["canvas_type"], json!(1));+ assert_eq!(common["meta"]["filename"], json!("kicad_common"));+ let nags = common["do_not_show_again"].as_object().unwrap();+ for k in ["zone_fill_warning", "env_var_overwrite_warning", "scaled_3d_models_warning", "data_collection_prompt", "update_check_prompt", "migrate_wrl_prompt"] {+ assert_eq!(nags.get(k), Some(&json!(true)), "{k}");+ }+ let ee = read_json(&cfg.join("eeschema.json"));+ assert_eq!(ee["system"]["never_show_rescue_dialog"], json!(true));+ assert_eq!(ee["appearance"]["show_illegal_symbol_lib_dialog"], json!(false));+ assert_eq!(ee["appearance"]["show_sexpr_file_convert_warning"], json!(false));+ assert_eq!(ee["appearance"]["show_sheet_filename_case_sensitivity_dialog"], json!(false));+ // The user's kicad.json is exactly what it was.+ let k = read_json(&cfg.join("kicad.json"));+ assert_eq!(k["system"]["check_for_kicad_updates"], json!(true));+ assert!(k.get("pcm").is_none());++ // Second run: everything skipped, nothing rewritten.+ let s2 = seed_first_run(&cfg);+ assert!(s2.written.is_empty());+ assert_eq!(s2.skipped.len(), 3);+ // Read side sees the seeded switch.+ assert_eq!(api_server_enabled(Some(&cfg)), Some(true));+ }++ #[test]+ fn seed_creates_missing_config_dir() {+ let t = tmp("seed-mkdir");+ let cfg = t.0.join("kicad").join("9.0");+ let s = seed_first_run(&cfg);+ assert_eq!(s.written.len(), 3);+ let k = read_json(&cfg.join("kicad.json"));+ assert_eq!(k["system"]["check_for_kicad_updates"], json!(false));+ assert_eq!(k["pcm"]["check_for_updates"], json!(false));+ }++ #[test]+ fn read_side_matches_python() {+ let t = tmp("read");+ assert_eq!(api_server_enabled(None), None);+ assert_eq!(api_server_enabled(Some(&t.0)), None);+ let f = t.0.join("kicad_common.json");+ std::fs::write(&f, "{\"api\": {}}").unwrap();+ assert_eq!(api_server_enabled(Some(&t.0)), None);+ std::fs::write(&f, "{\"api\": {\"enable_server\": false}}").unwrap();+ assert_eq!(api_server_enabled(Some(&t.0)), Some(false));+ std::fs::write(&f, "{\"api\": {\"enable_server\": 1}}").unwrap();+ assert_eq!(api_server_enabled(Some(&t.0)), Some(true));+ std::fs::write(&f, "not json").unwrap();+ assert_eq!(api_server_enabled(Some(&t.0)), None);+ }++ #[test]+ fn common_files_newest_version_first() {+ let t = tmp("versions");+ for v in ["8.0", "10.0", "9.0", "notes"] {+ let d = t.0.join(v);+ std::fs::create_dir_all(&d).unwrap();+ std::fs::write(d.join("kicad_common.json"), "{}").unwrap();+ }+ std::fs::create_dir_all(t.0.join("11.0")).unwrap(); // no file: not listed+ let names: Vec<String> = common_files(&t.0).iter().map(|p| p.parent().unwrap().file_name().unwrap().to_string_lossy().to_string()).collect();+ assert_eq!(names, vec!["10.0", "9.0", "8.0", "notes"]);+ assert!(common_files(&t.0.join("missing")).is_empty());+ }++ #[test]+ fn ipc_api_status_and_flip() {+ let t = tmp("ipc");+ let d = t.0.join("10.0");+ std::fs::create_dir_all(&d).unwrap();+ let f = d.join("kicad_common.json");+ std::fs::write(&f, "{\n \"meta\": {\"version\": 3},\n \"api\": {\"enable_server\": false},\n \"graphics\": {\"canvas_type\": 1}\n}\n").unwrap();++ // Read-only status.+ let r = ipc_api(Some(&t.0), &json!({}), "2026-09-11T00:00:00Z");+ assert_eq!(r["success"], json!(true));+ assert_eq!(r["enabled"], json!(false));+ assert_eq!(r["changed"], json!(false));+ assert_eq!(r["needsRestart"], json!(false));+ assert_eq!(r["kicadSettingsVersion"], json!("10.0"));+ assert!(r.get("backup").is_none());+ assert!(r["_hint"].as_str().unwrap().contains("disabled"));++ // Same value: no write, no backup.+ let r = ipc_api(Some(&t.0), &json!({"enable": false}), "2026-09-11T00:00:00Z");+ assert_eq!(r["changed"], json!(false));+ assert!(!d.join("kicad_common.json.adom-bak").exists());++ // Bad type.+ let r = ipc_api(Some(&t.0), &json!({"enable": "yes"}), "2026-09-11T00:00:00Z");+ assert_eq!(r["errorCode"], json!("invalid_arg"));++ // Flip on: backup, atomic replace, other keys preserved, tmp gone.+ let r = ipc_api(Some(&t.0), &json!({"enable": true}), "2026-09-11T00:00:00Z");+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["enabled"], json!(true));+ assert_eq!(r["changed"], json!(true));+ assert_eq!(r["needsRestart"], json!(true));+ assert_eq!(r["changedAt"], json!("2026-09-11T00:00:00Z"));+ assert!(r["kicadRunning"].is_null());+ let bak = d.join("kicad_common.json.adom-bak");+ assert!(bak.exists());+ assert_eq!(r["backup"].as_str().unwrap(), bak.to_string_lossy().replace('\\', "/"));+ assert_eq!(read_json(&bak)["api"]["enable_server"], json!(false));+ let after = read_json(&f);+ assert_eq!(after["api"]["enable_server"], json!(true));+ assert_eq!(after["graphics"]["canvas_type"], json!(1));+ assert_eq!(after["meta"]["version"], json!(3));+ assert!(!d.join("kicad_common.json.adom-tmp").exists());+ assert!(std::fs::read_to_string(&f).unwrap().ends_with('\n'));++ // Status now reports enabled; api_server_enabled agrees.+ let r = ipc_api(Some(&t.0), &json!({}), "x");+ assert_eq!(r["enabled"], json!(true));+ assert_eq!(api_server_enabled(Some(&d)), Some(true));+ }++ #[test]+ fn ipc_api_adds_api_block_when_missing() {+ let t = tmp("ipc-noapi");+ let d = t.0.join("9.0");+ std::fs::create_dir_all(&d).unwrap();+ std::fs::write(d.join("kicad_common.json"), "{\"api\": 5}").unwrap(); // not a dict: replaced, like setdefault would not; Python would crash here+ let r = ipc_api(Some(&t.0), &json!({"enable": true}), "x");+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(read_json(&d.join("kicad_common.json"))["api"]["enable_server"], json!(true));+ }++ #[test]+ fn ipc_api_errors() {+ let t = tmp("ipc-err");+ let r = ipc_api(Some(&t.0), &json!({}), "x");+ assert_eq!(r["errorCode"], json!("settings_not_found"));+ assert_eq!(ipc_api(None, &json!({}), "x")["errorCode"], json!("settings_not_found"));+ let d = t.0.join("10.0");+ std::fs::create_dir_all(&d).unwrap();+ std::fs::write(d.join("kicad_common.json"), "{ not json").unwrap();+ let r = ipc_api(Some(&t.0), &json!({}), "x");+ assert_eq!(r["errorCode"], json!("settings_unreadable"));+ }++ #[test]+ fn utc_timestamp_format() {+ let s = now_utc_iso();+ assert_eq!(s.len(), 20, "{s}");+ assert!(s.ends_with('Z'));+ assert_eq!(&s[4..5], "-");+ assert_eq!(&s[10..11], "T");+ assert!(s.starts_with("20"));+ }+}
rust/crates/kicad-core/src/lib.rs+1@@ -13,3 +13,4 @@ pub mod schematic; pub mod kicad_common; pub mod render; pub mod bridge_log;+pub mod install;
rust/crates/kicad-core/src/render.rs+344−1@@ -1 +1,344 @@-//! Placeholder: filled in by the phase 1 port.+//! `kicad-cli pcb render` (silkscreen PNGs) and the molecule export pack, ported from+//! handlers/export_molecule.py.+//!+//! The Adom molecule pipeline (container side) is: STEP, then step2glb (color-preserving+//! GLB), then `adom-molecule create --glb --pcb --sch --pro`. adom-molecule REQUIRES+//! four files for a KiCad source: .glb, .kicad_pcb, .kicad_sch and .kicad_pro. The GLB+//! comes from the STEP in the container; everything else lives on the desktop next to+//! the board. `export_molecule` does the whole desktop half in one call and returns a+//! manifest of host paths plus the exact container commands to finish.+//!+//! Every kicad-cli call goes through a `Runner` so the logic is unit-testable without+//! kicad-cli; the verb passes `cli_runner(path)`, which wraps `crate::cli::run`.++use std::path::{Path, PathBuf};+use std::time::Duration;++use serde_json::{json, Map, Value};++use crate::cli::{self, CliResult};++/// Runs kicad-cli with the given argv (no program name) and deadline.+pub type Runner<'a> = &'a dyn Fn(&[String], Duration) -> CliResult;++/// The real thing: `crate::cli::run` bound to one kicad-cli path.+pub fn cli_runner(cli: &Path) -> impl Fn(&[String], Duration) -> CliResult + '_ {+ move |argv: &[String], timeout: Duration| {+ let argv_ref: Vec<&str> = argv.iter().map(String::as_str).collect();+ cli::run(cli, &argv_ref, timeout)+ }+}++fn norm(p: &Path) -> String {+ p.to_string_lossy().replace('\\', "/")+}++fn stem_of(p: &Path) -> String {+ p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "board".into())+}++/// `kicad-cli pcb render --side <side> --background transparent --output <png> <pcb>`+/// (KiCad 9+). Ok(png) when the command succeeded AND the file exists; Err(reason)+/// otherwise, phrased like the Python's `missing` entries.+pub fn render_side(run: Runner, pcb: &Path, side: &str, png: &Path, timeout: Duration) -> Result<PathBuf, String> {+ let argv: Vec<String> = vec![+ "pcb".into(), "render".into(), "--side".into(), side.into(),+ "--background".into(), "transparent".into(), "--output".into(), norm(png), norm(pcb),+ ];+ let r = run(&argv, timeout);+ if r.timed_out {+ return Err(format!("silk-{side} (kicad-cli pcb render timed out after {}s)", timeout.as_secs()));+ }+ if r.exit_code == -1 {+ return Err(format!("silk-{side} ({})", r.stderr.trim()));+ }+ if r.exit_code == 0 && png.is_file() {+ Ok(png.to_path_buf())+ } else {+ Err(format!("silk-{side} (kicad-cli pcb render rc={})", r.exit_code))+ }+}++/// `kicad-cli pcb export step --output <out_dir>/<stem>.step <pcb>`, the same+/// command and output naming as the kicad_export_step verb. Ok(step path) or+/// Err(kicad-cli stderr).+pub fn export_step(run: Runner, pcb: &Path, out_dir: &Path, timeout: Duration) -> Result<PathBuf, String> {+ let out = out_dir.join(format!("{}.step", stem_of(pcb)));+ let argv: Vec<String> = vec!["pcb".into(), "export".into(), "step".into(), "--output".into(), norm(&out), norm(pcb)];+ let r = run(&argv, timeout);+ if r.timed_out {+ return Err(format!("STEP export timed out after {} seconds", timeout.as_secs()));+ }+ if r.exit_code != 0 {+ return Err(r.stderr.trim().to_string());+ }+ if !out.is_file() {+ return Err(format!("kicad-cli exited 0 but wrote no {}", out.display()));+ }+ Ok(out)+}++/// STEP export deadline (matches the kicad_export_step verb's budget).+pub const STEP_TIMEOUT: Duration = Duration::from_secs(280);+/// Per-side render deadline (Python: 180 s).+pub const RENDER_TIMEOUT: Duration = Duration::from_secs(180);++/// kicad_export_molecule {filePath, outputDir?, name?}: STEP + silk top/bottom PNGs ++/// sibling .kicad_sch/.kicad_pro discovery + the container commands. Same keys as the+/// Python handler: data.{step, silkscreen, pcb, sch, pro, nextSteps, missing?}, output,+/// _hint. `installed` false reproduces the Python's not-installed refusal.+pub fn export_molecule(installed: bool, args: &Value, run: Runner) -> Value {+ if !installed {+ return json!({"success": false, "errorCode": "not_installed", "error": "KiCad not installed",+ "_hint": "Install KiCad first (kicad_upgrade), then retry."});+ }+ let file_path = args.get("filePath").and_then(Value::as_str).map(str::trim).unwrap_or("");+ if file_path.is_empty() {+ return json!({"success": false, "errorCode": "missing_arg", "error": "No filePath specified (the .kicad_pcb)",+ "_hint": "Pass filePath: the absolute desktop path of the .kicad_pcb."});+ }+ let pcb = PathBuf::from(file_path);+ if !pcb.exists() {+ return json!({"success": false, "errorCode": "file_not_found", "error": format!("PCB file not found: {file_path}"),+ "_hint": "Path must exist on the desktop host. send_files it first if it lives in the container."});+ }+ let out_dir = args+ .get("outputDir")+ .and_then(Value::as_str)+ .map(str::trim)+ .filter(|s| !s.is_empty())+ .map(PathBuf::from)+ .unwrap_or_else(|| pcb.parent().unwrap_or(Path::new(".")).join("molecule_output"));+ if let Err(e) = std::fs::create_dir_all(&out_dir) {+ return json!({"success": false, "errorCode": "output_dir", "error": format!("cannot create {}: {e}", out_dir.display()),+ "_hint": "Pass an outputDir the desktop user can write."});+ }+ let stem = stem_of(&pcb);+ let mut missing: Vec<String> = Vec::new();++ // 1) STEP: mandatory, the GLB comes from it.+ let step = match export_step(run, &pcb, &out_dir, STEP_TIMEOUT) {+ Ok(p) => p,+ Err(e) => {+ return json!({"success": false, "errorCode": "step_export_failed", "error": format!("STEP export failed: {e}"),+ "_hint": "The STEP is mandatory for the molecule GLB: fix this first. kicad-cli's stderr names the reason (file version, missing 3D model)."});+ }+ };++ // 2) Silkscreen renders: optional, need `kicad-cli pcb render` (KiCad 9+).+ let mut silk = Map::new();+ for side in ["top", "bottom"] {+ let png = out_dir.join(format!("{stem}-silk-{side}.png"));+ match render_side(run, &pcb, side, &png, RENDER_TIMEOUT) {+ Ok(p) => {+ silk.insert(side.into(), json!(norm(&p)));+ }+ Err(reason) => missing.push(reason),+ }+ }+ let silk_sides: Vec<String> = silk.keys().cloned().collect();++ // 3) Sibling project files by stem: adom-molecule needs sch + pro.+ let sch = pcb.with_extension("kicad_sch");+ let pro = pcb.with_extension("kicad_pro");+ for (label, p) in [("sch", &sch), ("pro", &pro)] {+ if !p.exists() {+ missing.push(format!(+ "{label} ({} not next to the board; adom-molecule requires it)",+ p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()+ ));+ }+ }++ // 4) The exact container-side finish.+ let name = args.get("name").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).unwrap_or(&stem);+ let mut create = format!(+ "adom-molecule create --source kicad --name \"{name}\" --owner <owner> --description \"<desc>\" --glb /tmp/{stem}.glb --pcb <pcb> --sch <sch> --pro <pro>"+ );+ if !silk.is_empty() {+ create.push_str(" --silk-top <silk-top> --silk-bottom <silk-bottom>");+ }+ let next_steps = vec![+ "pull_file the paths in data (step/pcb/sch/pro/silkscreen) to the container".to_string(),+ format!("step2glb convert <step> --out /tmp/{stem}.glb"),+ create,+ ];++ let mut data = json!({+ "step": norm(&step),+ "silkscreen": Value::Object(silk),+ "pcb": norm(&pcb),+ "sch": if sch.exists() { json!(norm(&sch)) } else { Value::Null },+ "pro": if pro.exists() { json!(norm(&pro)) } else { Value::Null },+ "nextSteps": next_steps,+ });+ if !missing.is_empty() {+ data["missing"] = json!(missing);+ }+ let mut output = format!("Molecule export pack in {}: STEP", norm(&out_dir));+ if !silk_sides.is_empty() {+ output.push_str(&format!(" + silk({})", silk_sides.join(",")));+ }+ if missing.is_empty() {+ output.push_str(": complete");+ } else {+ output.push_str(&format!("; MISSING: {}", missing.join("; ")));+ }+ json!({+ "success": true,+ "data": data,+ "output": output,+ "_hint": "Desktop half of the molecule pipeline is done. pull_file everything in data, then run data.nextSteps in the container (step2glb, then adom-molecule create). adom-molecule REQUIRES glb+pcb+sch+pro; silk PNGs are optional polish for the molecule page.",+ })+}++#[cfg(test)]+mod tests {+ use super::*;+ use std::cell::RefCell;++ 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-render-{}-{name}", std::process::id()));+ let _ = std::fs::remove_dir_all(&p);+ std::fs::create_dir_all(&p).unwrap();+ Tmp(p)+ }+ fn ok(stdout: &str) -> CliResult {+ CliResult { exit_code: 0, stdout: stdout.into(), stderr: String::new(), elapsed_ms: 1, timed_out: false }+ }+ fn rc(code: i32, stderr: &str) -> CliResult {+ CliResult { exit_code: code, stdout: String::new(), stderr: stderr.into(), elapsed_ms: 1, timed_out: false }+ }+ fn output_arg(argv: &[String]) -> PathBuf {+ let i = argv.iter().position(|a| a == "--output").unwrap();+ PathBuf::from(&argv[i + 1])+ }++ /// A fake kicad-cli that writes the requested outputs and records every call.+ fn fake<'a>(calls: &'a RefCell<Vec<Vec<String>>>, render_works: bool) -> impl Fn(&[String], Duration) -> CliResult + 'a {+ move |argv: &[String], _t: Duration| {+ calls.borrow_mut().push(argv.to_vec());+ match (argv[0].as_str(), argv[1].as_str()) {+ ("pcb", "export") => {+ std::fs::write(output_arg(argv), "ISO-10303-21;").unwrap();+ ok("")+ }+ ("pcb", "render") if render_works => {+ std::fs::write(output_arg(argv), b"\x89PNG").unwrap();+ ok("")+ }+ ("pcb", "render") => rc(2, "Invalid command: render"),+ _ => rc(1, "unknown"),+ }+ }+ }++ #[test]+ fn full_pack_with_silk_and_siblings() {+ let t = tmp("full");+ let pcb = t.0.join("demo.kicad_pcb");+ std::fs::write(&pcb, "(kicad_pcb)").unwrap();+ std::fs::write(t.0.join("demo.kicad_sch"), "(kicad_sch)").unwrap();+ std::fs::write(t.0.join("demo.kicad_pro"), "{}").unwrap();+ let calls = RefCell::new(Vec::new());+ let run = fake(&calls, true);+ let r = export_molecule(true, &json!({"filePath": norm(&pcb), "name": "Demo Board"}), &run);+ assert_eq!(r["success"], json!(true), "{r}");+ let out_dir = t.0.join("molecule_output");+ assert_eq!(r["data"]["step"], json!(norm(&out_dir.join("demo.step"))));+ assert_eq!(r["data"]["silkscreen"]["top"], json!(norm(&out_dir.join("demo-silk-top.png"))));+ assert_eq!(r["data"]["silkscreen"]["bottom"], json!(norm(&out_dir.join("demo-silk-bottom.png"))));+ assert_eq!(r["data"]["pcb"], json!(norm(&pcb)));+ assert_eq!(r["data"]["sch"], json!(norm(&t.0.join("demo.kicad_sch"))));+ assert_eq!(r["data"]["pro"], json!(norm(&t.0.join("demo.kicad_pro"))));+ assert!(r["data"].get("missing").is_none());+ let steps = r["data"]["nextSteps"].as_array().unwrap();+ assert_eq!(steps.len(), 3);+ assert!(steps[1].as_str().unwrap().contains("--out /tmp/demo.glb"));+ assert!(steps[2].as_str().unwrap().contains("--name \"Demo Board\""));+ assert!(steps[2].as_str().unwrap().contains("--silk-top <silk-top> --silk-bottom <silk-bottom>"));+ assert_eq!(r["output"], json!(format!("Molecule export pack in {}: STEP + silk(bottom,top): complete", norm(&out_dir))));++ // The exact commands the Python issues.+ let c = calls.borrow();+ assert_eq!(c.len(), 3);+ assert_eq!(c[0][..4], ["pcb", "export", "step", "--output"].map(String::from));+ assert_eq!(c[0][5], norm(&pcb));+ assert_eq!(c[1], ["pcb", "render", "--side", "top", "--background", "transparent", "--output", &norm(&out_dir.join("demo-silk-top.png")), &norm(&pcb)].map(String::from));+ assert_eq!(c[2][3], "bottom");+ }++ #[test]+ fn old_kicad_without_render_and_no_siblings() {+ let t = tmp("old");+ let pcb = t.0.join("x.kicad_pcb");+ std::fs::write(&pcb, "(kicad_pcb)").unwrap();+ let out = t.0.join("custom");+ let calls = RefCell::new(Vec::new());+ let run = fake(&calls, false);+ let r = export_molecule(true, &json!({"filePath": norm(&pcb), "outputDir": norm(&out)}), &run);+ assert_eq!(r["success"], json!(true), "{r}");+ assert!(out.join("x.step").is_file());+ assert_eq!(r["data"]["silkscreen"], json!({}));+ assert!(r["data"]["sch"].is_null());+ assert!(r["data"]["pro"].is_null());+ let missing: Vec<&str> = r["data"]["missing"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();+ assert_eq!(missing, vec![+ "silk-top (kicad-cli pcb render rc=2)",+ "silk-bottom (kicad-cli pcb render rc=2)",+ "sch (x.kicad_sch not next to the board; adom-molecule requires it)",+ "pro (x.kicad_pro not next to the board; adom-molecule requires it)",+ ]);+ assert!(!r["data"]["nextSteps"][2].as_str().unwrap().contains("--silk-top"));+ assert!(r["output"].as_str().unwrap().starts_with(&format!("Molecule export pack in {}: STEP; MISSING: silk-top", norm(&out))));+ }++ #[test]+ fn step_failure_is_fatal() {+ let t = tmp("stepfail");+ let pcb = t.0.join("b.kicad_pcb");+ std::fs::write(&pcb, "(kicad_pcb)").unwrap();+ let run = |_argv: &[String], _t: Duration| rc(1, "Error loading board: bad file");+ let r = export_molecule(true, &json!({"filePath": norm(&pcb)}), &run);+ assert_eq!(r["success"], json!(false));+ assert_eq!(r["errorCode"], json!("step_export_failed"));+ assert_eq!(r["error"], json!("STEP export failed: Error loading board: bad file"));+ // No silk render attempted after a fatal STEP.+ assert!(!t.0.join("molecule_output").join("b-silk-top.png").exists());+ }++ #[test]+ fn arg_and_install_checks() {+ let run = |_argv: &[String], _t: Duration| ok("");+ let r = export_molecule(false, &json!({"filePath": "x"}), &run);+ assert_eq!(r["error"], json!("KiCad not installed"));+ let r = export_molecule(true, &json!({}), &run);+ assert_eq!(r["error"], json!("No filePath specified (the .kicad_pcb)"));+ let r = export_molecule(true, &json!({"filePath": "/definitely/not/here.kicad_pcb"}), &run);+ assert_eq!(r["errorCode"], json!("file_not_found"));+ assert!(r["error"].as_str().unwrap().starts_with("PCB file not found: "));+ }++ #[test]+ fn render_side_reports_timeout_and_spawn_failure() {+ let t = tmp("render");+ let pcb = t.0.join("b.kicad_pcb");+ let png = t.0.join("b-silk-top.png");+ let timed = |_a: &[String], _t: Duration| CliResult { exit_code: -2, stdout: String::new(), stderr: "kicad-cli timed out".into(), elapsed_ms: 1, timed_out: true };+ let e = render_side(&timed, &pcb, "top", &png, Duration::from_secs(180)).unwrap_err();+ assert_eq!(e, "silk-top (kicad-cli pcb render timed out after 180s)");+ let spawn = |_a: &[String], _t: Duration| rc(-1, "spawn failed: No such file");+ let e = render_side(&spawn, &pcb, "bottom", &png, RENDER_TIMEOUT).unwrap_err();+ assert_eq!(e, "silk-bottom (spawn failed: No such file)");+ // rc 0 but no file is still a miss.+ let silent = |_a: &[String], _t: Duration| ok("");+ let e = render_side(&silent, &pcb, "top", &png, RENDER_TIMEOUT).unwrap_err();+ assert_eq!(e, "silk-top (kicad-cli pcb render rc=0)");+ }+}