← Commit history

rust: phase 1 skeleton (server, catalog, detect, kicad-cli runner, 8 headless verbs); smoke-tested on ConfRoomROG

John Lauer ·b4a71b034a ·29d ago ·parent 707bd1a
8 files changed +1070
rust/.gitignoreadded+1
@@ -0,0 +1 @@+target/
rust/Cargo.lockadded+234
@@ -0,0 +1,234 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "chunked_transfer"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"++[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "kicad-bridge"+version = "1.0.0-alpha.1"+dependencies = [+ "serde",+ "serde_json",+ "tiny_http",+ "winreg",+]++[[package]]+name = "log"+version = "0.4.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"++[[package]]+name = "memchr"+version = "2.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"++[[package]]+name = "proc-macro2"+version = "1.0.107"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "serde"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.151"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "syn"+version = "3.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "tiny_http"+version = "0.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"+dependencies = [+ "ascii",+ "chunked_transfer",+ "httpdate",+ "log",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "windows-sys"+version = "0.59.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"+dependencies = [+ "windows-targets",+]++[[package]]+name = "windows-targets"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"+dependencies = [+ "windows_aarch64_gnullvm",+ "windows_aarch64_msvc",+ "windows_i686_gnu",+ "windows_i686_gnullvm",+ "windows_i686_msvc",+ "windows_x86_64_gnu",+ "windows_x86_64_gnullvm",+ "windows_x86_64_msvc",+]++[[package]]+name = "windows_aarch64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"++[[package]]+name = "windows_aarch64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"++[[package]]+name = "windows_i686_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"++[[package]]+name = "windows_i686_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"++[[package]]+name = "windows_i686_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"++[[package]]+name = "windows_x86_64_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"++[[package]]+name = "windows_x86_64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"++[[package]]+name = "windows_x86_64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"++[[package]]+name = "winreg"+version = "0.55.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"+dependencies = [+ "cfg-if",+ "windows-sys",+]++[[package]]+name = "zmij"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
rust/Cargo.tomladded+26
@@ -0,0 +1,26 @@+[package]+name = "kicad-bridge"+version = "1.0.0-alpha.1"+edition = "2021"+publish = false+description = "Adom KiCad Bridge, native binary (Rust port of the Python bridge)"+license = "MIT"++[[bin]]+name = "kicad-bridge"+path = "src/main.rs"++[dependencies]+tiny_http = "0.12"+serde = { version = "1", features = ["derive"] }+serde_json = "1"++[target.'cfg(windows)'.dependencies]+winreg = "0.55"++[profile.release]+opt-level = "z"+lto = true+codegen-units = 1+strip = true+panic = "abort"
rust/src/catalog.rsadded+150
@@ -0,0 +1,150 @@+//! One table for every verb: name, mechanism, risk, timeout and the describe entry.+//! ab's Verbs tab renders `kicad_describe` verbatim, so the text here is what the AI reads.++use serde_json::{json, Value};++#[allow(dead_code)] // File, Ipc and Window arrive in phases 1b to 3+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Mechanism {+    /// Pure bridge logic, no KiCad involved.+    Local,+    /// kicad-cli subprocess.+    Cli,+    /// Reads or edits KiCad files on disk.+    File,+    /// KiCad IPC API (phase 2).+    Ipc,+    /// Window control (phase 3).+    Window,+}++impl Mechanism {+    pub fn as_str(self) -> &'static str {+        match self {+            Mechanism::Local => "local",+            Mechanism::Cli => "cli",+            Mechanism::File => "file",+            Mechanism::Ipc => "ipc",+            Mechanism::Window => "window",+        }+    }+}++pub struct Verb {+    pub name: &'static str,+    pub summary: &'static str,+    pub mechanism: Mechanism,+    /// ab risk class: read | write | exec | process | config | input+    pub risk: &'static str,+    pub timeout_sec: u32,+    pub input: &'static str,+    pub example: &'static str,+    pub hint: &'static str,+    pub related: &'static [&'static str],+    pub pitfalls: &'static [&'static str],+}++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_describe",+        summary: "List every verb with input, example, hint, related verbs and pitfalls.",+        mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+        input: "{}", example: "kicad_describe {}",+        hint: "Read this once per session; every response also carries _hint and _next.",+        related: &["kicad_readiness", "kicad_status"], pitfalls: &[],+    },+    Verb {+        name: "kicad_status",+        summary: "Bridge health, KiCad version, install location and uptime.",+        mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+        input: "{}", example: "kicad_status {}",+        hint: "Cheap; call it before any window verb to learn what KiCad is on this box.",+        related: &["kicad_readiness", "kicad_diagnostics"], pitfalls: &[],+    },+    Verb {+        name: "kicad_readiness",+        summary: "Is KiCad installed and usable here; if not, the three alternatives (service-kicad, kicad-cli in your container, another box).",+        mechanism: Mechanism::Local, risk: "read", timeout_sec: 15,+        input: "{}", example: "kicad_readiness {}",+        hint: "When installed is false read `alternatives`: the headless verbs still run against service-kicad or a named target.",+        related: &["kicad_status", "kicad_list_versions", "kicad_upgrade"],+        pitfalls: &["The bridge never installs KiCad from readiness; kicad_upgrade does that on request."],+    },+    Verb {+        name: "kicad_list_versions",+        summary: "Every KiCad install found (registry and standard folders) with bin dir, kicad-cli path and version.",+        mechanism: Mechanism::Local, risk: "read", timeout_sec: 15,+        input: "{}", example: "kicad_list_versions {}",+        hint: "The first entry is the one the bridge drives.",+        related: &["kicad_readiness"], pitfalls: &[],+    },+    Verb {+        name: "kicad_diagnostics",+        summary: "Environment the bridge runs in: user, paths, env vars that matter, KiCad config dir.",+        mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+        input: "{}", example: "kicad_diagnostics {}",+        hint: "Attach this to any bug report.",+        related: &["kicad_status"], pitfalls: &[],+    },+    Verb {+        name: "kicad_run_drc",+        summary: "Run kicad-cli pcb drc on a board and return the parsed JSON report (violations, unconnected, counts by severity).",+        mechanism: Mechanism::Cli, risk: "read", timeout_sec: 300,+        input: "{\"boardPath\": \"C:/.../x.kicad_pcb\", \"refillZones\"?: bool, \"backend\"?: \"local\"}",+        example: "kicad_run_drc {\"boardPath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "Headless: works with no KiCad window open. refillZones true matches what the editor does before its own DRC.",+        related: &["kicad_run_erc", "kicad_lint_board"],+        pitfalls: &["A board saved by a newer KiCad than kicad-cli refuses to load; the error names both versions."],+    },+    Verb {+        name: "kicad_run_erc",+        summary: "Run kicad-cli sch erc on a schematic and return the parsed JSON report.",+        mechanism: Mechanism::Cli, risk: "read", timeout_sec: 300,+        input: "{\"schematicPath\": \"C:/.../x.kicad_sch\", \"backend\"?: \"local\"}",+        example: "kicad_run_erc {\"schematicPath\":\"C:/Users/john/proj/board.kicad_sch\"}",+        hint: "Headless. Pass the root sheet; hierarchical sheets are followed by kicad-cli.",+        related: &["kicad_run_drc", "kicad_lint_schematic"], pitfalls: &[],+    },+    Verb {+        name: "kicad_uninstall",+        summary: "Remove only this bridge's own artifacts (scratch dir, backups it made). Never KiCad, never user data.",+        mechanism: Mechanism::Local, risk: "write", timeout_sec: 120,+        input: "{\"cascade\"?: bool}", example: "kicad_uninstall {}",+        hint: "ab calls this during bridge_uninstall; it is idempotent and non-interactive.",+        related: &[], pitfalls: &[],+    },+];++pub fn find(name: &str) -> Option<&'static Verb> {+    VERBS.iter().find(|v| v.name == name)+}++pub fn describe(version: &str) -> Value {+    let verbs: Vec<Value> = VERBS+        .iter()+        .map(|v| {+            json!({+                "name": v.name,+                "summary": v.summary,+                "mechanism": v.mechanism.as_str(),+                "risk": v.risk,+                "timeoutSeconds": v.timeout_sec,+                "longRunning": v.timeout_sec > 60,+                "input": v.input,+                "example": v.example,+                "hint": v.hint,+                "related": v.related,+                "pitfalls": v.pitfalls,+            })+        })+        .collect();+    json!({+        "success": true,+        "bridge": "kicad",+        "version": version,+        "runtime": "native",+        "verbCount": verbs.len(),+        "verbs": verbs,+        "_hint": "Phase 1 of the Rust port: headless verbs only. Window, show, routing and demo verbs are still served by the Python bridge until each phase passes the verb runner.",+    })+}
rust/src/cli.rsadded+117
@@ -0,0 +1,117 @@+//! kicad-cli runner. Every call is quiet (no console window), has a deadline, and returns+//! stdout, stderr and the parsed JSON report when one was requested.++use std::path::Path;+use std::process::Command;+use std::time::{Duration, Instant};++use serde_json::{json, Value};++/// Never let a console child blip a window onto the user's screen.+pub fn quiet(cmd: &mut Command) {+    cmd.stdin(std::process::Stdio::null());+    #[cfg(windows)]+    {+        use std::os::windows::process::CommandExt;+        const CREATE_NO_WINDOW: u32 = 0x0800_0000;+        cmd.creation_flags(CREATE_NO_WINDOW);+    }+}++pub struct CliResult {+    pub exit_code: i32,+    pub stdout: String,+    pub stderr: String,+    pub elapsed_ms: u64,+    pub timed_out: bool,+}++pub fn run(cli: &Path, args: &[&str], timeout: Duration) -> CliResult {+    let mut cmd = Command::new(cli);+    cmd.args(args);+    quiet(&mut cmd);+    cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());+    let t0 = Instant::now();+    let mut child = match cmd.spawn() {+        Ok(c) => c,+        Err(e) => {+            return CliResult { exit_code: -1, stdout: String::new(), stderr: format!("spawn failed: {e}"), elapsed_ms: 0, timed_out: false }+        }+    };+    // Poll with a deadline; kill on timeout so a hung kicad-cli never hangs a verb.+    loop {+        match child.try_wait() {+            Ok(Some(_)) => break,+            Ok(None) => {+                if t0.elapsed() > timeout {+                    let _ = child.kill();+                    let _ = child.wait();+                    return CliResult { exit_code: -2, stdout: String::new(), stderr: "kicad-cli timed out".into(), elapsed_ms: t0.elapsed().as_millis() as u64, timed_out: true };+                }+                std::thread::sleep(Duration::from_millis(50));+            }+            Err(e) => {+                return CliResult { exit_code: -1, stdout: String::new(), stderr: format!("wait failed: {e}"), elapsed_ms: t0.elapsed().as_millis() as u64, timed_out: false }+            }+        }+    }+    let out = child.wait_with_output().unwrap();+    CliResult {+        exit_code: out.status.code().unwrap_or(-1),+        stdout: String::from_utf8_lossy(&out.stdout).to_string(),+        stderr: String::from_utf8_lossy(&out.stderr).to_string(),+        elapsed_ms: t0.elapsed().as_millis() as u64,+        timed_out: false,+    }+}++/// Summarise a kicad-cli drc/erc JSON report the way the Python bridge does:+/// counts by severity, unconnected items, and the first violations verbatim.+pub fn summarize_report(report: &Value) -> Value {+    let mut errors = 0u64;+    let mut warnings = 0u64;+    let mut exclusions = 0u64;+    let mut first: Vec<Value> = Vec::new();+    let mut walk = |arr: &Value| {+        if let Some(items) = arr.as_array() {+            for v in items {+                if v.get("excluded").and_then(Value::as_bool).unwrap_or(false) {+                    exclusions += 1;+                    continue;+                }+                match v.get("severity").and_then(Value::as_str) {+                    Some("error") => errors += 1,+                    Some("warning") => warnings += 1,+                    _ => {}+                }+                if first.len() < 25 {+                    first.push(json!({+                        "type": v.get("type"),+                        "severity": v.get("severity"),+                        "description": v.get("description"),+                        "items": v.get("items").and_then(Value::as_array).map(|a| a.iter().filter_map(|i| i.get("description").cloned()).collect::<Vec<_>>()),+                    }));+                }+            }+        }+    };+    walk(report.get("violations").unwrap_or(&Value::Null));+    let unconnected = report.get("unconnected_items").and_then(Value::as_array).map(|a| a.len()).unwrap_or(0);+    walk(report.get("unconnected_items").unwrap_or(&Value::Null));+    walk(report.get("schematic_parity").unwrap_or(&Value::Null));+    if let Some(sheets) = report.get("sheets").and_then(Value::as_array) {+        for s in sheets {+            walk(s.get("violations").unwrap_or(&Value::Null));+        }+    }+    json!({+        "errors": errors,+        "warnings": warnings,+        "excluded": exclusions,+        "unconnected": unconnected,+        "clean": errors == 0 && unconnected == 0,+        "first": first,+        "kicadVersion": report.get("kicad_version"),+        "source": report.get("source"),+    })+}
rust/src/detect.rsadded+179
@@ -0,0 +1,179 @@+//! Find KiCad on this machine. Windows: registry (HKLM/HKCU SOFTWARE\KiCad\<ver>\InstallationPath)+//! plus the standard folders. Other OSes: PATH and the app bundle. No subprocess except+//! `kicad-cli version` for the exact patch level.++use std::path::{Path, PathBuf};++use serde::Serialize;++#[derive(Clone, Debug, Default, Serialize)]+pub struct KicadInstall {+    pub version: String,+    #[serde(rename = "baseDir")]+    pub base_dir: String,+    #[serde(rename = "binDir")]+    pub bin_dir: String,+    #[serde(rename = "kicadCli")]+    pub kicad_cli: String,+    #[serde(rename = "cliVersion")]+    pub cli_version: String,+    pub source: String,+    #[serde(rename = "perUser")]+    pub per_user: bool,+}++#[derive(Clone, Debug, Default, Serialize)]+pub struct KicadInfo {+    pub installed: bool,+    pub version: String,+    pub installs: Vec<KicadInstall>,+}++impl KicadInfo {+    pub fn summary(&self) -> String {+        if !self.installed {+            return "KiCad: not found (registry and standard folders checked)".into();+        }+        let i = &self.installs[0];+        format!("KiCad {} at {} ({})", i.cli_version, i.base_dir, i.source)+    }+    pub fn primary(&self) -> Option<&KicadInstall> {+        self.installs.first()+    }+}++fn cli_name() -> &'static str {+    if cfg!(windows) { "kicad-cli.exe" } else { "kicad-cli" }+}++fn probe_cli_version(cli: &Path) -> String {+    let mut cmd = std::process::Command::new(cli);+    cmd.arg("version");+    crate::cli::quiet(&mut cmd);+    match cmd.output() {+        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),+        _ => String::new(),+    }+}++fn install_from_base(base: &Path, version_hint: &str, source: &str, per_user: bool) -> Option<KicadInstall> {+    let bin = base.join("bin");+    let cli = bin.join(cli_name());+    if !cli.is_file() {+        return None;+    }+    let cli_version = probe_cli_version(&cli);+    let version = if !version_hint.is_empty() {+        version_hint.to_string()+    } else {+        cli_version.split('.').take(2).collect::<Vec<_>>().join(".")+    };+    Some(KicadInstall {+        version,+        base_dir: base.to_string_lossy().replace('\\', "/"),+        bin_dir: bin.to_string_lossy().replace('\\', "/"),+        kicad_cli: cli.to_string_lossy().replace('\\', "/"),+        cli_version,+        source: source.into(),+        per_user,+    })+}++fn version_key(v: &str) -> (u32, u32, u32) {+    let mut it = v.split('.').map(|p| p.trim().parse::<u32>().unwrap_or(0));+    (it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))+}++#[cfg(windows)]+fn registry_installs() -> Vec<KicadInstall> {+    use winreg::enums::*;+    use winreg::RegKey;+    let mut out = Vec::new();+    for (hive, name, per_user) in [(HKEY_LOCAL_MACHINE, "HKLM", false), (HKEY_CURRENT_USER, "HKCU", true)] {+        let root = RegKey::predef(hive);+        let Ok(k) = root.open_subkey("SOFTWARE\\KiCad") else { continue };+        for ver in k.enum_keys().flatten() {+            let Ok(vk) = k.open_subkey(&ver) else { continue };+            let Ok(path): Result<String, _> = vk.get_value("InstallationPath") else { continue };+            if let Some(i) = install_from_base(Path::new(&path), &ver, &format!("registry {name}"), per_user) {+                out.push(i);+            }+        }+    }+    out+}++#[cfg(not(windows))]+fn registry_installs() -> Vec<KicadInstall> {+    Vec::new()+}++fn folder_installs() -> Vec<KicadInstall> {+    let mut out = Vec::new();+    let mut bases: Vec<(PathBuf, bool)> = Vec::new();+    if cfg!(windows) {+        for (var, per_user) in [("ProgramFiles", false), ("ProgramFiles(x86)", false)] {+            if let Ok(p) = std::env::var(var) {+                bases.push((PathBuf::from(p).join("KiCad"), per_user));+            }+        }+        let local = std::env::var("LOCALAPPDATA")+            .map(PathBuf::from)+            .or_else(|_| std::env::var("USERPROFILE").map(|u| PathBuf::from(u).join("AppData").join("Local")));+        if let Ok(l) = local {+            bases.push((l.join("Programs").join("KiCad"), true));+        }+    } else if cfg!(target_os = "macos") {+        bases.push((PathBuf::from("/Applications/KiCad/KiCad.app/Contents/MacOS"), false));+    } else {+        bases.push((PathBuf::from("/usr"), false));+        bases.push((PathBuf::from("/usr/local"), false));+    }+    for (base, per_user) in bases {+        if cfg!(windows) {+            let Ok(rd) = std::fs::read_dir(&base) else { continue };+            for e in rd.flatten() {+                let name = e.file_name().to_string_lossy().to_string();+                if e.path().is_dir() && name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {+                    if let Some(i) = install_from_base(&e.path(), &name, "folder", per_user) {+                        out.push(i);+                    }+                }+            }+        } else if let Some(i) = install_from_base(&base, "", "folder", per_user) {+            out.push(i);+        }+    }+    out+}++pub fn detect() -> KicadInfo {+    let mut installs = registry_installs();+    for f in folder_installs() {+        if !installs.iter().any(|i| i.bin_dir.eq_ignore_ascii_case(&f.bin_dir)) {+            installs.push(f);+        }+    }+    installs.sort_by(|a, b| version_key(&b.cli_version).cmp(&version_key(&a.cli_version)));+    let installed = !installs.is_empty();+    let version = installs.first().map(|i| i.cli_version.clone()).unwrap_or_default();+    KicadInfo { installed, version, installs }+}++/// %APPDATA%/kicad/<major.minor>/ on Windows; ~/.config/kicad/<ver> elsewhere.+pub fn config_dir(version: &str) -> Option<PathBuf> {+    let mm: String = version.split('.').take(2).collect::<Vec<_>>().join(".");+    if cfg!(windows) {+        let appdata = std::env::var("APPDATA").map(PathBuf::from).ok().or_else(|| {+            std::env::var("USERPROFILE").ok().map(|u| PathBuf::from(u).join("AppData").join("Roaming"))+        })?;+        Some(appdata.join("kicad").join(mm))+    } else if cfg!(target_os = "macos") {+        Some(PathBuf::from(std::env::var("HOME").ok()?).join("Library/Preferences/kicad").join(mm))+    } else {+        let base = std::env::var("XDG_CONFIG_HOME")+            .map(PathBuf::from)+            .unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config"));+        Some(base.join("kicad").join(mm))+    }+}
rust/src/main.rsadded+152
@@ -0,0 +1,152 @@+//! Adom KiCad Bridge, native binary. Phase 1 of docs/rust-port-plan.md.+//!+//! Contract (same as the Python bridge and the hello-rust sample):+//! - `--port N` from ab, bind ADOM_BIND_HOST (never 0.0.0.0)+//! - GET /status  -> health JSON with the optional led/summary/tooltip chip+//! - POST /command {"command":"kicad_x","args":{}} -> {"success","output"|"error",...}+//! Every verb lives in the catalog (catalog.rs) so describe, routing and risk+//! come from one table.++mod catalog;+mod cli;+mod detect;+mod verbs;++use std::time::Instant;++use serde::Deserialize;+use serde_json::{json, Value};+use tiny_http::{Header, Method, Response, Server, StatusCode};++pub const VERSION: &str = env!("CARGO_PKG_VERSION");+pub const SLUG: &str = "adom/kicad-bridge";++#[derive(Debug, Deserialize)]+struct Command {+    #[serde(default)]+    command: String,+    #[serde(default)]+    args: Value,+}++pub fn report_issues_line() -> String {+    format!(+        "THIS MAY BE A KICAD BRIDGE BUG. Check for a duplicate with `adom-wiki issue list {SLUG}`, \+         then file: `adom-wiki issue create {SLUG} --title \"<symptom>\" --category bug-report \+         --body \"<what you ran, expected, got, the verbatim response JSON, bridge version, OS>\"`. \+         An ab-core problem (relay, lifecycle, identity) belongs on adom/adom-bridge instead."+    )+}++pub struct Caller {+    pub thread: String,+    pub container: String,+    pub reason: String,+}++fn header_value(req: &tiny_http::Request, name: &str) -> Option<String> {+    req.headers()+        .iter()+        .find(|h| h.field.as_str().as_str().eq_ignore_ascii_case(name))+        .map(|h| h.value.as_str().to_string())+}++fn parse_port_arg() -> Option<u16> {+    let args: Vec<String> = std::env::args().collect();+    let mut i = 1;+    while i < args.len() {+        if args[i] == "--port" && i + 1 < args.len() {+            return args[i + 1].parse().ok();+        }+        if let Some(v) = args[i].strip_prefix("--port=") {+            return v.parse().ok();+        }+        i += 1;+    }+    None+}++fn json_response(body: Value, code: u16) -> Response<std::io::Cursor<Vec<u8>>> {+    let text = body.to_string();+    Response::from_string(text)+        .with_status_code(StatusCode(code))+        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap())+}++fn main() {+    let port = parse_port_arg().unwrap_or(0);+    let host = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());+    let started = Instant::now();+    let mut request_count: u64 = 0;+    let mut verb_count: u64 = 0;++    let server = Server::http((host.as_str(), port))+        .unwrap_or_else(|e| panic!("[kicad-bridge] bind {host}:{port} failed: {e}"));+    let actual_port = server.server_addr().to_ip().map(|a| a.port()).unwrap_or(port);+    eprintln!("[kicad-bridge] v{VERSION} listening on {host}:{actual_port}");++    let mut state = verbs::State::new();++    for mut req in server.incoming_requests() {+        request_count += 1;+        let uptime = started.elapsed().as_secs();+        let (body, code) = match (req.method(), req.url()) {+            (Method::Get, "/status") | (Method::Get, "/health") => {+                let info = state.kicad_info();+                let led = if info.installed { "green" } else { "yellow" };+                (+                    json!({+                        "ok": true,+                        "bridge": "kicad",+                        "version": VERSION,+                        "runtime": "native",+                        "uptimeSec": uptime,+                        "requestCount": request_count,+                        "led": led,+                        "summary": if info.installed { format!("KiCad {}", info.version) } else { "KiCad not found".into() },+                        "tooltip": format!("kicad-bridge v{VERSION} (native)\nup {uptime}s, {request_count} requests\n{}", info.summary()),+                    }),+                    200,+                )+            }+            (Method::Post, "/command") => {+                let caller = Caller {+                    thread: header_value(&req, "X-Adom-Caller-Thread").unwrap_or_else(|| "?".into()),+                    container: header_value(&req, "X-Adom-Caller-Container").unwrap_or_else(|| "?".into()),+                    reason: header_value(&req, "X-Adom-Caller-Reason").unwrap_or_default(),+                };+                let mut raw = String::new();+                let _ = req.as_reader().read_to_string(&mut raw);+                if raw.trim().is_empty() {+                    raw = "{}".into();+                }+                match serde_json::from_str::<Command>(&raw) {+                    Err(e) => (+                        json!({"success": false, "error": format!("bad JSON: {e}"), "_reportIssues": report_issues_line()}),+                        400,+                    ),+                    Ok(cmd) => {+                        verb_count += 1;+                        eprintln!(+                            "[kicad-bridge] {} <- thread={} container={} reason={}",+                            cmd.command, caller.thread, caller.container, caller.reason+                        );+                        let t0 = Instant::now();+                        let mut out = verbs::dispatch(&mut state, &cmd.command, &cmd.args, &caller);+                        if let Some(o) = out.as_object_mut() {+                            o.insert("elapsedMs".into(), json!(t0.elapsed().as_millis() as u64));+                            o.entry("bridgeVersion").or_insert(json!(VERSION));+                            let failed = !o.get("success").and_then(Value::as_bool).unwrap_or(false);+                            if failed || verb_count == 1 {+                                o.insert("_reportIssues".into(), json!(report_issues_line()));+                            }+                        }+                        (out, 200)+                    }+                }+            }+            _ => (json!({"success": false, "error": "not found"}), 404),+        };+        let _ = req.respond(json_response(body, code));+    }+}
rust/src/verbs.rsadded+211
@@ -0,0 +1,211 @@+//! Verb handlers for phase 1 (headless). Window, routing and demo verbs come in later phases.++use std::path::PathBuf;+use std::time::Duration;++use serde_json::{json, Value};++use crate::catalog;+use crate::cli;+use crate::detect::{self, KicadInfo};+use crate::Caller;++pub struct State {+    info: Option<(std::time::Instant, KicadInfo)>,+}++impl State {+    pub fn new() -> Self {+        State { info: None }+    }+    /// Detection is cached for 60 s; a verb that needs fresh truth calls refresh().+    pub fn kicad_info(&mut self) -> KicadInfo {+        if let Some((t, ref i)) = self.info {+            if t.elapsed() < Duration::from_secs(60) {+                return i.clone();+            }+        }+        self.refresh()+    }+    pub fn refresh(&mut self) -> KicadInfo {+        let i = detect::detect();+        self.info = Some((std::time::Instant::now(), i.clone()));+        i+    }+}++fn fail(code: &str, msg: impl Into<String>, hint: impl Into<String>) -> Value {+    json!({"success": false, "errorCode": code, "error": msg.into(), "_hint": hint.into()})+}++fn arg_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {+    args.get(key).and_then(Value::as_str).filter(|s| !s.trim().is_empty())+}++fn alternatives() -> Value {+    json!([+        {"backend": "service", "what": "The shared service-kicad container runs DRC, ERC, exports, renders and library lookups with no desktop KiCad.", "hint": "Load the service-kicad skill; pass backend:\"service\" to the headless verbs."},+        {"backend": "container", "what": "Install kicad-cli into your own container.", "hint": "The service-kicad skill has the install; then the headless verbs run locally."},+        {"backend": "target", "what": "Run KiCad on another box (a second laptop, a conference room machine, a Grok Computer session) and address it by name through Adom Bridge.", "hint": "adom-bridge targets lists the boxes; add --target <name> to every call."}+    ])+}++pub fn dispatch(state: &mut State, command: &str, args: &Value, _caller: &Caller) -> Value {+    let Some(verb) = catalog::find(command) else {+        let names: Vec<&str> = catalog::VERBS.iter().map(|v| v.name).collect();+        return fail("unknown_verb", format!("Unknown command: {command}"), format!("Valid commands: {}", names.join(", ")));+    };+    let mut out = match command {+        "kicad_describe" => catalog::describe(crate::VERSION),+        "kicad_status" => status(state),+        "kicad_readiness" => readiness(state),+        "kicad_list_versions" => list_versions(state),+        "kicad_diagnostics" => diagnostics(state),+        "kicad_run_drc" => run_check(state, args, true),+        "kicad_run_erc" => run_check(state, args, false),+        "kicad_uninstall" => uninstall(),+        _ => fail("not_implemented", format!("{command} is catalogued but not implemented in this phase"), "Use the Python bridge for this verb until the phase that ports it lands."),+    };+    if let Some(o) = out.as_object_mut() {+        o.entry("mechanism").or_insert(json!(verb.mechanism.as_str()));+    }+    out+}++fn status(state: &mut State) -> Value {+    let info = state.kicad_info();+    json!({+        "success": true,+        "bridgeVersion": crate::VERSION,+        "runtime": "native",+        "installed": info.installed,+        "kicadVersion": info.version,+        "install": info.primary(),+        "_hint": if info.installed { "KiCad found. Headless verbs (run_drc, run_erc) are ready." } else { "KiCad not found here. kicad_readiness lists the alternatives." },+    })+}++fn readiness(state: &mut State) -> Value {+    let info = state.refresh();+    let mut v = json!({+        "success": true,+        "installed": info.installed,+        "ready": info.installed,+        "kicadVersion": info.version,+        "installs": info.installs,+        "configDir": info.primary().and_then(|p| detect::config_dir(&p.version)).map(|p| p.to_string_lossy().replace('\\', "/")),+        "runtime": {"kind": "native", "provisioned": false},+    });+    if !info.installed {+        v["errorCode"] = json!("not_installed");+        v["alternatives"] = alternatives();+        v["_hint"] = json!("No KiCad on this box. Pick one of `alternatives`, or call kicad_upgrade to install KiCad silently (asks the user first).");+    } else {+        v["_hint"] = json!("Ready. Headless verbs need nothing else; window verbs are served by the Python bridge until phase 3.");+    }+    v+}++fn list_versions(state: &mut State) -> Value {+    let info = state.refresh();+    json!({"success": true, "count": info.installs.len(), "installs": info.installs, "primary": info.primary()})+}++fn diagnostics(state: &mut State) -> Value {+    let info = state.kicad_info();+    let keys = ["USERPROFILE", "APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ADOM_BIND_HOST", "ADOM_DIRECT_API_URL", "HOME", "PATH"];+    let env: serde_json::Map<String, Value> = keys+        .iter()+        .map(|k| (k.to_string(), json!(std::env::var(k).ok())))+        .collect();+    json!({+        "success": true,+        "bridgeVersion": crate::VERSION,+        "runtime": "native",+        "exe": std::env::current_exe().ok().map(|p| p.to_string_lossy().replace('\\', "/")),+        "cwd": std::env::current_dir().ok().map(|p| p.to_string_lossy().replace('\\', "/")),+        "os": std::env::consts::OS,+        "arch": std::env::consts::ARCH,+        "env": env,+        "kicad": info,+        "configDir": info.primary().and_then(|p| detect::config_dir(&p.version)).map(|p| p.to_string_lossy().replace('\\', "/")),+    })+}++fn run_check(state: &mut State, args: &Value, drc: bool) -> Value {+    let key = if drc { "boardPath" } else { "schematicPath" };+    let Some(path) = arg_str(args, key) else {+        return fail("missing_arg", format!("{key} is required"), format!("Pass the absolute path of the {} file.", if drc { ".kicad_pcb" } else { ".kicad_sch" }));+    };+    let path = PathBuf::from(path);+    if !path.is_file() {+        return fail("file_not_found", format!("{} does not exist", path.display()), "Check the path; the bridge runs on the desktop, so use the desktop's path, not the container's.");+    }+    let backend = arg_str(args, "backend").unwrap_or("local");+    if backend != "local" {+        return fail("backend_not_implemented", format!("backend {backend} lands with phase 1b"), "Use backend local for now, or run service-kicad from the container directly.");+    }+    let info = state.kicad_info();+    let Some(inst) = info.primary() else {+        let mut v = fail("not_installed", "KiCad is not installed on this box", "See alternatives.");+        v["alternatives"] = alternatives();+        return v;+    };+    let cli_path = PathBuf::from(&inst.kicad_cli);+    let report = std::env::temp_dir().join(format!("adom-kicad-{}-{}.json", if drc { "drc" } else { "erc" }, std::process::id()));+    let report_s = report.to_string_lossy().to_string();+    let path_s = path.to_string_lossy().to_string();+    let mut argv: Vec<&str> = if drc {+        vec!["pcb", "drc", "--format", "json", "--severity-all", "--all-track-errors"]+    } else {+        vec!["sch", "erc", "--format", "json", "--severity-all"]+    };+    if drc && args.get("refillZones").and_then(Value::as_bool).unwrap_or(false) {+        argv.push("--refill-zones");+    }+    argv.extend(["--output", report_s.as_str(), path_s.as_str()]);+    let r = cli::run(&cli_path, &argv, Duration::from_secs(280));+    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 summary = cli::summarize_report(&rep);+            json!({+                "success": true,+                "check": if drc { "drc" } else { "erc" },+                "path": path_s,+                "summary": summary,+                "cliExitCode": r.exit_code,+                "cliElapsedMs": r.elapsed_ms,+                "cliVersion": inst.cli_version,+                "report": rep,+                "_hint": if drc { "summary.clean is true only with zero errors and zero unconnected items; warnings are listed but do not fail the board." } else { "summary.clean means zero errors; ERC warnings are listed." },+            })+        }+        None => {+            let mut v = fail(+                if r.timed_out { "cli_timeout" } else { "cli_failed" },+                format!("kicad-cli exit {}: {}", r.exit_code, r.stderr.trim()),+                "kicad-cli wrote no report. The stderr above usually names the reason (file version, missing library, bad path).",+            );+            v["stdout"] = json!(r.stdout);+            v["cliElapsedMs"] = json!(r.elapsed_ms);+            v+        }+    }+}++fn uninstall() -> Value {+    let mut cleaned: Vec<String> = Vec::new();+    for name in ["adom-kicad-drc", "adom-kicad-erc"] {+        if let Ok(rd) = std::fs::read_dir(std::env::temp_dir()) {+            for e in rd.flatten() {+                let n = e.file_name().to_string_lossy().to_string();+                if n.starts_with(name) && n.ends_with(".json") && std::fs::remove_file(e.path()).is_ok() {+                    cleaned.push(n);+                }+            }+        }+    }+    json!({"success": true, "cleaned": cleaned, "_hint": "Only bridge scratch files are removed. KiCad, its settings and user libraries are untouched."})+}