← Commit history

rust: schematic edit (6 verbs, ERC-validated, verified on KiCad 10.0.5) and install group (7 verbs); 119 tests

John Lauer ·8a4b23fb99 ·28d ago ·parent 154f5c5
5 files changed +2176−25
rust/crates/kicad-bridge/src/verbs_install.rs+179−6
@@ -1,11 +1,184 @@-//! Verb group "install". Placeholder until the port lands.+//! Verb group "install": kicad_install_library, kicad_install_symbol,+//! kicad_install_footprint, kicad_install_library_bundle, kicad_adom_library_heal,+//! kicad_make_part_project, kicad_export_part. Same arg names and response shapes+//! as the Python handlers (`handlers/install_library.py`, `handlers/install_symbol.py`,+//! `handlers/install_footprint.py`, `handlers/install_library_bundle.py`,+//! `server.py::_handle_adom_library_heal`, `handlers/make_part_project.py`,+//! `handlers/export_part.py`); the file logic lives in `kicad_core::install`.+//!+//! What 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) is window-phase and is reported as not done, never faked.++use std::path::PathBuf;+ use serde_json::Value; -use crate::catalog::Verb;-use crate::util::State;+use crate::catalog::{Mechanism, Verb};+use crate::util::{arg_str, State};+use kicad_core::detect::KicadInfo;+use kicad_core::install;+use kicad_core::libraries::LibCtx;+use kicad_platform::native; -pub static VERBS: &[Verb] = &[];+pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_install_library",+        summary: "Register a symbol/footprint library in the user's sym-/fp-lib-table (footprint installs also carry a bundle's 3D models).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 130,+        input: "{\"libraryPath\": \"C:/.../x.kicad_sym | x.pretty | x.kicad_mod\", \"libraryType\"?: \"symbol|footprint\", \"libraryName\"?: \"x\", \"description\"?: \"...\", \"model3dPath\"?: \"C:/.../x.step\", \"modelsPath\"?: \"C:/.../3dmodels\"}",+        example: "kicad_install_library {\"libraryPath\":\"C:/Users/john/libs/MyParts.kicad_sym\"}",+        hint: "Adds a library to the selected KiCad version's lib table so its parts are usable. Footprint installs take modelsPath (a directory of STEP/WRL files) or auto-detect a 3dmodels/ sibling of the source .pretty (the Adom wiki library-bundle layout): models copy into 3dmodels/<lib>.3dshapes and the COPIED footprints' matching (model ...) refs are rewritten to the installed absolute paths, so 3D previews resolve.",+        related: &["kicad_install_symbol", "kicad_install_footprint", "kicad_list_versions"],+        pitfalls: &[+            "each KiCad version has its OWN lib tables: install under every version the user needs",+            "restart/reload KiCad to see a newly-added library",+            "model-ref rewriting touches only the INSTALLED copy, never the source, and only refs whose basename matches an installed model (stock ${KICAD*_3DMODEL_DIR} refs stay)",+            "a one-part .kicad_sym registers as its own library and leaves the shared Adom library untouched (singlePartFile:true says so); kicad_install_symbol is the verb for 'put this part into Adom' (wiki #72)",+            "a .kicad_sym KiCad's own parser refuses is not copied or registered (errorCode kicad_refuses_library, wiki #71); without kicad-cli there is no verdict and the install proceeds",+        ],+    },+    Verb {+        name: "kicad_install_symbol",+        summary: "Merge the symbol(s) of a .kicad_sym into the single global Adom library (Adom.kicad_sym), creating and registering it on first use.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 130,+        input: "{\"fileName\": \"BQ76952.kicad_sym\", \"fileContent\": \"<base64>\"} or {\"filePath\": \"C:/.../x.kicad_sym\"}, \"symbolName\"?: \"x\", \"openEditor\"?: true, \"quietInstall\"?: false",+        example: "kicad_install_symbol {\"filePath\":\"C:/Users/john/Downloads/BQ76952.kicad_sym\",\"openEditor\":false}",+        hint: "Pass filePath for a file already on this machine (no base64 size limit), else fileName + base64 fileContent. Symbols land in the Adom.kicad_sym the sym-lib-table row points at (ROW FIRST, wiki #71); an existing symbol of the same name is replaced in place. Then kicad_show_symbol {symbolName, libraryName:\"Adom\"}.",+        related: &["kicad_show_symbol", "kicad_install_library", "kicad_install_footprint", "kicad_adom_library_status"],+        pitfalls: &[+            "libraryName is ignored: every Adom symbol lives in the one 'Adom' library (ignoredArgs says so); kicad_install_library is the verb for a separate library file",+            "the merge refuses (errorCode kicad_refuses_symbol / kicad_refuses_merged_library) when kicad-cli's parser rejects the upload or the result, because one bad symbol hides the WHOLE library in the Symbol Editor (wiki #71); kicadParser.available:false means no kicad-cli and no verdict either way",+            "a corrupt Adom.kicad_sym on disk is never merged into (REFUSING to merge); kicad_adom_library_heal repairs it",+            "one-generation backup Adom.kicad_sym.bak beside the library before every write",+            "openEditor is window-phase: this build never opens the Symbol Editor (editorOpened is always false) and cannot tell whether one is already open, so an open editor's library index is stale until it reopens (kicad_show_symbol rescan:true once the window verbs land)",+        ],+    },+    Verb {+        name: "kicad_install_footprint",+        summary: "Write a .kicad_mod into the global Adom.pretty footprint library, register it in fp-lib-table, optionally install and wire a 3D model.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 130,+        input: "{\"fileName\": \"BMI423.kicad_mod\", \"fileContent\": \"<base64>\"} or {\"filePath\": \"C:/.../x.kicad_mod\"}, \"footprintName\"?: \"x\", \"openEditor\"?: true, \"quietInstall\"?: false, \"model\"?: {\"filePath\": \"C:/.../x.step\"} | {\"fileName\": \"x.step\", \"fileContent\": \"<base64>\"}",+        example: "kicad_install_footprint {\"filePath\":\"C:/Users/john/Downloads/BMI423.kicad_mod\",\"model\":{\"filePath\":\"C:/Users/john/Downloads/BMI423.step\"},\"openEditor\":false}",+        hint: "The file is written as <footprintName>.kicad_mod (the name declared inside the file unless footprintName says otherwise) into the Adom.pretty the fp-lib-table row points at (ROW FIRST). model installs the STEP/WRL to Documents/KiCad/<ver>/3dmodels/Adom/ and rewrites or injects the footprint's (model ...) so kicad_show_3d_chip renders the part. Big models (>90KB) overflow the CLI arg limit as base64: push with ab send_files, then pass model.filePath.",+        related: &["kicad_show_footprint", "kicad_show_3d_chip", "kicad_install_symbol", "kicad_list_footprints", "kicad_model_check"],+        pitfalls: &[+            "identical content is not rewritten (unchanged:true): an mtime bump past KiCad's cache load makes the opener report a stale cache",+            "a NEW footprint (existedBefore:false) needs the Footprint Editor's library tree refreshed or KiCad restarted to be seen (process-wide cache, wiki #47)",+            "openEditor is window-phase: this build never opens the Footprint Editor (editorOpened is always false) and does not report staleEditorOpen",+            "libraryName is not an argument: every footprint lands in 'Adom'; kicad_install_library {libraryType:\"footprint\"} is the verb for a separate .pretty",+        ],+    },+    Verb {+        name: "kicad_install_library_bundle",+        summary: "Install an Adom wiki Cloud Library bundle zip (symbols + footprints + 3D models) in one call.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 130,+        input: "{\"zipPath\": \"C:/.../MyLib.zip\", \"libraryName\"?: \"MyLib\", \"capture\"?: false}",+        example: "kicad_install_library_bundle {\"zipPath\":\"C:/Users/john/Downloads/MyLib.zip\"}",+        hint: "Pass zipPath (staged on THIS machine via write_file / write_file_chunk) and optionally libraryName (defaults to the zip stem). Unpacks the bundle (<Lib>.kicad_sym + <Lib>.pretty/ + 3dmodels/), installs symbols and footprints, carries the 3D models with ref rewriting, and registers both lib tables. Returns {symbols, footprints, modelsInstalled, modelRefsRewritten, kicadRunning, restartNeeded}.",+        related: &["kicad_install_library", "kicad_lint_library", "kicad_show_library", "kicad_pcm_install"],+        pitfalls: &[+            "a RUNNING KiCad reads lib tables at startup only: the user must restart KiCad themselves (never close it for them)",+            "this build has no process scan yet: kicadRunning is null and restartNeeded is always true (the safe answer)",+            "capture:true is window-phase (no Symbol Editor is opened, no frame taken): proofSkipped says so; kicad_show_library once the window verbs land",+            "zip entries with .. or absolute paths are refused; zip64 and non-deflate entries are not supported",+            "the zip is capped at 256MB and 512MB unpacked",+        ],+    },+    Verb {+        name: "kicad_adom_library_heal",+        summary: "Reconcile a SPLIT Adom library: merge the orphaned file's symbols into the registered one (default), repoint the row, or prune the symbols KiCad refuses. Human-chosen; dryRun previews.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 130,+        input: "{\"mode\"?: \"merge|repoint|prune\", \"dryRun\"?: false}",+        example: "kicad_adom_library_heal {\"mode\":\"prune\",\"dryRun\":true}",+        hint: "Run kicad_adom_library_status first; merge and repoint are only meaningful when it reports split:true, prune when kicadParser.ok is false. merge never deletes or edits the orphan; every symbol goes through the quote-aware add with the balance gate and a .bak. prune rebuilds the registered file from the symbols kicad-cli accepts and keeps the original as .kicad_sym.pruned.bak.",+        related: &["kicad_adom_library_status", "kicad_install_symbol", "kicad_rescan_libraries", "kicad_lint_library"],+        pitfalls: &[+            "KiCad sees merged symbols only after its next library load",+            "prune needs kicad-cli (KiCad's own parser is the judge); without it the verb refuses with kicadParser.available:false rather than guess",+            "never runs on its own: the bridge cannot know which of two libraries is the user's real one",+        ],+    },+    Verb {+        name: "kicad_make_part_project",+        summary: "Generate a small real project (pro/sch/pcb) AROUND an installed Adom-library part, so the project/schematic/2D-board/3D-board surfaces have something true to show for one component.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 130,+        input: "{\"partName\": \"IN-S42ATR\", \"footprintName\"?: \"IN-S42ATR\", \"projectName\"?: \"in-s42atr-demo\", \"outputDir\"?: \"C:/...\"}",+        example: "kicad_make_part_project {\"partName\":\"IN-S42ATR\"}",+        hint: "kicad_make_part_project {\"partName\":\"IN-S42ATR\"} after install_symbol/install_footprint. Returns projectFile/schematicFile/boardFile for the show_* verbs. Board keeps the footprint's (model ...) so show_3d_board renders the real part. Default output Documents/<projectName>; single-component by design.",+        related: &["kicad_show_project", "kicad_show_schematic", "kicad_show_2d_board", "kicad_show_3d_board", "kicad_export_part"],+        pitfalls: &[+            "the part must already be in the Adom library (symbol AND footprint); the symbol is read from the Adom.kicad_sym the sym-lib-table row points at, the footprint from the Adom.pretty the fp-lib-table row points at",+            "regenerating overwrites the project in place and removes stale *.lck locks",+            "registering the project with the focus guardian is window-phase and not done here",+        ],+    },+    Verb {+        name: "kicad_export_part",+        summary: "One-off single-part library exports: a .kicad_sym with ONLY that symbol, a .pretty with ONLY that footprint, the bare 3D model file. The reverse of install.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 130,+        input: "{\"partName\": \"IN-S42ATA\", \"what\"?: [\"symbol\",\"footprint\",\"model\"], \"footprintName\"?: \"x\", \"outputDir\"?: \"C:/...\"}",+        example: "kicad_export_part {\"partName\":\"IN-S42ATA\"}",+        hint: "kicad_export_part {\"partName\":\"IN-S42ATA\"} (optional what:[\"symbol\",\"footprint\",\"model\"]). Writes to Documents/adom-part-exports/<part>/; pull with ab pull_file. Proves per-asset round-trip.",+        related: &["kicad_install_symbol", "kicad_install_footprint", "kicad_make_part_project"],+        pitfalls: &[+            "the part must be installed in the Adom library first",+            "success is false when any requested asset is missing; errors names each one and exports carries what did land",+            "the model is looked up as 3dmodels/Adom/<footprint>.step|.stp|.wrl, then the footprint's own (model ...) path",+        ],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_install_library" => {+            let info = state.kicad_info();+            install::install_library(&lib_ctx(&info), info.installed, args)+        }+        "kicad_install_symbol" => {+            let info = state.kicad_info();+            install::install_symbol(&lib_ctx(&info), info.installed, args)+        }+        "kicad_install_footprint" => {+            let info = state.kicad_info();+            install::install_footprint(&lib_ctx(&info), info.installed, args)+        }+        "kicad_install_library_bundle" => {+            let info = state.kicad_info();+            install::install_library_bundle(&lib_ctx(&info), info.installed, args)+        }+        "kicad_adom_library_heal" => {+            let info = state.kicad_info();+            let mode = arg_str(args, "mode").unwrap_or("merge");+            let dry_run = args.get("dryRun").map(|v| !matches!(v, Value::Bool(false) | Value::Null)).unwrap_or(false);+            install::heal_split(&lib_ctx(&info), mode, dry_run)+        }+        "kicad_make_part_project" => {+            let info = state.kicad_info();+            install::make_part_project(&lib_ctx(&info), args)+        }+        "kicad_export_part" => {+            let info = state.kicad_info();+            install::export_part(&lib_ctx(&info), args)+        }+        _ => return None,+    })+} -pub fn dispatch(_state: &mut State, _command: &str, _args: &Value) -> Option<Value> {-    None+/// The library context for the KiCad this bridge drives: version, install root,+/// config and user dirs from the platform, Configure Paths overrides loaded.+/// (Same shape as verbs_libraries::lib_ctx, which is private to that group.)+fn lib_ctx(info: &KicadInfo) -> LibCtx {+    let inst = info.primary();+    let version = inst.map(|i| i.version.clone()).unwrap_or_default();+    let mut ctx = LibCtx {+        version: version.clone(),+        base_dir: inst.map(|i| i.base_dir.clone()).filter(|b| !b.is_empty()),+        config_dir: if version.is_empty() { None } else { native().config_dir(&version) },+        user_dir: if version.is_empty() { None } else { native().user_dir(&version) },+        kicad_cli: inst.map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()),+        env_overrides: Default::default(),+    };+    ctx.load_env_overrides();+    ctx }
rust/crates/kicad-bridge/src/verbs_schematic.rs+464−7
@@ -1,11 +1,468 @@-//! Verb group "schematic". Placeholder until the port lands.-use serde_json::Value;+//! Verb group "schematic": kicad_sch_place_symbol, kicad_sch_wire, kicad_sch_label,+//! kicad_sch_move, kicad_sch_delete, kicad_sch_set_property. KiCad 10 has no schematic+//! API, so these edit the `.kicad_sch` on disk (`kicad_core::schematic`, lossless+//! round trip), keep a `.bak`, run `kicad-cli sch erc` before and after when a kicad-cli+//! is installed, and tell the caller the open editor must be reloaded to see the change. -use crate::catalog::Verb;-use crate::util::State;+use std::path::{Path, PathBuf};+use std::time::Duration; -pub static VERBS: &[Verb] = &[];+use serde_json::{json, Value}; -pub fn dispatch(_state: &mut State, _command: &str, _args: &Value) -> Option<Value> {-    None+use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::cli;+use kicad_core::detect::KicadInfo;+use kicad_core::libraries::LibCtx;+use kicad_core::schematic::{self, LabelKind, PlaceSymbol, Schematic};+use kicad_platform::native;++const RELOAD_HINT: &str = "The edit is on disk only. An open Schematic Editor keeps its in-memory copy and does not see it: call kicad_close {\"filePath\": <this file>} then kicad_open_schematic {\"filePath\": <this file>} (or have the user pick File > Revert) before looking at or saving the sheet, or the editor's next save overwrites this edit.";+++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_sch_place_symbol",+        summary: "Place a library symbol on a .kicad_sch (edits the file on disk; the lib_symbols entry is copied from the library when the sheet lacks it).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"libId\": \"Device:R\", \"reference\": \"R5\", \"at\": [x_mm, y_mm], \"rotation\"?: 0|90|180|270, \"unit\"?: 1, \"value\"?: \"10k\", \"footprint\"?: \"Resistor_SMD:R_0603_1608Metric\", \"libraryPath\"?: \"C:/.../Lib.kicad_sym\"}",+        example: "kicad_sch_place_symbol {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"libId\":\"Device:R\",\"reference\":\"R5\",\"at\":[127,81.28],\"value\":\"10k\"}",+        hint: "Writes the symbol, its pins and its instances block the way eeschema does and returns its uuid. The edit is on disk: the open Schematic Editor does not see it until you call kicad_close then kicad_open_schematic (reloadHint names the calls). A .bak is written next to the file; ERC runs before and after when kicad-cli is present and erc.delta reports the change. libId is resolved through the project sym-lib-table then the global one; libraryPath bypasses both.",+        related: &["kicad_sch_wire", "kicad_sch_label", "kicad_sch_set_property", "kicad_list_symbols", "kicad_run_erc", "kicad_open_schematic"],+        pitfalls: &[+            "the edit is on disk: an open Schematic Editor does not see it until reloaded (kicad_close, then kicad_open_schematic); saving from the stale editor overwrites the edit",+            "every write copies the previous file to <file>.bak next to it (one generation; the next write replaces it)",+            "ERC runs before and after each edit when kicad-cli is installed and erc.delta reports the change in errors and warnings; with no kicad-cli erc.ran is false and the file is unvalidated",+            "reference must be free on the sheet; the verb refuses a duplicate instead of renumbering",+            "the symbol lands with fields_autoplaced and pins unconnected: follow with kicad_sch_wire or kicad_sch_label, or ERC reports pin_not_connected",+            "a libId whose library is not in any sym-lib-table fails with the tables that were checked; pass libraryPath for a library file that is not installed",+        ],+    },+    Verb {+        name: "kicad_sch_wire",+        summary: "Draw a wire polyline on a .kicad_sch (one wire segment per pair of points; edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"points\": [[x_mm, y_mm], [x_mm, y_mm], ...]}",+        example: "kicad_sch_wire {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"points\":[[127,77.47],[127,70],[140,70]]}",+        hint: "Each consecutive pair becomes one (wire) with its own uuid; uuid is the first, uuids lists them all. Pin ends are at the symbol anchor plus the pin offset (a Device:R at (x,y) has pins at y-3.81 and y+3.81). On disk only: reload the editor (kicad_close, kicad_open_schematic) to see it. .bak written; ERC delta reported when kicad-cli is present.",+        related: &["kicad_sch_place_symbol", "kicad_sch_label", "kicad_sch_delete", "kicad_run_erc"],+        pitfalls: &[+            "the edit is on disk: an open Schematic Editor does not see it until reloaded (kicad_close, then kicad_open_schematic); saving from the stale editor overwrites the edit",+            "every write copies the previous file to <file>.bak next to it (one generation; the next write replaces it)",+            "ERC runs before and after each edit when kicad-cli is installed and erc.delta reports the change in errors and warnings; with no kicad-cli erc.ran is false and the file is unvalidated",+            "KiCad connects a wire end to a pin only when the coordinates match exactly; stay on the 1.27 mm grid",+            "no junctions are added; a T off the middle of another wire needs one for KiCad to join them",+        ],+    },+    Verb {+        name: "kicad_sch_label",+        summary: "Add a net label (local, global, hierarchical) or a power symbol on a .kicad_sch (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"text\": \"VIN\", \"at\": [x_mm, y_mm], \"kind\": \"local\"|\"global\"|\"hierarchical\"|\"power\", \"rotation\"?: 0|90|180|270, \"shape\"?: \"input|output|bidirectional|tri_state|passive\" (global/hierarchical), \"reference\"?: \"#PWR05\" (power)}",+        example: "kicad_sch_label {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"text\":\"GND\",\"at\":[127,90.17],\"kind\":\"power\"}",+        hint: "kind power places the symbol power:<text> (GND, +3V3, +5V, VCC ...) with the next free #PWRnn reference, since KiCad has no power label item; the other kinds write (label), (global_label) or (hierarchical_label) at the point. On disk only: reload the editor (kicad_close, kicad_open_schematic) to see it. .bak written; ERC delta reported when kicad-cli is present.",+        related: &["kicad_sch_wire", "kicad_sch_place_symbol", "kicad_sch_delete", "kicad_run_erc"],+        pitfalls: &[+            "the edit is on disk: an open Schematic Editor does not see it until reloaded (kicad_close, then kicad_open_schematic); saving from the stale editor overwrites the edit",+            "every write copies the previous file to <file>.bak next to it (one generation; the next write replaces it)",+            "ERC runs before and after each edit when kicad-cli is installed and erc.delta reports the change in errors and warnings; with no kicad-cli erc.ran is false and the file is unvalidated",+            "a label connects only when its point sits exactly on a wire end or a pin end",+            "kind power needs the stock power library resolvable (KiCad installed, or libraryPath)",+        ],+    },+    Verb {+        name: "kicad_sch_move",+        summary: "Move a placed symbol (by reference) so its anchor lands on a new point; its fields move with it (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"reference\": \"R5\", \"to\": [x_mm, y_mm]}",+        example: "kicad_sch_move {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"reference\":\"R5\",\"to\":[150,90]}",+        hint: "Shifts the symbol's (at) and every property's (at) by the same delta; wires do not follow. On disk only: reload the editor (kicad_close, kicad_open_schematic) to see it. .bak written; ERC delta reported when kicad-cli is present.",+        related: &["kicad_sch_place_symbol", "kicad_sch_wire", "kicad_sch_delete"],+        pitfalls: &[+            "the edit is on disk: an open Schematic Editor does not see it until reloaded (kicad_close, then kicad_open_schematic); saving from the stale editor overwrites the edit",+            "every write copies the previous file to <file>.bak next to it (one generation; the next write replaces it)",+            "ERC runs before and after each edit when kicad-cli is installed and erc.delta reports the change in errors and warnings; with no kicad-cli erc.ran is false and the file is unvalidated",+            "wires attached to the symbol stay where they were; move them (delete and redraw) or ERC reports the pins unconnected",+        ],+    },+    Verb {+        name: "kicad_sch_delete",+        summary: "Delete any top-level item (symbol, wire, label, junction, sheet ...) from a .kicad_sch by uuid (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"uuid\": \"f8e0a1ff-...\"}",+        example: "kicad_sch_delete {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"uuid\":\"f8e0a1ff-cdf3-4b10-8a45-de5c5807717e\"}",+        hint: "uuids come from the create verbs' responses, from kicad_extract_netlist, or from ERC items. Returns the kind of item removed. On disk only: reload the editor (kicad_close, kicad_open_schematic) to see it. .bak written; ERC delta reported when kicad-cli is present.",+        related: &["kicad_sch_place_symbol", "kicad_sch_wire", "kicad_sch_label", "kicad_extract_netlist"],+        pitfalls: &[+            "the edit is on disk: an open Schematic Editor does not see it until reloaded (kicad_close, then kicad_open_schematic); saving from the stale editor overwrites the edit",+            "every write copies the previous file to <file>.bak next to it (one generation; the next write replaces it)",+            "ERC runs before and after each edit when kicad-cli is installed and erc.delta reports the change in errors and warnings; with no kicad-cli erc.ran is false and the file is unvalidated",+            "deleting a symbol leaves its lib_symbols entry in place (KiCad prunes unused entries on its next save); wires that touched it stay",+        ],+    },+    Verb {+        name: "kicad_sch_set_property",+        summary: "Set or add a field on a placed symbol by reference: Value, Footprint, Datasheet, a custom field, or Reference itself (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"reference\": \"R5\", \"name\": \"Value\", \"value\": \"4k7\"}",+        example: "kicad_sch_set_property {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"reference\":\"R5\",\"name\":\"Footprint\",\"value\":\"Resistor_SMD:R_0603_1608Metric\"}",+        hint: "An existing field keeps its position and effects; a new one is added hidden at the symbol anchor. name Reference renames the symbol and its instances entry. On disk only: reload the editor (kicad_close, kicad_open_schematic) to see it. .bak written; ERC delta reported when kicad-cli is present.",+        related: &["kicad_sch_place_symbol", "kicad_sch_move", "kicad_extract_netlist"],+        pitfalls: &[+            "the edit is on disk: an open Schematic Editor does not see it until reloaded (kicad_close, then kicad_open_schematic); saving from the stale editor overwrites the edit",+            "every write copies the previous file to <file>.bak next to it (one generation; the next write replaces it)",+            "ERC runs before and after each edit when kicad-cli is installed and erc.delta reports the change in errors and warnings; with no kicad-cli erc.ran is false and the file is unvalidated",+            "field names are case-sensitive (Value, not value); a new name creates a new field",+            "Reference must stay unique on the sheet; the verb refuses a name already in use",+        ],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    let out = match command {+        "kicad_sch_place_symbol" => edit(state, args, place_symbol),+        "kicad_sch_wire" => edit(state, args, wire),+        "kicad_sch_label" => edit(state, args, label),+        "kicad_sch_move" => edit(state, args, move_symbol),+        "kicad_sch_delete" => edit(state, args, delete),+        "kicad_sch_set_property" => edit(state, args, set_property),+        _ => return None,+    };+    Some(out)+}++/// What an operation hands back: the one-line output, the uuid of the created (or+/// touched) item, and any extra fields for the response.+struct Done {+    output: String,+    uuid: String,+    extra: Value,+}++/// The context an operation gets: the parsed sheet, the sheet's path and the library+/// context (for lib id resolution).+struct Ctx<'a> {+    sch: &'a mut Schematic,+    path: &'a Path,+    lib: &'a LibCtx,+    args: &'a Value,+}++type Op = fn(&mut Ctx) -> Result<Done, Value>;++/// Shared pipeline: validate the path, ERC before, load, apply, backup + write, ERC+/// after, and the response every schematic verb shares.+fn edit(state: &mut State, args: &Value, op: Op) -> Value {+    let Some(p) = arg_any(args, &["filePath", "schematicPath", "path"]) else {+        return fail("missing_arg", "filePath is required", "Pass the absolute path of the .kicad_sch to edit.");+    };+    let path = PathBuf::from(p.trim());+    if !path.to_string_lossy().to_ascii_lowercase().ends_with(".kicad_sch") {+        return fail("wrong_file_type", format!("{} is not a .kicad_sch", norm(&path)), "These verbs edit schematic sheets only; boards are edited through the IPC verbs.");+    }+    if !path.is_file() {+        return fail("file_not_found", format!("{} does not exist", norm(&path)), "Check the path; the sheet must already exist (the bridge does not create schematics).");+    }+    let info = state.kicad_info();+    let cli_path = cli_exe(&info);+    let lib = lib_ctx(&info);+    let before = run_erc(cli_path.as_deref(), &path);++    let mut sch = match Schematic::load(&path) {+        Ok(s) => s,+        Err(e) => return fail("parse_failed", format!("cannot parse {}: {e}", norm(&path)), "The file is not a schematic this bridge can read; open it in KiCad and save it again, or run kicad_lint_schematic."),+    };+    let done = {+        let mut ctx = Ctx { sch: &mut sch, path: &path, lib: &lib, args };+        match op(&mut ctx) {+            Ok(d) => d,+            Err(v) => return v,+        }+    };+    let backup = match sch.save_with_backup(&path) {+        Ok(b) => b,+        Err(e) => return fail("write_failed", e, "Check the file is writable and not locked by another process; nothing was changed."),+    };+    let after = run_erc(cli_path.as_deref(), &path);+    let erc = erc_block(&before, &after);+    let erc_line = match (&before.counts, &after.counts) {+        (Some(b), Some(a)) => format!("; ERC {} -> {} error(s), {} -> {} warning(s)", b.0, a.0, b.1, a.1),+        _ => "; ERC not run (no kicad-cli)".to_string(),+    };+    let mut v = json!({+        "success": true,+        "output": format!("{}{erc_line}", done.output),+        "path": norm(&path),+        "backup": norm(&backup),+        "uuid": done.uuid,+        "erc": erc,+        "reloadHint": RELOAD_HINT,+        "_hint": format!("Written to disk with a .bak beside it. {} {}", erc_hint(&before, &after), "Reload the open editor before it saves over this edit: kicad_close then kicad_open_schematic on this file."),+        "_next": ["kicad_close", "kicad_open_schematic", "kicad_run_erc"],+    });+    if let (Some(o), Some(e)) = (v.as_object_mut(), done.extra.as_object()) {+        for (k, val) in e {+            o.insert(k.clone(), val.clone());+        }+    }+    v+}++// ---------------------------------------------------------------------------+// Operations+// ---------------------------------------------------------------------------++fn point(args: &Value, key: &str) -> Result<(f64, f64), Value> {+    let v = args.get(key).ok_or_else(|| fail("missing_arg", format!("{key} is required"), format!("Pass {key} as [x_mm, y_mm].")))?;+    let arr = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| fail("bad_arg", format!("{key} must be [x_mm, y_mm]"), "Two numbers in millimetres, sheet coordinates (origin top-left, y down)."))?;+    let x = arr[0].as_f64().ok_or_else(|| fail("bad_arg", format!("{key}[0] is not a number"), "Millimetres as numbers, not strings."))?;+    let y = arr[1].as_f64().ok_or_else(|| fail("bad_arg", format!("{key}[1] is not a number"), "Millimetres as numbers, not strings."))?;+    Ok((x, y))+}++fn rotation(args: &Value) -> f64 {+    args.get("rotation").and_then(Value::as_f64).unwrap_or(0.0)+}++fn op_fail(code: &str, e: String, hint: &str) -> Value {+    fail(code, e, hint)+}++/// The library block for `lib_id`: from an explicit libraryPath, else through the+/// project and global sym-lib-tables.+fn library_block(ctx: &Ctx, lib_id: &str) -> Result<(schematic::Node, String), Value> {+    let name = lib_id.split_once(':').map(|(_, n)| n).unwrap_or(lib_id);+    if let Some(lp) = arg_str(ctx.args, "libraryPath") {+        let r = schematic::load_lib_symbol(Path::new(lp.trim()), name).map_err(|e| op_fail("symbol_not_found", e, "Check the library file and the symbol name (case-sensitive); kicad_list_symbols lists what a library holds."))?;+        return Ok((r.block, r.library_path));+    }+    let r = schematic::resolve_lib_symbol(ctx.lib, lib_id, ctx.path.parent()).map_err(|e| {+        op_fail(+            "symbol_not_found",+            e,+            "libId is Library:Symbol as the Symbol Chooser shows it. The library must be in the project's sym-lib-table or the global one (kicad_adom_library_status shows the global table); pass libraryPath to read a .kicad_sym directly.",+        )+    })?;+    Ok((r.block, r.library_path))+}++fn place_symbol(ctx: &mut Ctx) -> Result<Done, Value> {+    let lib_id = arg_any(ctx.args, &["libId", "lib_id", "symbol"]).ok_or_else(|| fail("missing_arg", "libId is required", "Pass libId like \"Device:R\"."))?.trim().to_string();+    if !lib_id.contains(':') {+        return Err(fail("bad_arg", format!("libId {lib_id} is not Library:Symbol"), "Use the nickname and symbol name the Symbol Chooser shows, e.g. Device:C."));+    }+    let reference = arg_any(ctx.args, &["reference", "ref"]).ok_or_else(|| fail("missing_arg", "reference is required", "Pass the designator to place, e.g. R5; it must be unused on the sheet."))?.trim().to_string();+    let at = point(ctx.args, "at")?;+    let unit = ctx.args.get("unit").and_then(Value::as_u64).unwrap_or(1) as u32;+    let value = arg_str(ctx.args, "value");+    let footprint = arg_str(ctx.args, "footprint");+    let mut library_path = None;+    let block = if ctx.sch.has_lib_symbol(&lib_id) {+        None+    } else {+        let (b, lp) = library_block(ctx, &lib_id)?;+        library_path = Some(lp);+        Some(b)+    };+    let p = PlaceSymbol { lib_id: &lib_id, reference: &reference, at, rotation: rotation(ctx.args), unit, value, footprint };+    let placed = ctx.sch.place_symbol(&p, block.as_ref()).map_err(|e| op_fail("edit_failed", e, "Nothing was written. Fix the argument the error names and retry."))?;+    Ok(Done {+        output: format!("Placed {lib_id} as {reference} at ({}, {}) in {}", schematic::fmt_num(at.0), schematic::fmt_num(at.1), file_name(ctx.path)),+        uuid: placed.uuid,+        extra: json!({+            "reference": reference,+            "libId": lib_id,+            "at": [at.0, at.1],+            "unit": unit,+            "pins": placed.pins,+            "libSymbolAdded": placed.lib_symbol_added,+            "libraryPath": library_path,+        }),+    })+}++fn wire(ctx: &mut Ctx) -> Result<Done, Value> {+    let pts = ctx.args.get("points").and_then(Value::as_array).ok_or_else(|| fail("missing_arg", "points is required", "Pass points as [[x_mm, y_mm], [x_mm, y_mm], ...] with at least two entries."))?;+    let mut points = Vec::with_capacity(pts.len());+    for (i, p) in pts.iter().enumerate() {+        let pair = p.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a[0].as_f64()?, a[1].as_f64()?)));+        points.push(pair.ok_or_else(|| fail("bad_arg", format!("points[{i}] is not [x_mm, y_mm]"), "Every point is two numbers in millimetres."))?);+    }+    let uuids = ctx.sch.add_wire(&points).map_err(|e| op_fail("edit_failed", e, "Nothing was written. Pass at least two distinct points."))?;+    Ok(Done {+        output: format!("Drew {} wire segment(s) through {} point(s) in {}", uuids.len(), points.len(), file_name(ctx.path)),+        uuid: uuids[0].clone(),+        extra: json!({"uuids": uuids, "segments": uuids.len(), "points": points.iter().map(|p| json!([p.0, p.1])).collect::<Vec<_>>()}),+    })+}++fn label(ctx: &mut Ctx) -> Result<Done, Value> {+    let text = arg_any(ctx.args, &["text", "name", "net"]).ok_or_else(|| fail("missing_arg", "text is required", "Pass the net name, e.g. VIN or GND."))?.trim().to_string();+    let at = point(ctx.args, "at")?;+    let kind_s = arg_str(ctx.args, "kind").unwrap_or("local").trim().to_ascii_lowercase();+    let rot = rotation(ctx.args);+    if kind_s == "power" || kind_s == "power_symbol" {+        let lib_id = format!("power:{text}");+        let reference = arg_str(ctx.args, "reference").map(|s| s.trim().to_string()).unwrap_or_else(|| ctx.sch.next_reference("#PWR"));+        let block = if ctx.sch.has_lib_symbol(&lib_id) { None } else { Some(library_block(ctx, &lib_id)?.0) };+        let p = PlaceSymbol { lib_id: &lib_id, reference: &reference, at, rotation: rot, unit: 1, value: Some(&text), footprint: None };+        let placed = ctx.sch.place_symbol(&p, block.as_ref()).map_err(|e| op_fail("edit_failed", e, "Nothing was written. Fix the argument the error names and retry."))?;+        return Ok(Done {+            output: format!("Placed power symbol {lib_id} as {reference} at ({}, {}) in {}", schematic::fmt_num(at.0), schematic::fmt_num(at.1), file_name(ctx.path)),+            uuid: placed.uuid,+            extra: json!({"kind": "power", "text": text, "reference": reference, "libId": lib_id, "at": [at.0, at.1], "pins": placed.pins}),+        });+    }+    let kind = LabelKind::parse(&kind_s).ok_or_else(|| fail("bad_arg", format!("kind {kind_s} is not local, global, hierarchical or power"), "Use local for a net name on this sheet, global to span sheets, hierarchical for a sheet pin, power for GND/+3V3 style symbols."))?;+    let shape = arg_str(ctx.args, "shape");+    let uuid = ctx.sch.add_label(&text, kind, at, rot, shape).map_err(|e| op_fail("edit_failed", e, "Nothing was written. Fix the argument the error names and retry."))?;+    Ok(Done {+        output: format!("Added {} label {text} at ({}, {}) in {}", kind_s, schematic::fmt_num(at.0), schematic::fmt_num(at.1), file_name(ctx.path)),+        uuid,+        extra: json!({"kind": kind_s, "text": text, "at": [at.0, at.1], "rotation": rot}),+    })+}++fn move_symbol(ctx: &mut Ctx) -> Result<Done, Value> {+    let reference = arg_any(ctx.args, &["reference", "ref"]).ok_or_else(|| fail("missing_arg", "reference is required", "Pass the designator of the symbol to move, e.g. R5."))?.trim().to_string();+    let to = point(ctx.args, "to")?;+    let (uuid, from) = ctx.sch.move_symbol(&reference, to).map_err(|e| op_fail("not_found", e, "kicad_extract_netlist lists the references on the sheet."))?;+    Ok(Done {+        output: format!("Moved {reference} from ({}, {}) to ({}, {}) in {}", schematic::fmt_num(from.0), schematic::fmt_num(from.1), schematic::fmt_num(to.0), schematic::fmt_num(to.1), file_name(ctx.path)),+        uuid,+        extra: json!({"reference": reference, "from": [from.0, from.1], "to": [to.0, to.1]}),+    })+}++fn delete(ctx: &mut Ctx) -> Result<Done, Value> {+    let uuid = arg_str(ctx.args, "uuid").ok_or_else(|| fail("missing_arg", "uuid is required", "Pass the uuid of the item; the create verbs return it, and ERC items carry it."))?.trim().to_string();+    let kind = ctx.sch.delete(&uuid).map_err(|e| op_fail("not_found", e, "uuids are per item; check it against the create verb's response or kicad_extract_netlist."))?;+    Ok(Done { output: format!("Deleted {kind} {uuid} from {}", file_name(ctx.path)), uuid: uuid.clone(), extra: json!({"kind": kind}) })+}++fn set_property(ctx: &mut Ctx) -> Result<Done, Value> {+    let reference = arg_any(ctx.args, &["reference", "ref"]).ok_or_else(|| fail("missing_arg", "reference is required", "Pass the designator of the symbol, e.g. R5."))?.trim().to_string();+    let name = arg_any(ctx.args, &["name", "property", "field"]).ok_or_else(|| fail("missing_arg", "name is required", "Pass the field name: Value, Footprint, Datasheet, Reference or a custom field."))?.trim().to_string();+    let value = ctx.args.get("value").and_then(Value::as_str).ok_or_else(|| fail("missing_arg", "value is required", "Pass the new field text as a string (empty string clears it)."))?.to_string();+    let (uuid, old) = ctx.sch.set_property(&reference, &name, &value).map_err(|e| op_fail("edit_failed", e, "Nothing was written. kicad_extract_netlist lists the references on the sheet."))?;+    let what = match &old {+        Some(o) => format!("changed {name} of {reference} from {o:?} to {value:?}"),+        None => format!("added {name}={value:?} to {reference}"),+    };+    let new_ref = if name == "Reference" { value.clone() } else { reference.clone() };+    Ok(Done {+        output: format!("{} in {}", capitalize(&what), file_name(ctx.path)),+        uuid,+        extra: json!({"reference": new_ref, "name": name, "value": value, "previous": old, "added": old.is_none()}),+    })+}++fn capitalize(s: &str) -> String {+    let mut c = s.chars();+    match c.next() {+        Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),+        None => String::new(),+    }+}++fn file_name(p: &Path) -> String {+    p.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| norm(p))+}++// ---------------------------------------------------------------------------+// KiCad context and ERC+// ---------------------------------------------------------------------------++fn cli_exe(info: &KicadInfo) -> Option<PathBuf> {+    info.primary().and_then(|i| kicad_core::lint::cli_exe(&i.kicad_cli, &i.bin_dir))+}++/// Same construction as the libraries group: version, install root, config and user+/// dirs from the platform, Configure Paths overrides loaded.+fn lib_ctx(info: &KicadInfo) -> LibCtx {+    let inst = info.primary();+    let version = inst.map(|i| i.version.clone()).unwrap_or_default();+    let mut ctx = LibCtx {+        version: version.clone(),+        base_dir: inst.map(|i| i.base_dir.clone()).filter(|b| !b.is_empty()),+        config_dir: if version.is_empty() { None } else { native().config_dir(&version) },+        user_dir: if version.is_empty() { None } else { native().user_dir(&version) },+        kicad_cli: cli_exe(info),+        env_overrides: Default::default(),+    };+    ctx.load_env_overrides();+    ctx+}++struct Erc {+    /// (errors, warnings) when a report was produced.+    counts: Option<(u64, u64)>,+    reason: Option<String>,+    elapsed_ms: u64,+}++fn run_erc(cli_path: Option<&Path>, file: &Path) -> Erc {+    let Some(cli_path) = cli_path else {+        return Erc { counts: None, reason: Some("kicad-cli not installed on this box; the edit is unvalidated (kicad_readiness lists the alternatives, e.g. service-kicad)".into()), elapsed_ms: 0 };+    };+    let report = std::env::temp_dir().join(format!("adom-kicad-sch-erc-{}-{}.json", std::process::id(), schematic::new_uuid()));+    let report_s = norm(&report);+    let file_s = norm(file);+    let r = cli::run(cli_path, &["sch", "erc", "--format", "json", "--severity-all", "--output", report_s.as_str(), file_s.as_str()], Duration::from_secs(50));+    let parsed = std::fs::read_to_string(&report).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok());+    let _ = std::fs::remove_file(&report);+    match parsed {+        Some(rep) => {+            let s = cli::summarize_report(&rep);+            Erc { counts: Some((s["errors"].as_u64().unwrap_or(0), s["warnings"].as_u64().unwrap_or(0))), reason: None, elapsed_ms: r.elapsed_ms }+        }+        None => {+            let tail: String = r.stderr.trim().chars().rev().take(300).collect::<Vec<_>>().into_iter().rev().collect();+            let why = if r.timed_out { "kicad-cli sch erc timed out after 50 s".to_string() } else { format!("kicad-cli sch erc wrote no report (exit {}): {}", r.exit_code, tail) };+            Erc { counts: None, reason: Some(why), elapsed_ms: r.elapsed_ms }+        }+    }+}++fn counts_json(c: Option<(u64, u64)>) -> Value {+    match c {+        Some((e, w)) => json!({"errors": e, "warnings": w}),+        None => Value::Null,+    }+}++fn erc_block(before: &Erc, after: &Erc) -> Value {+    let ran = before.counts.is_some() && after.counts.is_some();+    let delta = match (before.counts, after.counts) {+        (Some(b), Some(a)) => json!({"errors": a.0 as i64 - b.0 as i64, "warnings": a.1 as i64 - b.1 as i64}),+        _ => Value::Null,+    };+    let mut v = json!({+        "ran": ran,+        "before": counts_json(before.counts),+        "after": counts_json(after.counts),+        "delta": delta,+        "elapsedMs": before.elapsed_ms + after.elapsed_ms,+    });+    if let Some(r) = after.reason.as_ref().or(before.reason.as_ref()) {+        v["reason"] = json!(r);+    }+    v+}++fn erc_hint(before: &Erc, after: &Erc) -> String {+    match (before.counts, after.counts) {+        (Some(b), Some(a)) => {+            if a.0 > b.0 {+                format!("ERC errors went UP by {} (kicad_run_erc shows which; a placed symbol's pins count as unconnected until wired).", a.0 - b.0)+            } else if a.0 < b.0 {+                format!("ERC errors went down by {}.", b.0 - a.0)+            } else if a.1 != b.1 {+                format!("ERC errors unchanged; warnings {} -> {}.", b.1, a.1)+            } else {+                "ERC unchanged by this edit.".to_string()+            }+        }+        _ => format!("ERC did not run ({}); the file was not validated.", after.reason.clone().or_else(|| before.reason.clone()).unwrap_or_default()),+    } }
rust/crates/kicad-core/src/install.rs+24−11
@@ -127,9 +127,11 @@ fn py_float(x: f64) -> String {     } } +/// Python's round(): correctly rounded on the float's exact decimal value, ties to+/// even (108.9125 -> 108.912). Rust's `{:.N}` formatting has the same semantics, so+/// go through it rather than the half-away-from-zero `f64::round`. fn round_to(x: f64, places: i32) -> f64 {-    let f = 10f64.powi(places);-    (x * f).round() / f+    format!("{:.*}", places.max(0) as usize, x).parse().unwrap_or(x) }  /// Sorted `*.kicad_mod` paths in a directory.@@ -340,13 +342,15 @@ 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));-    }+    let fresh = !(config.is_dir() && user.is_dir());+    // The two library folders are always ensured (idempotent, like the Python's mkdir+    // exist_ok); the lib-table seed and heal run only on a fresh box.     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());+    if fresh {+        heal_lib_tables(&config, ctx.base_dir.as_deref());+    }     Ok((config, user)) } @@ -530,6 +534,11 @@ fn attr_head(block: &str) -> &str { /// Returns `{success, added[], lib_path, kicadParser}` or `{success:false, error, /// errorCode?, kicadParser?, _hint?}`. pub fn add_symbol(ctx: &LibCtx, symbol_text: &str) -> Value {+    // Same bootstrap the Python does through kicad_detect.ensure_win_user_config: a+    // never-launched KiCad has no config or user dir yet (issue #20, ADOMBASELINE 2026-07).+    if let Err(e) = ensure_user_config(ctx) {+        return json!({"success": false, "error": e});+    }     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});@@ -2120,16 +2129,20 @@ mod tests {         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}));+        let c_path = bx.root.join("C.kicad_sym");+        std::fs::write(&c_path, SYM_C).unwrap();+        let r = install_symbol(&bx.ctx, true, &json!({"filePath": norm_path(&c_path), "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}");+        // The bridge's empty library template carries no (embedded_fonts) node (same as+        // the Python's _EMPTY_LIB), so the merged file ends with the closing paren and+        // the new symbol sits after the last existing one.+        assert!(text.trim_end().ends_with(')'), "{text}");+        assert!(text.find("(symbol \"R_Adom\"").unwrap() < text.find("(symbol \"C_Adom\"").unwrap());         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}));@@ -2481,7 +2494,7 @@ mod tests {         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("(gr_line (start 104.32 64.35) (end 111.68 64.35)"), "{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());
rust/crates/kicad-core/src/schematic.rs+1074−1
@@ -1 +1,1074 @@-//! Placeholder: filled in by the phase 1 port.+//! Schematic edit in place. KiCad 10 has no schematic API, so the kicad_sch_* verbs+//! rewrite `.kicad_sch` on disk. This module is the OS-neutral half of that:+//!+//! * a lossless s-expression document model. Every token keeps its original text+//!   (numbers stay the digits KiCad wrote, quoted strings keep their escapes) and every+//!   run of whitespace is kept on the node that follows it, so a file that is parsed+//!   and written back unchanged is byte-identical, and an edit touches only the nodes+//!   it edits;+//! * a pretty printer that lays out NEW nodes the way eeschema's formatter does+//!   (tabs, one list per line, closing paren on its own line when the list has list+//!   children, `(xy ..)` pairs sharing a line);+//! * the edit operations: place a symbol (with its `lib_symbols` entry copied from the+//!   library, derived symbols flattened), wire, label, move, delete, set property;+//! * library resolution for a `Nick:Name` lib id through the project and global+//!   sym-lib-table (via `libraries::LibCtx` and `expand_kicad_uri`), and a v4 UUID+//!   generator built on std only.+//!+//! Validation (ERC) and the reload hint live in the verb layer.++use std::collections::BTreeSet;+use std::path::{Path, PathBuf};++use crate::libraries::{self, expand_kicad_uri, LibCtx, LibTable};++// ---------------------------------------------------------------------------+// Lossless document model+// ---------------------------------------------------------------------------++/// One s-expression node. `lead` is the whitespace that precedes it in the file.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct Node {+    pub lead: String,+    pub kind: Kind,+}++#[derive(Clone, Debug, PartialEq, Eq)]+pub enum Kind {+    /// Raw token text: a bare atom (`yes`, `1.27`, `xy`) or a quoted string WITH its+    /// quotes and escapes (`"R1"`, `"a \"b\""`).+    Atom(String),+    /// `close_lead` is the whitespace before the closing paren.+    List { children: Vec<Node>, close_lead: String },+}++/// A whole file: the root list plus whatever follows it (KiCad writes one "\n").+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct Document {+    pub root: Node,+    pub tail: String,+}++/// Quote a string the way KiCad's CTX_QUOTED_STR escaper does.+pub fn quote(s: &str) -> String {+    let mut out = String::with_capacity(s.len() + 2);+    out.push('"');+    for c in s.chars() {+        match c {+            '"' => out.push_str("\\\""),+            '\\' => out.push_str("\\\\"),+            '\n' => out.push_str("\\n"),+            '\r' => out.push_str("\\r"),+            '\t' => out.push_str("\\t"),+            _ => out.push(c),+        }+    }+    out.push('"');+    out+}++/// Undo `quote`. A bare atom comes back unchanged.+pub fn unquote(raw: &str) -> String {+    if raw.len() < 2 || !raw.starts_with('"') || !raw.ends_with('"') {+        return raw.to_string();+    }+    let inner = &raw[1..raw.len() - 1];+    let mut out = String::with_capacity(inner.len());+    let mut chars = inner.chars();+    while let Some(c) = chars.next() {+        if c != '\\' {+            out.push(c);+            continue;+        }+        match chars.next() {+            Some('n') => out.push('\n'),+            Some('r') => out.push('\r'),+            Some('t') => out.push('\t'),+            Some(o) => out.push(o),+            None => out.push('\\'),+        }+    }+    out+}++/// Millimetres the way eeschema writes them: up to four decimals, no trailing zeros.+pub fn fmt_num(v: f64) -> String {+    let r = (v * 10000.0).round() / 10000.0;+    let r = if r == 0.0 { 0.0 } else { r }; // no "-0"+    let mut s = format!("{r:.4}");+    while s.ends_with('0') {+        s.pop();+    }+    if s.ends_with('.') {+        s.pop();+    }+    s+}++impl Node {+    pub fn atom(raw: impl Into<String>) -> Node {+        Node { lead: String::new(), kind: Kind::Atom(raw.into()) }+    }+    pub fn quoted(s: &str) -> Node {+        Node::atom(quote(s))+    }+    pub fn number(v: f64) -> Node {+        Node::atom(fmt_num(v))+    }+    /// `(head child child ...)` with no layout yet; run `pretty` before inserting.+    pub fn list(head: &str, mut rest: Vec<Node>) -> Node {+        let mut children = Vec::with_capacity(rest.len() + 1);+        children.push(Node::atom(head));+        children.append(&mut rest);+        Node { lead: String::new(), kind: Kind::List { children, close_lead: String::new() } }+    }++    pub fn is_list(&self) -> bool {+        matches!(self.kind, Kind::List { .. })+    }+    pub fn children(&self) -> &[Node] {+        match &self.kind {+            Kind::List { children, .. } => children,+            Kind::Atom(_) => &[],+        }+    }+    pub fn children_mut(&mut self) -> Option<&mut Vec<Node>> {+        match &mut self.kind {+            Kind::List { children, .. } => Some(children),+            Kind::Atom(_) => None,+        }+    }+    /// Raw token text of an atom.+    pub fn raw(&self) -> Option<&str> {+        match &self.kind {+            Kind::Atom(s) => Some(s),+            Kind::List { .. } => None,+        }+    }+    /// Unquoted value of an atom.+    pub fn value(&self) -> Option<String> {+        self.raw().map(unquote)+    }+    /// The head atom of a list: `lib_id` for `(lib_id "Device:R")`.+    pub fn head(&self) -> Option<&str> {+        self.children().first().and_then(Node::raw)+    }+    /// The i-th atom after the head, unquoted. `arg(0)` of `(at 1 2 3)` is `1`.+    pub fn arg(&self, i: usize) -> Option<String> {+        self.children().get(i + 1).and_then(Node::value)+    }+    pub fn num(&self, i: usize) -> Option<f64> {+        self.arg(i)?.parse().ok()+    }+    pub fn child(&self, name: &str) -> Option<&Node> {+        self.children().iter().find(|c| c.head() == Some(name))+    }+    pub fn child_mut(&mut self, name: &str) -> Option<&mut Node> {+        self.children_mut()?.iter_mut().find(|c| c.head() == Some(name))+    }+    pub fn child_value(&self, name: &str) -> Option<String> {+        self.child(name)?.arg(0)+    }+    /// Replace the i-th atom after the head (index must exist).+    pub fn set_arg(&mut self, i: usize, node: Node) -> bool {+        match self.children_mut().and_then(|c| c.get_mut(i + 1)) {+            Some(slot) => {+                let lead = std::mem::take(&mut slot.lead);+                *slot = node;+                slot.lead = if lead.is_empty() { " ".into() } else { lead };+                true+            }+            None => false,+        }+    }++    pub fn write(&self, out: &mut String) {+        out.push_str(&self.lead);+        match &self.kind {+            Kind::Atom(s) => out.push_str(s),+            Kind::List { children, close_lead } => {+                out.push('(');+                for c in children {+                    c.write(out);+                }+                out.push_str(close_lead);+                out.push(')');+            }+        }+    }+    pub fn to_text(&self) -> String {+        let mut s = String::new();+        self.write(&mut s);+        s+    }+}++struct Parser<'a> {+    text: &'a str,+    b: &'a [u8],+    i: usize,+}++impl<'a> Parser<'a> {+    fn ws(&mut self) -> String {+        let start = self.i;+        while self.i < self.b.len() && (self.b[self.i] as char).is_ascii_whitespace() {+            self.i += 1;+        }+        self.text[start..self.i].to_string()+    }+    fn node(&mut self, lead: String) -> Result<Node, String> {+        let Some(&c) = self.b.get(self.i) else { return Err("unexpected end of file".into()) };+        match c {+            b'(' => {+                self.i += 1;+                let mut children = Vec::new();+                loop {+                    let ws = self.ws();+                    match self.b.get(self.i) {+                        None => return Err(format!("unbalanced s-expression: list opened at byte {} never closes", self.i)),+                        Some(b')') => {+                            self.i += 1;+                            return Ok(Node { lead, kind: Kind::List { children, close_lead: ws } });+                        }+                        Some(_) => children.push(self.node(ws)?),+                    }+                }+            }+            b')' => Err(format!("unexpected ')' at byte {}", self.i)),+            b'"' => {+                let start = self.i;+                self.i += 1;+                let mut esc = false;+                loop {+                    let Some(&ch) = self.b.get(self.i) else { return Err(format!("unterminated quoted string at byte {start}")) };+                    self.i += 1;+                    if esc {+                        esc = false;+                    } else if ch == b'\\' {+                        esc = true;+                    } else if ch == b'"' {+                        break;+                    }+                }+                Ok(Node { lead, kind: Kind::Atom(self.text[start..self.i].to_string()) })+            }+            _ => {+                let start = self.i;+                while let Some(&ch) = self.b.get(self.i) {+                    if (ch as char).is_ascii_whitespace() || ch == b'(' || ch == b')' || ch == b'"' {+                        break;+                    }+                    self.i += 1;+                }+                Ok(Node { lead, kind: Kind::Atom(self.text[start..self.i].to_string()) })+            }+        }+    }+}++/// Parse a single node (leading whitespace kept as its `lead`); trailing text is an error.+pub fn parse_node(text: &str) -> Result<Node, String> {+    let mut p = Parser { text, b: text.as_bytes(), i: 0 };+    let lead = p.ws();+    let n = p.node(lead)?;+    if !text[p.i..].trim().is_empty() {+        return Err(format!("trailing content after the s-expression at byte {}", p.i));+    }+    Ok(n)+}++impl Document {+    pub fn parse(text: &str) -> Result<Document, String> {+        let mut p = Parser { text, b: text.as_bytes(), i: 0 };+        let lead = p.ws();+        if p.b.get(p.i) != Some(&b'(') {+            return Err("not an s-expression file (no opening paren)".into());+        }+        let root = p.node(lead)?;+        let tail = text[p.i..].to_string();+        if !tail.trim().is_empty() {+            return Err("trailing content after the root list".into());+        }+        Ok(Document { root, tail })+    }+    pub fn to_text(&self) -> String {+        let mut s = String::new();+        self.root.write(&mut s);+        s.push_str(&self.tail);+        s+    }+}++/// Lay a node out the way eeschema's formatter does, at `depth` tabs. Atoms follow the+/// head on one line; every list child starts a new line one level deeper; a list with+/// list children closes on its own line; consecutive `(xy ..)` pairs share a line.+pub fn pretty(node: &mut Node, depth: usize) {+    fn indent(depth: usize) -> String {+        let mut s = String::with_capacity(depth + 1);+        s.push('\n');+        for _ in 0..depth {+            s.push('\t');+        }+        s+    }+    node.lead = indent(depth);+    layout_children(node, depth);+    fn layout_children(node: &mut Node, depth: usize) {+        let Kind::List { children, close_lead } = &mut node.kind else { return };+        let mut has_list = false;+        let mut prev_xy = false;+        for (i, c) in children.iter_mut().enumerate() {+            match &c.kind {+                Kind::Atom(_) => {+                    c.lead = if i == 0 { String::new() } else { " ".into() };+                    prev_xy = false;+                }+                Kind::List { .. } => {+                    has_list = true;+                    let is_xy = c.head() == Some("xy");+                    c.lead = if is_xy && prev_xy { " ".into() } else { indent(depth + 1) };+                    prev_xy = is_xy;+                    layout_children(c, depth + 1);+                }+            }+        }+        *close_lead = if has_list { indent(depth) } else { String::new() };+    }+}++// ---------------------------------------------------------------------------+// UUIDs+// ---------------------------------------------------------------------------++/// A random v4 UUID from std only: the OS-seeded `RandomState` hasher mixed with the+/// clock, the pid and a process-wide counter. Same code on every OS.+pub fn new_uuid() -> String {+    use std::hash::{BuildHasher, Hasher};+    use std::sync::atomic::{AtomicU64, Ordering};+    static COUNTER: AtomicU64 = AtomicU64::new(0);+    let n = COUNTER.fetch_add(1, Ordering::Relaxed);+    let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);+    let mut words = [0u64; 2];+    let stack_marker = &n as *const u64 as usize;+    for (k, w) in words.iter_mut().enumerate() {+        let mut h = std::collections::hash_map::RandomState::new().build_hasher();+        h.write_u128(nanos);+        h.write_u32(std::process::id());+        h.write_u64(n);+        h.write_usize(k);+        h.write_usize(stack_marker);+        *w = h.finish();+    }+    let mut b = [0u8; 16];+    b[..8].copy_from_slice(&words[0].to_le_bytes());+    b[8..].copy_from_slice(&words[1].to_le_bytes());+    b[6] = (b[6] & 0x0f) | 0x40; // version 4+    b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant+    let hex: Vec<String> = b.iter().map(|x| format!("{x:02x}")).collect();+    let h = hex.concat();+    format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])+}++// ---------------------------------------------------------------------------+// Schematic+// ---------------------------------------------------------------------------++/// One placed symbol as the verbs report it.+#[derive(Clone, Debug, serde::Serialize)]+pub struct SymbolInfo {+    pub uuid: String,+    pub reference: String,+    #[serde(rename = "libId")]+    pub lib_id: String,+    pub value: String,+    pub at: [f64; 2],+    pub rotation: f64,+    pub unit: u32,+}++#[derive(Clone, Debug)]+pub struct PlaceSymbol<'a> {+    pub lib_id: &'a str,+    pub reference: &'a str,+    pub at: (f64, f64),+    pub rotation: f64,+    pub unit: u32,+    pub value: Option<&'a str>,+    pub footprint: Option<&'a str>,+}++#[derive(Clone, Debug)]+pub struct Placed {+    pub uuid: String,+    /// True when the `lib_symbols` entry was copied in by this call.+    pub lib_symbol_added: bool,+    pub pins: Vec<String>,+    pub is_power: bool,+}++#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum LabelKind {+    Local,+    Global,+    Hierarchical,+}++impl LabelKind {+    pub fn parse(s: &str) -> Option<LabelKind> {+        match s.trim().to_ascii_lowercase().as_str() {+            "local" | "label" | "net" | "" => Some(LabelKind::Local),+            "global" | "global_label" => Some(LabelKind::Global),+            "hierarchical" | "hier" | "hierarchical_label" | "sheet" => Some(LabelKind::Hierarchical),+            _ => None,+        }+    }+    pub fn token(self) -> &'static str {+        match self {+            LabelKind::Local => "label",+            LabelKind::Global => "global_label",+            LabelKind::Hierarchical => "hierarchical_label",+        }+    }+}++/// A `.kicad_sch` open for editing.+pub struct Schematic {+    pub doc: Document,+    /// Project name hint from the file's location (`<name>.kicad_pro` next to it, else+    /// the file stem). Existing symbol instances win over it.+    pub project_hint: Option<String>,+}++const TOP_TAIL: &[&str] = &["sheet_instances", "symbol_instances", "embedded_fonts"];++impl Schematic {+    pub fn parse(text: &str) -> Result<Schematic, String> {+        let doc = Document::parse(text)?;+        if doc.root.head() != Some("kicad_sch") {+            return Err(format!("not a schematic: the root list is ({})", doc.root.head().unwrap_or("")));+        }+        Ok(Schematic { doc, project_hint: None })+    }++    pub fn load(path: &Path) -> Result<Schematic, String> {+        let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;+        let mut s = Schematic::parse(&text)?;+        s.project_hint = project_name_for(path);+        Ok(s)+    }++    pub fn to_text(&self) -> String {+        self.doc.to_text()+    }++    /// Copy the current file to `<file>.bak`, then write the document atomically.+    pub fn save_with_backup(&self, path: &Path) -> Result<PathBuf, String> {+        let mut bak = path.as_os_str().to_owned();+        bak.push(".bak");+        let bak = PathBuf::from(bak);+        if path.exists() {+            std::fs::copy(path, &bak).map_err(|e| format!("cannot write backup {}: {e}", bak.display()))?;+        }+        libraries::write_atomic(path, self.to_text().as_bytes())?;+        Ok(bak)+    }++    pub fn root_uuid(&self) -> Option<String> {+        self.doc.root.child_value("uuid")+    }++    pub fn version(&self) -> Option<String> {+        self.doc.root.child_value("version")+    }++    fn top(&self) -> &[Node] {+        self.doc.root.children()+    }+    fn top_mut(&mut self) -> &mut Vec<Node> {+        self.doc.root.children_mut().expect("root is a list")+    }++    /// Placed symbols (top-level `(symbol (lib_id ..) ..)` items).+    pub fn symbols(&self) -> Vec<SymbolInfo> {+        self.top().iter().filter(|n| is_symbol_instance(n)).map(symbol_info).collect()+    }++    pub fn find_symbol_index(&self, reference: &str) -> Option<usize> {+        self.top().iter().position(|n| is_symbol_instance(n) && symbol_property(n, "Reference").as_deref() == Some(reference))+    }++    pub fn find_uuid_index(&self, uuid: &str) -> Option<usize> {+        self.top().iter().position(|n| n.is_list() && n.child_value("uuid").as_deref() == Some(uuid))+    }++    /// Project name for the instances block: what the existing symbols use, else the+    /// `.kicad_pro` next to the file, else the file stem.+    pub fn project_name(&self) -> String {+        for s in self.top().iter().filter(|n| is_symbol_instance(n)) {+            if let Some(p) = s.child("instances").and_then(|i| i.child_value("project")) {+                if !p.is_empty() {+                    return p;+                }+            }+        }+        self.project_hint.clone().unwrap_or_else(|| "project".into())+    }++    /// Instances path for a new symbol: the path existing symbols use on this sheet+    /// (so a sub-sheet keeps its hierarchy), else "/<root uuid>".+    pub fn instance_path(&self) -> Result<String, String> {+        for s in self.top().iter().filter(|n| is_symbol_instance(n)) {+            if let Some(p) = s.child("instances").and_then(|i| i.child("project")).and_then(|p| p.child_value("path")) {+                if p.starts_with('/') {+                    return Ok(p);+                }+            }+        }+        self.root_uuid().map(|u| format!("/{u}")).ok_or_else(|| "the schematic has no (uuid ...) at the root; cannot build the symbol instances path".to_string())+    }++    pub fn has_lib_symbol(&self, lib_id: &str) -> bool {+        self.lib_symbol(lib_id).is_some()+    }++    pub fn lib_symbol(&self, lib_id: &str) -> Option<&Node> {+        self.doc.root.child("lib_symbols")?.children().iter().find(|n| n.head() == Some("symbol") && n.arg(0).as_deref() == Some(lib_id))+    }++    /// Add a library symbol block (as read from a `.kicad_sym`, already flattened) under+    /// `lib_symbols` as `lib_id`. No-op when the entry exists.+    pub fn add_lib_symbol(&mut self, lib_id: &str, block: &Node) -> Result<bool, String> {+        if self.has_lib_symbol(lib_id) {+            return Ok(false);+        }+        if block.head() != Some("symbol") || block.arg(0).is_none() {+            return Err("library symbol block must be (symbol \"Name\" ...)".into());+        }+        let mut entry = block.clone();+        entry.set_arg(0, Node::quoted(lib_id));+        pretty(&mut entry, 2);+        if self.doc.root.child("lib_symbols").is_none() {+            let mut ls = Node::list("lib_symbols", vec![]);+            pretty(&mut ls, 1);+            let top = self.top_mut();+            let after = ["title_block", "paper", "uuid", "generator_version", "generator", "version"];+            let mut idx = 1;+            for name in after {+                if let Some(i) = top.iter().position(|n| n.head() == Some(name)) {+                    idx = i + 1;+                    break;+                }+            }+            top.insert(idx, ls);+        }+        let ls = self.doc.root.child_mut("lib_symbols").expect("just ensured");+        let Kind::List { children, close_lead } = &mut ls.kind else { unreachable!() };+        children.push(entry);+        if close_lead.is_empty() {+            *close_lead = "\n\t".into();+        }+        Ok(true)+    }++    fn insert_item(&mut self, mut node: Node) {+        pretty(&mut node, 1);+        let top = self.top_mut();+        let idx = top.iter().position(|n| n.head().map(|h| TOP_TAIL.contains(&h)).unwrap_or(false)).unwrap_or(top.len());+        top.insert(idx, node);+    }++    /// Place a symbol. `lib_block` is the `(symbol "Name" ...)` block from the library,+    /// needed only when the sheet has no `lib_symbols` entry for the lib id yet.+    pub fn place_symbol(&mut self, p: &PlaceSymbol, lib_block: Option<&Node>) -> Result<Placed, String> {+        if p.reference.trim().is_empty() {+            return Err("reference is required".into());+        }+        if self.find_symbol_index(p.reference).is_some() {+            return Err(format!("reference {} is already placed on this sheet", p.reference));+        }+        let rot = norm_rotation(p.rotation)?;+        let mut added = false;+        if !self.has_lib_symbol(p.lib_id) {+            let blk = lib_block.ok_or_else(|| format!("no lib_symbols entry for {} in the sheet and no library block supplied", p.lib_id))?;+            added = self.add_lib_symbol(p.lib_id, blk)?;+        }+        let lib = self.lib_symbol(p.lib_id).expect("ensured").clone();+        let base_name = p.lib_id.split_once(':').map(|(_, n)| n).unwrap_or(p.lib_id);+        let is_power = lib.child("power").is_some();+        let pins = lib_symbol_pins(&lib, base_name, p.unit);+        let project = self.project_name();+        let path = self.instance_path()?;+        let (x, y) = p.at;+        let uuid = new_uuid();++        let mut props: Vec<Node> = Vec::new();+        let lib_prop = |name: &str| -> String { lib_property_value(&lib, name).unwrap_or_default() };+        let value = p.value.map(str::to_string).unwrap_or_else(|| {+            let v = lib_prop("Value");+            if v.is_empty() { base_name.to_string() } else { v }+        });+        let footprint = p.footprint.map(str::to_string).unwrap_or_else(|| lib_prop("Footprint"));+        // Reference and Value sit beside the body; the rest is hidden at the anchor.+        props.push(property_node("Reference", p.reference, (x + 2.54, y - 1.27), Some("left"), is_power));+        props.push(property_node("Value", &value, (x + 2.54, y + 1.27), Some("left"), false));+        props.push(property_node("Footprint", &footprint, (x, y), None, true));+        props.push(property_node("Datasheet", &lib_prop("Datasheet"), (x, y), None, true));+        props.push(property_node("Description", &lib_prop("Description"), (x, y), None, true));+        for extra in lib.children().iter().filter(|n| n.head() == Some("property")) {+            let name = extra.arg(0).unwrap_or_default();+            if name.starts_with("ki_") || ["Reference", "Value", "Footprint", "Datasheet", "Description"].contains(&name.as_str()) {+                continue;+            }+            props.push(property_node(&name, &extra.arg(1).unwrap_or_default(), (x, y), None, true));+        }++        let mut children = vec![+            Node::list("lib_id", vec![Node::quoted(p.lib_id)]),+            Node::list("at", vec![Node::number(x), Node::number(y), Node::number(rot)]),+            Node::list("unit", vec![Node::atom(p.unit.to_string())]),+            Node::list("exclude_from_sim", vec![Node::atom("no")]),+            Node::list("in_bom", vec![Node::atom("yes")]),+            Node::list("on_board", vec![Node::atom("yes")]),+            Node::list("dnp", vec![Node::atom("no")]),+            Node::list("fields_autoplaced", vec![Node::atom("yes")]),+            Node::list("uuid", vec![Node::quoted(&uuid)]),+        ];+        children.append(&mut props);+        for pin in &pins {+            children.push(Node::list("pin", vec![Node::quoted(pin), Node::list("uuid", vec![Node::quoted(&new_uuid())])]));+        }+        children.push(Node::list(+            "instances",+            vec![Node::list(+                "project",+                vec![+                    Node::quoted(&project),+                    Node::list(+                        "path",+                        vec![+                            Node::quoted(&path),+                            Node::list("reference", vec![Node::quoted(p.reference)]),+                            Node::list("unit", vec![Node::atom(p.unit.to_string())]),+                        ],+                    ),+                ],+            )],+        ));+        self.insert_item(Node::list("symbol", children));+        Ok(Placed { uuid, lib_symbol_added: added, pins, is_power })+    }++    /// One `(wire ...)` per straight segment; returns their uuids in order.+    pub fn add_wire(&mut self, points: &[(f64, f64)]) -> Result<Vec<String>, String> {+        if points.len() < 2 {+            return Err("a wire needs at least two points".into());+        }+        let mut uuids = Vec::new();+        for seg in points.windows(2) {+            let (a, b) = (seg[0], seg[1]);+            if a == b {+                continue;+            }+            let uuid = new_uuid();+            self.insert_item(Node::list(+                "wire",+                vec![+                    Node::list("pts", vec![xy(a), xy(b)]),+                    Node::list("stroke", vec![Node::list("width", vec![Node::number(0.0)]), Node::list("type", vec![Node::atom("default")])]),+                    Node::list("uuid", vec![Node::quoted(&uuid)]),+                ],+            ));+            uuids.push(uuid);+        }+        if uuids.is_empty() {+            return Err("all points are the same; nothing to draw".into());+        }+        Ok(uuids)+    }++    /// A net label. `shape` (input|output|bidirectional|tri_state|passive) applies to+    /// global and hierarchical labels only.+    pub fn add_label(&mut self, text: &str, kind: LabelKind, at: (f64, f64), rotation: f64, shape: Option<&str>) -> Result<String, String> {+        if text.trim().is_empty() {+            return Err("label text is required".into());+        }+        let rot = norm_rotation(rotation)?;+        let uuid = new_uuid();+        let (x, y) = at;+        let right = rot == 180.0 || rot == 270.0;+        let mut children = vec![Node::quoted(text)];+        let justify: Vec<Node> = match kind {+            LabelKind::Local => vec![Node::atom(if right { "right" } else { "left" }), Node::atom("bottom")],+            _ => vec![Node::atom(if right { "right" } else { "left" })],+        };+        if kind != LabelKind::Local {+            let shape = shape.map(|s| s.trim().to_ascii_lowercase()).unwrap_or_else(|| if kind == LabelKind::Global { "bidirectional".into() } else { "input".into() });+            if !["input", "output", "bidirectional", "tri_state", "passive"].contains(&shape.as_str()) {+                return Err(format!("shape must be input, output, bidirectional, tri_state or passive (got {shape})"));+            }+            children.push(Node::list("shape", vec![Node::atom(shape)]));+        }+        children.push(Node::list("at", vec![Node::number(x), Node::number(y), Node::number(rot)]));+        if kind != LabelKind::Local {+            children.push(Node::list("fields_autoplaced", vec![Node::atom("yes")]));+        }+        children.push(Node::list("effects", vec![font_node(), Node::list("justify", justify)]));+        children.push(Node::list("uuid", vec![Node::quoted(&uuid)]));+        if kind == LabelKind::Global {+            children.push(property_node("Intersheetrefs", "${INTERSHEET_REFS}", (x, y), None, true));+        }+        self.insert_item(Node::list(kind.token(), children));+        Ok(uuid)+    }++    /// Move a symbol (and its fields with it) so its anchor lands on `to`.+    pub fn move_symbol(&mut self, reference: &str, to: (f64, f64)) -> Result<(String, (f64, f64)), String> {+        let idx = self.find_symbol_index(reference).ok_or_else(|| format!("no symbol with reference {reference} on this sheet"))?;+        let node = &mut self.top_mut()[idx];+        let at = node.child("at").ok_or("symbol has no (at ...)")?;+        let (ox, oy) = (at.num(0).unwrap_or(0.0), at.num(1).unwrap_or(0.0));+        let (dx, dy) = (to.0 - ox, to.1 - oy);+        shift_at(node.child_mut("at").expect("checked"), dx, dy);+        for prop in node.children_mut().expect("list").iter_mut().filter(|n| n.head() == Some("property")) {+            if let Some(a) = prop.child_mut("at") {+                shift_at(a, dx, dy);+            }+        }+        let uuid = node.child_value("uuid").unwrap_or_default();+        Ok((uuid, (ox, oy)))+    }++    /// Delete any top-level item by uuid (symbol, wire, label, junction, sheet, ...).+    /// Returns the item's kind.+    pub fn delete(&mut self, uuid: &str) -> Result<String, String> {+        let idx = self.find_uuid_index(uuid).ok_or_else(|| format!("no item with uuid {uuid} on this sheet"))?;+        let node = self.top_mut().remove(idx);+        let kind = node.head().unwrap_or("item").to_string();+        Ok(kind)+    }++    /// Set (or add) a field on a symbol. Reference also updates the instances block.+    pub fn set_property(&mut self, reference: &str, name: &str, value: &str) -> Result<(String, Option<String>), String> {+        if name.trim().is_empty() {+            return Err("property name is required".into());+        }+        if name == "Reference" && value.trim().is_empty() {+            return Err("Reference cannot be empty".into());+        }+        if name == "Reference" && value != reference && self.find_symbol_index(value).is_some() {+            return Err(format!("reference {value} is already used on this sheet"));+        }+        let idx = self.find_symbol_index(reference).ok_or_else(|| format!("no symbol with reference {reference} on this sheet"))?;+        let node = &mut self.top_mut()[idx];+        let uuid = node.child_value("uuid").unwrap_or_default();+        let (ax, ay) = node.child("at").map(|a| (a.num(0).unwrap_or(0.0), a.num(1).unwrap_or(0.0))).unwrap_or((0.0, 0.0));+        let children = node.children_mut().expect("list");+        let mut old = None;+        if let Some(prop) = children.iter_mut().find(|n| n.head() == Some("property") && n.arg(0).as_deref() == Some(name)) {+            old = prop.arg(1);+            prop.set_arg(1, Node::quoted(value));+        } else {+            let mut p = property_node(name, value, (ax, ay), None, true);+            let depth = 2;+            pretty(&mut p, depth);+            let last_prop = children.iter().rposition(|n| n.head() == Some("property"));+            let at = last_prop.map(|i| i + 1).unwrap_or_else(|| children.iter().position(|n| n.head() == Some("pin") || n.head() == Some("instances")).unwrap_or(children.len()));+            children.insert(at, p);+        }+        if name == "Reference" {+            if let Some(inst) = children.iter_mut().find(|n| n.head() == Some("instances")) {+                for proj in inst.children_mut().expect("list").iter_mut().filter(|n| n.head() == Some("project")) {+                    for path in proj.children_mut().expect("list").iter_mut().filter(|n| n.head() == Some("path")) {+                        if let Some(r) = path.child_mut("reference") {+                            r.set_arg(0, Node::quoted(value));+                        }+                    }+                }+            }+        }+        Ok((uuid, old))+    }++    /// Next free `<prefix><n>` reference on this sheet (`#PWR` gives `#PWR01` style).+    pub fn next_reference(&self, prefix: &str) -> String {+        let used: BTreeSet<u32> = self+            .symbols()+            .iter()+            .filter_map(|s| s.reference.strip_prefix(prefix).and_then(|n| n.parse::<u32>().ok()))+            .collect();+        let mut n = 1;+        while used.contains(&n) {+            n += 1;+        }+        if prefix.starts_with('#') {+            format!("{prefix}{n:02}")+        } else {+            format!("{prefix}{n}")+        }+    }+}++fn xy(p: (f64, f64)) -> Node {+    Node::list("xy", vec![Node::number(p.0), Node::number(p.1)])+}++fn font_node() -> Node {+    Node::list("font", vec![Node::list("size", vec![Node::number(1.27), Node::number(1.27)])])+}++fn property_node(name: &str, value: &str, at: (f64, f64), justify: Option<&str>, hide: bool) -> Node {+    let mut effects = vec![font_node()];+    if let Some(j) = justify {+        effects.push(Node::list("justify", vec![Node::atom(j)]));+    }+    if hide {+        effects.push(Node::list("hide", vec![Node::atom("yes")]));+    }+    Node::list(+        "property",+        vec![+            Node::quoted(name),+            Node::quoted(value),+            Node::list("at", vec![Node::number(at.0), Node::number(at.1), Node::number(0.0)]),+            Node::list("effects", effects),+        ],+    )+}++fn shift_at(at: &mut Node, dx: f64, dy: f64) {+    let x = at.num(0).unwrap_or(0.0) + dx;+    let y = at.num(1).unwrap_or(0.0) + dy;+    at.set_arg(0, Node::number(x));+    at.set_arg(1, Node::number(y));+}++fn norm_rotation(r: f64) -> Result<f64, String> {+    let r = ((r % 360.0) + 360.0) % 360.0;+    if [0.0, 90.0, 180.0, 270.0].contains(&r) {+        Ok(r)+    } else {+        Err(format!("rotation must be 0, 90, 180 or 270 (got {r})"))+    }+}++/// A top-level `(symbol (lib_id ...) ...)`, as opposed to a `lib_symbols` entry.+pub fn is_symbol_instance(n: &Node) -> bool {+    n.head() == Some("symbol") && n.child("lib_id").is_some()+}++pub fn symbol_property(n: &Node, name: &str) -> Option<String> {+    n.children().iter().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some(name))?.arg(1)+}++fn symbol_info(n: &Node) -> SymbolInfo {+    let at = n.child("at");+    SymbolInfo {+        uuid: n.child_value("uuid").unwrap_or_default(),+        reference: symbol_property(n, "Reference").unwrap_or_default(),+        lib_id: n.child_value("lib_id").unwrap_or_default(),+        value: symbol_property(n, "Value").unwrap_or_default(),+        at: [at.and_then(|a| a.num(0)).unwrap_or(0.0), at.and_then(|a| a.num(1)).unwrap_or(0.0)],+        rotation: at.and_then(|a| a.num(2)).unwrap_or(0.0),+        unit: n.child_value("unit").and_then(|u| u.parse().ok()).unwrap_or(1),+    }+}++fn lib_property_value(lib: &Node, name: &str) -> Option<String> {+    symbol_property(lib, name)+}++/// Pin numbers a placed unit carries: the pins of sub-symbols `Name_0_*` (shared) and+/// `Name_<unit>_*`, in file order, de-duplicated.+pub fn lib_symbol_pins(lib: &Node, base_name: &str, unit: u32) -> Vec<String> {+    let mut out: Vec<String> = Vec::new();+    for sub in lib.children().iter().filter(|n| n.head() == Some("symbol")) {+        let Some(name) = sub.arg(0) else { continue };+        let Some(u) = sub_unit(&name, base_name) else { continue };+        if u != 0 && u != unit {+            continue;+        }+        for pin in sub.children().iter().filter(|n| n.head() == Some("pin")) {+            if let Some(num) = pin.child_value("number") {+                if !out.contains(&num) {+                    out.push(num);+                }+            }+        }+    }+    out+}++/// `R_1_1` with base `R` -> unit 1.+fn sub_unit(name: &str, base: &str) -> Option<u32> {+    let rest = name.strip_prefix(base)?.strip_prefix('_')?;+    let (u, _style) = rest.split_once('_')?;+    u.parse().ok()+}++fn project_name_for(path: &Path) -> Option<String> {+    let dir = path.parent()?;+    if let Ok(rd) = std::fs::read_dir(dir) {+        let mut pros: Vec<String> = rd+            .flatten()+            .filter_map(|e| {+                let p = e.path();+                if p.extension().and_then(|x| x.to_str()) == Some("kicad_pro") {+                    p.file_stem().map(|s| s.to_string_lossy().to_string())+                } else {+                    None+                }+            })+            .collect();+        pros.sort();+        let stem = path.file_stem().map(|s| s.to_string_lossy().to_string());+        if let Some(s) = &stem {+            if pros.iter().any(|p| p == s) {+                return stem;+            }+        }+        if pros.len() == 1 {+            return pros.pop();+        }+    }+    path.file_stem().map(|s| s.to_string_lossy().to_string())+}++// ---------------------------------------------------------------------------+// Library symbols+// ---------------------------------------------------------------------------++/// The `(symbol "Name" ...)` block for `name` from a library's text, with `extends`+/// resolved the way KiCad's Flatten does: the parent's drawings and pins, the child's+/// properties on top, units renamed to the child's name.+pub fn flatten_lib_symbol(lib_text: &str, name: &str) -> Result<Node, String> {+    let symbols = libraries::extract_top_level_symbols(lib_text)?;+    flatten_in(&symbols, name, 0)+}++fn flatten_in(symbols: &[(String, String)], name: &str, depth: u32) -> Result<Node, String> {+    if depth > 8 {+        return Err(format!("symbol {name}: extends chain deeper than 8"));+    }+    let (_, blk) = symbols.iter().find(|(n, _)| n == name).ok_or_else(|| format!("symbol {name} not found in the library ({} symbols)", symbols.len()))?;+    let mut node = parse_node(blk)?;+    node.lead.clear();+    let Some(parent_name) = node.child_value("extends") else { return Ok(node) };+    let mut parent = flatten_in(symbols, &parent_name, depth + 1)?;+    parent.set_arg(0, Node::quoted(name));+    let parent_children = parent.children_mut().expect("list");+    parent_children.retain(|n| n.head() != Some("extends"));+    for sub in parent_children.iter_mut().filter(|n| n.head() == Some("symbol")) {+        if let Some(sn) = sub.arg(0) {+            if let Some(rest) = sn.strip_prefix(&parent_name).and_then(|r| r.strip_prefix('_')) {+                sub.set_arg(0, Node::quoted(&format!("{name}_{rest}")));+            }+        }+    }+    for child in node.children().iter().filter(|n| n.head() == Some("property")) {+        let pname = child.arg(0).unwrap_or_default();+        let mut replacement = child.clone();+        replacement.lead.clear();+        if let Some(slot) = parent_children.iter_mut().find(|n| n.head() == Some("property") && n.arg(0).as_deref() == Some(pname.as_str())) {+            *slot = replacement;+        } else {+            let at = parent_children.iter().rposition(|n| n.head() == Some("property")).map(|i| i + 1).unwrap_or(parent_children.len());+            parent_children.insert(at, replacement);+        }+    }+    // Child-level flags (power, pin_names offset, ...) override the parent's.+    for child in node.children().iter().filter(|n| n.is_list() && !matches!(n.head(), Some("property") | Some("symbol") | Some("extends"))) {+        let h = child.head().unwrap_or("");+        let mut replacement = child.clone();+        replacement.lead.clear();+        if let Some(slot) = parent_children.iter_mut().find(|n| n.head() == Some(h)) {+            *slot = replacement;+        }+    }+    Ok(parent)+}++#[derive(Clone, Debug)]+pub struct ResolvedSymbol {+    pub library_path: String,+    pub name: String,+    pub block: Node,+    pub tables_checked: Vec<String>,+}++/// Find `Nick:Name` through the project's `sym-lib-table` (next to the schematic,+/// `${KIPRJMOD}` honoured) and then the global one from `ctx`, and return the+/// flattened block.+pub fn resolve_lib_symbol(ctx: &LibCtx, lib_id: &str, project_dir: Option<&Path>) -> Result<ResolvedSymbol, String> {+    let (nick, name) = lib_id.split_once(':').ok_or_else(|| format!("libId must be Library:Symbol (got {lib_id})"))?;+    let mut tables: Vec<PathBuf> = Vec::new();+    if let Some(d) = project_dir {+        tables.push(d.join("sym-lib-table"));+    }+    if let Some(g) = ctx.sym_lib_table() {+        tables.push(g);+    }+    let mut checked = Vec::new();+    for t in &tables {+        if !t.is_file() {+            continue;+        }+        checked.push(libraries::norm_path(t));+        let table = LibTable::parse_file(t)?;+        let Some(entry) = table.get(nick) else { continue };+        let uri = match project_dir {+            Some(d) => entry.uri.replace("${KIPRJMOD}", &libraries::norm_path(d)),+            None => entry.uri.clone(),+        };+        let path = expand_kicad_uri(&uri, ctx).ok_or_else(|| format!("library {nick} uri {} uses a path variable this bridge cannot expand (is KiCad installed?)", entry.uri))?;+        let mut r = load_lib_symbol(Path::new(&path), name)?;+        r.tables_checked = checked;+        return Ok(r);+    }+    Err(format!("library {nick} is not in any sym-lib-table (checked: {})", if checked.is_empty() { "none found".to_string() } else { checked.join(", ") }))+}++/// Read `name` straight from a `.kicad_sym` file.+pub fn load_lib_symbol(lib_path: &Path, name: &str) -> Result<ResolvedSymbol, String> {+    let text = std::fs::read_to_string(lib_path).map_err(|e| format!("cannot read library {}: {e}", lib_path.display()))?;+    let block = flatten_lib_symbol(&text, name).map_err(|e| format!("{}: {e}", lib_path.display()))?;+    Ok(ResolvedSymbol { library_path: libraries::norm_path(lib_path), name: name.to_string(), block, tables_checked: vec![] })+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn quote_roundtrip() {+        for s in ["", "R1", "a \"b\" c", "back\\slash", "tab\there", "line\nbreak"] {+            assert_eq!(unquote(&quote(s)), s);+        }+    }++    #[test]+    fn numbers_look_like_kicad() {+        assert_eq!(fmt_num(127.0), "127");+        assert_eq!(fmt_num(80.0099), "80.0099");+        assert_eq!(fmt_num(1.27), "1.27");+        assert_eq!(fmt_num(-0.00001), "0");+        assert_eq!(fmt_num(2.54 * 3.0), "7.62");+    }++    #[test]+    fn pretty_matches_eeschema_layout() {+        let mut n = Node::list("wire", vec![Node::list("pts", vec![xy((1.0, 2.0)), xy((3.0, 4.0))]), Node::list("uuid", vec![Node::quoted("u")])]);+        pretty(&mut n, 1);+        assert_eq!(n.to_text(), "\n\t(wire\n\t\t(pts\n\t\t\t(xy 1 2) (xy 3 4)\n\t\t)\n\t\t(uuid \"u\")\n\t)");+    }++    #[test]+    fn uuids_are_v4_and_unique() {+        let a = new_uuid();+        let b = new_uuid();+        assert_ne!(a, b);+        assert_eq!(a.len(), 36);+        assert_eq!(&a[14..15], "4");+        assert!("89ab".contains(&a[19..20]));+    }+}
rust/crates/kicad-core/tests/schematic_edit.rsadded+435
@@ -0,0 +1,435 @@+//! Schematic edit in place: lossless round trip on the two shipped tour schematics,+//! the structural effect of every operation, library resolution through a+//! sym-lib-table, and (when `service-kicad` is on PATH) a real KiCad 10 ERC on an+//! edited sheet so the edits are proven loadable by KiCad itself.++use std::path::{Path, PathBuf};+use std::process::Command;++use kicad_core::libraries::LibCtx;+use kicad_core::schematic::*;++fn repo_file(rel: &str) -> PathBuf {+    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel)+}++fn tour_demo() -> String {+    std::fs::read_to_string(repo_file("tour-pack/template/tour-demo.kicad_sch")).expect("tour-demo.kicad_sch readable")+}++fn rp2040() -> String {+    std::fs::read_to_string(repo_file("tour-pack-rp2040/template/rp2040-breakout.kicad_sch")).expect("rp2040-breakout.kicad_sch readable")+}++fn temp_dir(tag: &str) -> PathBuf {+    let d = std::env::temp_dir().join(format!("kicad-core-sch-{tag}-{}-{}", std::process::id(), new_uuid()));+    std::fs::create_dir_all(&d).unwrap();+    d+}++/// A two-symbol library: R plus a derived R_Derived (extends R) with its own Value.+const MINI_LIB: &str = r#"(kicad_symbol_lib+	(version 20241209)+	(generator "kicad_symbol_editor")+	(generator_version "9.0")+	(symbol "R"+		(pin_numbers+			(hide yes)+		)+		(pin_names+			(offset 0)+		)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(property "Reference" "R"+			(at 2.032 0 90)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Value" "R"+			(at 0 0 90)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Footprint" ""+			(at -1.778 0 90)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" "~"+			(at 0 0 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Resistor"+			(at 0 0 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "ki_keywords" "R res resistor"+			(at 0 0 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(symbol "R_0_1"+			(rectangle+				(start -1.016 -2.54)+				(end 1.016 2.54)+				(stroke+					(width 0.254)+					(type default)+				)+				(fill+					(type none)+				)+			)+		)+		(symbol "R_1_1"+			(pin passive line+				(at 0 3.81 270)+				(length 1.27)+				(name "~"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "1"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+			(pin passive line+				(at 0 -3.81 90)+				(length 1.27)+				(name "~"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "2"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+		)+		(embedded_fonts no)+	)+	(symbol "R_Derived"+		(extends "R")+		(property "Reference" "R"+			(at 2.032 0 90)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Value" "R_Derived"+			(at 0 0 90)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Footprint" "Resistor_SMD:R_0603_1608Metric"+			(at -1.778 0 90)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+	)+)+"#;++#[test]+fn round_trip_is_byte_identical() {+    for (name, text) in [("tour-demo", tour_demo()), ("rp2040-breakout", rp2040())] {+        let sch = Schematic::parse(&text).unwrap_or_else(|e| panic!("{name}: {e}"));+        assert_eq!(sch.to_text(), text, "{name}: parse + write must be byte identical");+    }+}++#[test]+fn reads_what_the_sheet_holds() {+    let sch = Schematic::parse(&tour_demo()).unwrap();+    assert_eq!(sch.root_uuid().as_deref(), Some("7b5608be-31c7-4010-bca1-370a9e35dce5"));+    assert_eq!(sch.version().as_deref(), Some("20231120"));+    assert!(sch.has_lib_symbol("AdomTour:R_AdomTour"));+    let syms = sch.symbols();+    assert_eq!(syms.len(), 1);+    assert_eq!(syms[0].reference, "R1");+    assert_eq!(syms[0].at, [127.0, 81.28]);+    assert_eq!(sch.project_name(), "tour-demo");+    assert_eq!(sch.instance_path().unwrap(), "/7b5608be-31c7-4010-bca1-370a9e35dce5");+    assert_eq!(Schematic::parse(&rp2040()).unwrap().symbols().len(), 20);+}++#[test]+fn place_symbol_from_existing_lib_symbols_entry() {+    let before = tour_demo();+    let mut sch = Schematic::parse(&before).unwrap();+    let placed = sch+        .place_symbol(+            &PlaceSymbol { lib_id: "AdomTour:R_AdomTour", reference: "R5", at: (140.0, 81.28), rotation: 90.0, unit: 1, value: Some("4k7"), footprint: None },+            None,+        )+        .unwrap();+    assert!(!placed.lib_symbol_added);+    assert_eq!(placed.pins, vec!["1", "2"]);+    let text = sch.to_text();+    // Every original byte survives: the new block is inserted before sheet_instances.+    let cut = before.find("\t(sheet_instances").unwrap();+    assert!(text.starts_with(&before[..cut]));+    assert!(text.ends_with(&before[cut..]));+    let block = &text[cut..text.find("\t(sheet_instances").unwrap()];+    assert!(block.starts_with("\t(symbol\n\t\t(lib_id \"AdomTour:R_AdomTour\")\n\t\t(at 140 81.28 90)\n\t\t(unit 1)\n"), "{block}");+    assert!(block.contains(&format!("(uuid \"{}\")", placed.uuid)));+    assert!(block.contains("(property \"Reference\" \"R5\"\n\t\t\t(at 142.54 80.01 0)"));+    assert!(block.contains("(property \"Value\" \"4k7\""));+    assert!(block.contains("(property \"Footprint\" \"AdomTour:R_0805_AdomTour\""));+    assert!(block.contains("\t\t(pin \"1\"\n\t\t\t(uuid \""));+    assert!(block.contains("(instances\n\t\t\t(project \"tour-demo\"\n\t\t\t\t(path \"/7b5608be-31c7-4010-bca1-370a9e35dce5\"\n\t\t\t\t\t(reference \"R5\")\n\t\t\t\t\t(unit 1)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n\t)\n"));+    let again = Schematic::parse(&text).unwrap();+    assert_eq!(again.symbols().len(), 2);+    assert_eq!(again.to_text(), text);+    assert!(sch.place_symbol(&PlaceSymbol { lib_id: "AdomTour:R_AdomTour", reference: "R5", at: (0.0, 0.0), rotation: 0.0, unit: 1, value: None, footprint: None }, None).is_err());+}++#[test]+fn place_symbol_copies_lib_symbol_and_flattens_extends() {+    let mut sch = Schematic::parse(&tour_demo()).unwrap();+    let block = flatten_lib_symbol(MINI_LIB, "R_Derived").unwrap();+    assert!(block.child("extends").is_none());+    assert_eq!(symbol_property(&block, "Value").as_deref(), Some("R_Derived"));+    assert_eq!(symbol_property(&block, "Description").as_deref(), Some("Resistor"));+    let units: Vec<String> = block.children().iter().filter(|n| n.head() == Some("symbol")).map(|n| n.arg(0).unwrap()).collect();+    assert_eq!(units, vec!["R_Derived_0_1", "R_Derived_1_1"]);++    let placed = sch+        .place_symbol(&PlaceSymbol { lib_id: "Mini:R_Derived", reference: "R2", at: (100.0, 50.0), rotation: 0.0, unit: 1, value: None, footprint: None }, Some(&block))+        .unwrap();+    assert!(placed.lib_symbol_added);+    assert_eq!(placed.pins, vec!["1", "2"]);+    let text = sch.to_text();+    assert!(text.contains("\n\t\t(symbol \"Mini:R_Derived\"\n\t\t\t(pin_numbers"), "lib_symbols entry is renamed to the lib id and indented two tabs");+    assert!(text.contains("\t\t\t(symbol \"R_Derived_1_1\""));+    assert!(text.contains("(property \"Value\" \"R_Derived\"\n\t\t\t(at 102.54 51.27 0)"));+    assert!(text.contains("(property \"Footprint\" \"Resistor_SMD:R_0603_1608Metric\""));+    assert!(!text.contains("\"ki_keywords\"\n\t\t\t(at 100"), "ki_ fields are not copied onto the instance");+    let again = Schematic::parse(&text).unwrap();+    assert!(again.has_lib_symbol("Mini:R_Derived"));+    assert!(again.has_lib_symbol("AdomTour:R_AdomTour"));+    // Placing a second one reuses the entry.+    let mut again = again;+    let p2 = again.place_symbol(&PlaceSymbol { lib_id: "Mini:R_Derived", reference: "R3", at: (110.0, 50.0), rotation: 0.0, unit: 1, value: None, footprint: None }, None).unwrap();+    assert!(!p2.lib_symbol_added);+}++#[test]+fn wire_label_move_delete_set_property() {+    let mut sch = Schematic::parse(&tour_demo()).unwrap();+    let wires = sch.add_wire(&[(127.0, 77.47), (127.0, 70.0), (140.0, 70.0)]).unwrap();+    assert_eq!(wires.len(), 2);+    let text = sch.to_text();+    assert!(text.contains("\n\t(wire\n\t\t(pts\n\t\t\t(xy 127 77.47) (xy 127 70)\n\t\t)\n\t\t(stroke\n\t\t\t(width 0)\n\t\t\t(type default)\n\t\t)\n\t\t(uuid \""), "{text}");+    assert!(sch.add_wire(&[(1.0, 1.0)]).is_err());++    let l = sch.add_label("VIN", LabelKind::Local, (140.0, 70.0), 0.0, None).unwrap();+    let g = sch.add_label("USB_5V", LabelKind::Global, (127.0, 85.09), 180.0, None).unwrap();+    let h = sch.add_label("SDA", LabelKind::Hierarchical, (150.0, 70.0), 0.0, Some("output")).unwrap();+    let text = sch.to_text();+    assert!(text.contains(&format!("\n\t(label \"VIN\"\n\t\t(at 140 70 0)\n\t\t(effects\n\t\t\t(font\n\t\t\t\t(size 1.27 1.27)\n\t\t\t)\n\t\t\t(justify left bottom)\n\t\t)\n\t\t(uuid \"{l}\")\n\t)")), "{text}");+    assert!(text.contains("\n\t(global_label \"USB_5V\"\n\t\t(shape bidirectional)\n\t\t(at 127 85.09 180)\n\t\t(fields_autoplaced yes)"));+    assert!(text.contains(&format!("(uuid \"{g}\")\n\t\t(property \"Intersheetrefs\" \"${{INTERSHEET_REFS}}\"")));+    assert!(text.contains(&format!("\n\t(hierarchical_label \"SDA\"\n\t\t(shape output)\n\t\t(at 150 70 0)\n\t\t(fields_autoplaced yes)\n\t\t(effects\n\t\t\t(font\n\t\t\t\t(size 1.27 1.27)\n\t\t\t)\n\t\t\t(justify left)\n\t\t)\n\t\t(uuid \"{h}\")\n\t)")));+    assert!(sch.add_label("X", LabelKind::Global, (0.0, 0.0), 0.0, Some("weird")).is_err());+    assert!(sch.add_label("X", LabelKind::Local, (0.0, 0.0), 45.0, None).is_err());++    // Move R1 from (127, 81.28) to (150, 90): the anchor and every field shift together.+    let (uuid, from) = sch.move_symbol("R1", (150.0, 90.0)).unwrap();+    assert_eq!(uuid, "f8e0a1ff-cdf3-4b10-8a45-de5c5807717e");+    assert_eq!(from, (127.0, 81.28));+    let text = sch.to_text();+    assert!(text.contains("(lib_id \"AdomTour:R_AdomTour\")\n\t\t(at 150 90 0)"));+    assert!(text.contains("(property \"Reference\" \"R1\"\n\t\t\t(at 152.54 88.7299 0)"), "{text}");+    assert!(text.contains("(property \"Footprint\" \"AdomTour:R_0805_AdomTour\"\n\t\t\t(at 148.222 90 90)"));+    assert!(sch.move_symbol("R99", (0.0, 0.0)).is_err());++    // Set an existing field, add a new one, rename the reference (instances follow).+    let (u, old) = sch.set_property("R1", "Value", "22k").unwrap();+    assert_eq!(u, uuid);+    assert_eq!(old.as_deref(), Some("10k"));+    let (_, old) = sch.set_property("R1", "MPN", "RC0805FR-0722KL").unwrap();+    assert!(old.is_none());+    sch.set_property("R1", "Reference", "R7").unwrap();+    let text = sch.to_text();+    assert!(text.contains("(property \"Value\" \"22k\""));+    assert!(text.contains("\n\t\t(property \"MPN\" \"RC0805FR-0722KL\"\n\t\t\t(at 150 90 0)\n\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.27 1.27)\n\t\t\t\t)\n\t\t\t\t(hide yes)\n\t\t\t)\n\t\t)\n\t\t(pin \"1\""), "{text}");+    assert!(text.contains("(property \"Reference\" \"R7\""));+    assert!(text.contains("(reference \"R7\")"));+    assert!(!text.contains("\"R1\""));+    assert_eq!(sch.symbols()[0].value, "22k");+    assert!(sch.set_property("R7", "Reference", "").is_err());++    // Delete by uuid: a wire, a label and the symbol.+    assert_eq!(sch.delete(&wires[0]).unwrap(), "wire");+    assert_eq!(sch.delete(&g).unwrap(), "global_label");+    assert_eq!(sch.delete(&uuid).unwrap(), "symbol");+    assert!(sch.delete(&uuid).is_err());+    let text = sch.to_text();+    assert!(!text.contains(&wires[0]) && !text.contains(&g) && !text.contains(&uuid));+    assert!(text.contains(&wires[1]) && text.contains(&l) && text.contains(&h));+    assert!(sch.symbols().is_empty());+    assert!(text.ends_with("\t(sheet_instances\n\t\t(path \"/\"\n\t\t\t(page \"1\")\n\t\t)\n\t)\n\t(embedded_fonts no)\n)\n"));+    Schematic::parse(&text).unwrap();+}++#[test]+fn next_reference_skips_used_numbers() {+    let mut sch = Schematic::parse(&tour_demo()).unwrap();+    assert_eq!(sch.next_reference("R"), "R2");+    assert_eq!(sch.next_reference("#PWR"), "#PWR01");+    sch.place_symbol(&PlaceSymbol { lib_id: "AdomTour:R_AdomTour", reference: "R2", at: (0.0, 0.0), rotation: 0.0, unit: 1, value: None, footprint: None }, None).unwrap();+    assert_eq!(sch.next_reference("R"), "R3");+}++#[test]+fn resolves_lib_id_through_sym_lib_tables() {+    let dir = temp_dir("libs");+    let lib = dir.join("Mini.kicad_sym");+    std::fs::write(&lib, MINI_LIB).unwrap();+    // Global table (config dir) names it Mini; a project table next to the sheet names+    // the same file Local via ${KIPRJMOD}.+    let cfg = dir.join("cfg");+    std::fs::create_dir_all(&cfg).unwrap();+    std::fs::write(cfg.join("sym-lib-table"), format!("(sym_lib_table\n  (version 7)\n  (lib (name \"Mini\")(type \"KiCad\")(uri \"{}\")(options \"\")(descr \"\"))\n)\n", lib.to_string_lossy().replace('\\', "/"))).unwrap();+    let proj = dir.join("proj");+    std::fs::create_dir_all(&proj).unwrap();+    std::fs::write(proj.join("sym-lib-table"), "(sym_lib_table\n  (version 7)\n  (lib (name \"Local\")(type \"KiCad\")(uri \"${KIPRJMOD}/../Mini.kicad_sym\")(options \"\")(descr \"\"))\n)\n").unwrap();+    let ctx = LibCtx { version: "10.0".into(), config_dir: Some(cfg), ..Default::default() };++    let r = resolve_lib_symbol(&ctx, "Mini:R", Some(&proj)).unwrap();+    assert_eq!(r.name, "R");+    assert_eq!(r.tables_checked.len(), 2);+    let r = resolve_lib_symbol(&ctx, "Local:R_Derived", Some(&proj)).unwrap();+    assert!(r.block.child("extends").is_none());+    assert!(r.library_path.ends_with("Mini.kicad_sym"));+    let e = resolve_lib_symbol(&ctx, "Nope:R", Some(&proj)).unwrap_err();+    assert!(e.contains("not in any sym-lib-table"), "{e}");+    let e = resolve_lib_symbol(&ctx, "Mini:Missing", None).unwrap_err();+    assert!(e.contains("not found"), "{e}");+    assert!(resolve_lib_symbol(&ctx, "no-colon", None).is_err());+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn save_with_backup_writes_bak_next_to_file() {+    let dir = temp_dir("save");+    let path = dir.join("tour-demo.kicad_sch");+    std::fs::write(&path, tour_demo()).unwrap();+    std::fs::write(dir.join("tour-demo.kicad_pro"), "{}").unwrap();+    let mut sch = Schematic::load(&path).unwrap();+    assert_eq!(sch.project_name(), "tour-demo");+    sch.add_wire(&[(0.0, 0.0), (10.0, 0.0)]).unwrap();+    let bak = sch.save_with_backup(&path).unwrap();+    assert_eq!(bak, dir.join("tour-demo.kicad_sch.bak"));+    assert_eq!(std::fs::read_to_string(&bak).unwrap(), tour_demo());+    assert_eq!(std::fs::read_to_string(&path).unwrap(), sch.to_text());+    let _ = std::fs::remove_dir_all(&dir);+}++fn service_kicad() -> Option<PathBuf> {+    let path = std::env::var_os("PATH")?;+    std::env::split_paths(&path).map(|d| d.join("service-kicad")).find(|p| p.is_file())+}++/// Real KiCad 10 verdict on an edited sheet: place_symbol (from a stock library fetched+/// through the service), wire, label, then `service-kicad sch erc`. The report must+/// parse and mention the placed reference; KiCad refusing the file would fail here.+#[test]+fn service_kicad_erc_accepts_the_edited_sheet() {+    let Some(svc) = service_kicad() else {+        eprintln!("service-kicad not on PATH; skipping the live ERC test");+        return;+    };+    let dir = temp_dir("erc");+    let sch_path = dir.join("tour-demo.kicad_sch");+    std::fs::write(&sch_path, tour_demo()).unwrap();+    std::fs::copy(repo_file("tour-pack/template/tour-demo.kicad_pro"), dir.join("tour-demo.kicad_pro")).unwrap();++    let device = dir.join("Device.kicad_sym");+    let fetched = Command::new(&svc).args(["sym", "fetch", "Device", "--out"]).arg(&device).output().expect("service-kicad runs");+    assert!(fetched.status.success() && device.is_file(), "sym fetch Device: {}", String::from_utf8_lossy(&fetched.stderr));++    let mut sch = Schematic::load(&sch_path).unwrap();+    let r = load_lib_symbol(&device, "R").unwrap();+    let placed = sch+        .place_symbol(&PlaceSymbol { lib_id: "Device:R", reference: "R5", at: (140.97, 81.28), rotation: 0.0, unit: 1, value: Some("4k7"), footprint: Some("Resistor_SMD:R_0603_1608Metric") }, Some(&r.block))+        .unwrap();+    assert!(placed.lib_symbol_added);+    // Wire the two resistors in series and label the free ends so ERC sees nets.+    sch.add_wire(&[(127.0, 77.47), (127.0, 72.39), (140.97, 72.39), (140.97, 77.47)]).unwrap();+    sch.add_wire(&[(127.0, 85.09), (127.0, 90.17)]).unwrap();+    sch.add_wire(&[(140.97, 85.09), (140.97, 90.17)]).unwrap();+    sch.add_label("VIN", LabelKind::Local, (127.0, 90.17), 0.0, None).unwrap();+    sch.add_label("VOUT", LabelKind::Global, (140.97, 90.17), 0.0, None).unwrap();+    sch.set_property("R1", "Value", "22k").unwrap();+    sch.save_with_backup(&sch_path).unwrap();++    let report = dir.join("erc.json");+    let out = Command::new(&svc).args(["sch", "erc"]).arg(&sch_path).args(["--format", "json", "--out"]).arg(&report).output().expect("service-kicad runs");+    let stderr = String::from_utf8_lossy(&out.stderr);+    assert!(report.is_file(), "service-kicad sch erc wrote no report: {stderr}\n{}", String::from_utf8_lossy(&out.stdout));+    let rep: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).expect("ERC report is JSON");+    assert!(rep.get("kicad_version").is_some(), "not an ERC report: {rep}");+    let flat = kicad_core::cli::flatten_violations(&rep);+    let text = serde_json::to_string(&rep).unwrap();+    assert!(text.contains("R5"), "KiCad loaded the placed symbol (R5 appears in the report): {text}");+    let summary = kicad_core::cli::summarize_report(&rep);+    eprintln!("service-kicad ERC on the edited sheet: {} error(s), {} warning(s), {} finding(s); kicad {}", summary["errors"], summary["warnings"], flat.len(), rep["kicad_version"]);+    for v in &flat {+        eprintln!("  {} {}: {}", v["severity"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["description"].as_str().unwrap_or(""));+    }+    // The series wires connect R1 pin 1 to R5 pin 1 and each free pin to a label: no+    // pin_not_connected may remain.+    let unconnected: Vec<&serde_json::Value> = flat.iter().filter(|v| v["type"] == "pin_not_connected").collect();+    assert!(unconnected.is_empty(), "wires and labels did not connect the pins: {unconnected:?}");+    let _ = std::fs::remove_dir_all(&dir);+}