app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
Colby Knox
Merge PR #399: 1.0.100: update_pcb sets every reference (closes #138); the verbs ruled on standard #29
85f32a2
8d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657
//! The Freerouting engine behind `kicad_freerouting` and `kicad_autoroute {engine:
//! "freerouting"}`. Freerouting 2.4.1 ships its own Java runtime in every platform
//! build, so there is no system Java and no elevation anywhere in here.
//!
//! Contract (John's terms): always offered, installed only when the caller asks
//! (`kicad_freerouting {action:"install"}`), removable on its own (`uninstall` deletes the
//! bridge's Freerouting folder and nothing else), and nothing in this module downloads or
//! runs a process unless the caller asked for an install or a Freerouting route. `status`
//! only looks at the disk.
//!
//! Every process here has a deadline (`cli::run` for the version probe, the reader loop in
//! `run` for the router) and the download is streamed with the size check
//! `install_kicad::stream_download` already does for the KiCad installer.
use std::collections::VecDeque;
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use serde_json::{json, Value};
use crate::{cli, dsn, install_kicad, pcb, pcm};
use kicad_platform::native;
pub const VERSION: &str = "2.4.1";
/// Environment override for the executable (tests and boxes with their own copy).
pub const ENV_EXE: &str = "ADOM_FREEROUTING_EXE";
pub const DEFAULT_PASSES: u32 = 20;
pub const DEFAULT_TIMEOUT_SECS: u64 = 600;
/// JVM start plus the help text; generous for a cold Windows box.
const HELP_TIMEOUT: Duration = Duration::from_secs(60);
const TAIL_LINES: usize = 40;
/// The flags every headless run gets: no GUI (or Freerouting warns about the screen
/// resolution and, on a box without a display, may not start), no analytics.
pub const HEADLESS_FLAGS: [&str; 2] = ["--gui.enabled=false", "--usage_and_diagnostic_data.disable_analytics=true"];
/// Where the bundle unpacks to, in decimal MB, per archive kind (measured 2026-09-12:
/// the msi admin install on Windows, the zip on Linux).
pub fn on_disk_mb(kind: &str) -> u64 {
match kind {
"msi" => 141,
"zip" => 163,
_ => 150,
}
}
// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
pub fn dir() -> PathBuf {
native().freerouting_dir()
}
/// The executable: `ADOM_FREEROUTING_EXE` when it points at a file, else the bundle
/// layout under `dir` (Windows `freerouting\freerouting.exe` from the msi admin install,
/// Linux `freerouting-<ver>-linux-x64/bin/freerouting` from the zip).
pub fn find_exe(dir: &Path) -> Option<PathBuf> {
if let Some(p) = std::env::var_os(ENV_EXE).map(PathBuf::from).filter(|p| p.is_file()) {
return Some(p);
}
let mut candidates = vec![dir.join("freerouting").join("freerouting.exe"), dir.join("freerouting.exe"), dir.join("bin").join("freerouting")];
if let Ok(rd) = std::fs::read_dir(dir) {
for e in rd.flatten().filter(|e| e.path().is_dir()) {
candidates.push(e.path().join("bin").join("freerouting"));
candidates.push(e.path().join("freerouting.exe"));
}
}
candidates.into_iter().find(|p| p.is_file())
}
/// `freerouting --help`'s first line: `... Freerouting v2.4.1 (build-date: ...)`.
pub fn parse_version(output: &str) -> Option<String> {
let i = output.find("Freerouting v")?;
let rest = &output[i + "Freerouting v".len()..];
let v: String = rest.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
if v.is_empty() { None } else { Some(v) }
}
/// Run `--help` with a deadline and read the version it prints. Err when the exe does not
/// start or does not identify itself.
pub fn probe_version(exe: &Path) -> Result<String, String> {
let r = cli::run(exe, &["--gui.enabled=false", "-h"], HELP_TIMEOUT);
if r.timed_out {
return Err(format!("{} did not answer --help within {} s", exe.display(), HELP_TIMEOUT.as_secs()));
}
let text = format!("{}\n{}", r.stdout, r.stderr);
parse_version(&text).ok_or_else(|| format!("{} did not identify itself (exit {}): {}", exe.display(), r.exit_code, text.lines().next().unwrap_or("").chars().take(200).collect::<String>()))
}
fn dir_bytes(p: &Path) -> u64 {
let Ok(rd) = std::fs::read_dir(p) else { return 0 };
rd.flatten()
.map(|e| {
let path = e.path();
if path.is_dir() {
dir_bytes(&path)
} else {
e.metadata().map(|m| m.len()).unwrap_or(0)
}
})
.sum()
}
/// Decimal megabytes (what the release page and the user's download dialog show).
fn mb(bytes: u64) -> f64 {
(bytes as f64 / 1_000_000.0 * 10.0).round() / 10.0
}
pub struct Status {
pub dir: PathBuf,
pub exe: Option<PathBuf>,
pub bundle: Option<(String, u64, &'static str)>,
}
impl Status {
pub fn installed(&self) -> bool {
self.exe.is_some()
}
}
/// Disk facts only: no process runs, nothing downloads.
pub fn status() -> Status {
let dir = dir();
Status { exe: find_exe(&dir), bundle: native().freerouting_bundle(), dir }
}
/// The `kicad_freerouting {action:"status"}` reply.
pub fn status_json() -> Value {
let s = status();
let mut out = json!({
"success": true,
"installed": s.installed(),
"supported": s.bundle.is_some(),
"dir": s.dir.to_string_lossy().replace('\\', "/"),
"exe": s.exe.as_ref().map(|p| p.to_string_lossy().replace('\\', "/")),
"bundledJava": true,
"install": {"call": "kicad_freerouting {\"action\":\"install\"}", "elevation": "none", "runsOnlyWhenAsked": true},
"uninstall": {"call": "kicad_freerouting {\"action\":\"uninstall\"}", "removes": "the bridge's Freerouting folder only"},
});
if let Some((url, bytes, kind)) = &s.bundle {
out["bundle"] = json!({"version": VERSION, "url": url, "bytes": bytes, "downloadMb": mb(*bytes), "onDiskMb": on_disk_mb(kind), "kind": kind});
} else {
out["unsupportedReason"] = json!(format!("Freerouting {VERSION} ships a dmg for macOS; the bridge does not drive dmg installs. Install Freerouting yourself and point {ENV_EXE} at the executable, or route with engine ai."));
}
if s.installed() {
out["diskMb"] = json!(mb(dir_bytes(&s.dir)));
out["version"] = json!(VERSION);
out["versionSource"] = json!("bundle (kicad_freerouting install verified --help at install time; autoroute reports the live version)");
out["_hint"] = json!("Freerouting is installed. kicad_autoroute {\"engine\":\"freerouting\",\"filePath\":\"...\"} routes with it; engine ai stays the recommendation for a board whose intent matters. Ask the user which engine they want. uninstall removes the folder.");
} else if s.bundle.is_some() {
let (_, bytes, kind) = s.bundle.as_ref().unwrap();
out["_hint"] = json!(format!("Freerouting is not installed. It is a {} MB download ({} MB on disk, bundled Java, no elevation) that the bridge only fetches when asked: ASK THE USER before calling kicad_freerouting {{\"action\":\"install\"}}. Engine ai needs nothing installed.", mb(*bytes).round() as u64, on_disk_mb(kind)));
} else {
out["_hint"] = json!("Freerouting is not available on this OS through the bridge; route with engine ai, or install Freerouting yourself and set ADOM_FREEROUTING_EXE.");
}
out
}
/// The `freerouting_not_installed` reply `kicad_autoroute` gives.
pub fn not_installed(s: &Status) -> Value {
let mut v = json!({
"success": false, "errorCode": "freerouting_not_installed", "engine": "freerouting", "mutated": false,
"error": "Freerouting is not installed on this box",
"install": {"call": "kicad_freerouting {\"action\":\"install\"}", "elevation": "none"},
"dir": s.dir.to_string_lossy().replace('\\', "/"),
});
match &s.bundle {
Some((url, bytes, kind)) => {
v["download"] = json!({"url": url, "bytes": bytes, "mb": mb(*bytes).round() as u64, "onDiskMb": on_disk_mb(kind), "kind": kind, "bundledJava": true});
v["_hint"] = json!(format!(
"ASK THE USER BEFORE INSTALLING: Freerouting {VERSION} is a {} MB download and {} MB on disk (its own Java runtime, no UAC, into {}). If they say yes, call kicad_freerouting {{\"action\":\"install\"}} (about a minute), then this autoroute call again. If they would rather not install anything, engine ai routes now with kicad_route_net: kicad_autoroute {{\"engine\":\"ai\"}}.",
mb(*bytes).round() as u64,
on_disk_mb(kind),
s.dir.to_string_lossy().replace('\\', "/")
));
}
None => {
v["errorCode"] = json!("freerouting_unsupported_platform");
v["_hint"] = json!(format!("The bridge has no Freerouting bundle for this OS (macOS ships a dmg). Install Freerouting yourself and set {ENV_EXE} to the executable, or route with engine ai."));
}
}
v
}
// ---------------------------------------------------------------------------
// Install and uninstall
// ---------------------------------------------------------------------------
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 log(msg: &str) {
eprintln!("[kicad-bridge] freerouting: {msg}");
}
/// Unpack a zip (the std-only reader from `pcm`) under `dest`, zip-slip guarded.
pub fn unzip_to(archive: &Path, dest: &Path) -> Result<(), String> {
let bytes = std::fs::read(archive).map_err(|e| format!("cannot read {}: {e}", archive.display()))?;
let zf = pcm::ZipFile::parse(&bytes)?;
for entry in &zf.entries {
let name = entry.name.replace('\\', "/");
if name.is_empty() || name.starts_with('/') || name.split('/').any(|seg| seg == "..") {
return Err(format!("unsafe zip entry: {}", entry.name));
}
let target = dest.join(&name);
if name.ends_with('/') {
std::fs::create_dir_all(&target).map_err(|e| format!("cannot create {}: {e}", target.display()))?;
continue;
}
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
}
let data = zf.read(entry)?;
std::fs::write(&target, data).map_err(|e| format!("cannot write {}: {e}", target.display()))?;
}
Ok(())
}
/// Extract an already downloaded bundle into `dest` and return the executable. The OS
/// layer decides how (msiexec /a on Windows, the zip reader plus chmod on Linux).
pub fn extract_bundle(archive: &Path, dest: &Path) -> Result<PathBuf, String> {
native().freerouting_extract(archive, dest, &unzip_to)
}
/// Download, extract, verify. Runs ONLY from `kicad_freerouting {action:"install"}`.
/// `force`: re-download even when an install already answers `--help`.
pub fn install(force: bool, mut progress: impl FnMut(&str, Option<i64>)) -> Value {
let started = Instant::now();
let s = status();
let Some((url, bytes, kind)) = s.bundle.clone() else {
let mut v = not_installed(&s);
v["errorCode"] = json!("freerouting_unsupported_platform");
return v;
};
if !force {
if let Some(exe) = &s.exe {
if let Ok(v) = probe_version(exe) {
return json!({
"success": true, "installed": true, "alreadyInstalled": true, "version": v,
"exe": exe.to_string_lossy().replace('\\', "/"), "dir": s.dir.to_string_lossy().replace('\\', "/"),
"diskMb": mb(dir_bytes(&s.dir)),
"_hint": "Already installed and answering --help; nothing downloaded. Pass force:true to re-download. kicad_autoroute {\"engine\":\"freerouting\"} routes with it.",
});
}
}
}
let dir = s.dir.clone();
if let Err(e) = std::fs::create_dir_all(&dir) {
return fail("install_failed", format!("cannot create {}: {e}", dir.display()), "Check that the folder is writable; nothing was downloaded.");
}
// A clean slate: a half-extracted tree or an older bundle would confuse find_exe.
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let p = e.path();
let _ = if p.is_dir() { std::fs::remove_dir_all(&p) } else { std::fs::remove_file(&p) };
}
let archive = dir.join(format!("download.{kind}"));
let mut timings = json!({});
// ---- download (streamed to download.<kind>.part, renamed on completion, size checked)
progress(&format!("downloading Freerouting {VERSION} ({} MB)", mb(bytes).round() as u64), Some(5));
log(&format!("downloading {url} -> {}", archive.display()));
let t = Instant::now();
let total = match install_kicad::stream_download(&url, &archive, Some(bytes)) {
Ok((total, _)) => total,
Err(e) => {
let _ = std::fs::remove_file(&archive);
return fail("download_failed", format!("download failed: {e}"), "Nothing was installed. Check the box's internet access and retry kicad_freerouting {\"action\":\"install\"}; engine ai needs no download.");
}
};
timings["download"] = json!((t.elapsed().as_secs_f64() * 100.0).round() / 100.0);
if total != bytes {
let _ = std::fs::remove_file(&archive);
let mut v = fail("download_truncated", format!("download truncated: got {total} of {bytes} bytes; the file was deleted"), "Retry kicad_freerouting {\"action\":\"install\"}. A repeat means a flaky link or a changed release asset.");
v["timings"] = timings;
return v;
}
// ---- extract
progress("extracting", Some(60));
log(&format!("extracting {} ({kind}) into {}", archive.display(), dir.display()));
let t = Instant::now();
let exe = match extract_bundle(&archive, &dir) {
Ok(p) => p,
Err(e) => {
let _ = std::fs::remove_file(&archive);
let mut v = fail("extract_failed", format!("extraction failed: {e}"), "The archive was deleted. Retry the install; if it repeats, the release asset layout changed and the bridge needs an update.");
v["timings"] = timings;
return v;
}
};
timings["extract"] = json!((t.elapsed().as_secs_f64() * 100.0).round() / 100.0);
let _ = std::fs::remove_file(&archive);
// ---- verify
progress("verifying (--help)", Some(90));
let t = Instant::now();
let version = match probe_version(&exe) {
Ok(v) => v,
Err(e) => {
let mut v = fail("verify_failed", format!("Freerouting was extracted but does not start: {e}"), "The folder is left in place for inspection; kicad_freerouting {\"action\":\"uninstall\"} removes it. Engine ai routes without it.");
v["exe"] = json!(exe.to_string_lossy().replace('\\', "/"));
v["timings"] = timings;
return v;
}
};
timings["verify"] = json!((t.elapsed().as_secs_f64() * 100.0).round() / 100.0);
timings["total"] = json!((started.elapsed().as_secs_f64() * 100.0).round() / 100.0);
let disk = mb(dir_bytes(&dir));
log(&format!("installed Freerouting {version} at {} ({disk} MB) in {} s", exe.display(), timings["total"]));
json!({
"success": true, "installed": true, "version": version,
"exe": exe.to_string_lossy().replace('\\', "/"), "dir": dir.to_string_lossy().replace('\\', "/"),
"downloadedBytes": total, "diskMb": disk, "kind": kind, "timings": timings,
"_hint": "Freerouting is installed and answered --help. kicad_autoroute {\"engine\":\"freerouting\",\"filePath\":\"...\"} routes with it; kicad_freerouting {\"action\":\"uninstall\"} removes it again. Report to the user what was installed and where.",
})
}
/// Remove the bridge's Freerouting folder and nothing else.
pub fn uninstall() -> Value {
let dir = dir();
if !dir.exists() {
return json!({"success": true, "removed": false, "alreadyClean": true, "dir": dir.to_string_lossy().replace('\\', "/"), "_hint": "Nothing to remove: Freerouting was not installed by the bridge."});
}
let size = mb(dir_bytes(&dir));
match std::fs::remove_dir_all(&dir) {
Ok(()) => json!({"success": true, "removed": true, "dir": dir.to_string_lossy().replace('\\', "/"), "freedMb": size, "_hint": "Freerouting removed (the bridge's folder only). kicad_freerouting {\"action\":\"install\"} brings it back when the user asks; engine ai needs nothing."}),
Err(e) => {
let mut v = fail("uninstall_failed", format!("could not remove {}: {e}", dir.display()), "A Freerouting process may still be running (a route in flight); wait for it and retry.");
v["dir"] = json!(dir.to_string_lossy().replace('\\', "/"));
v
}
}
}
// ---------------------------------------------------------------------------
// Run
// ---------------------------------------------------------------------------
pub struct RunOutcome {
pub passes_seen: u32,
/// From the last `... (N unrouted and M violations)` line Freerouting printed.
pub unrouted: Option<u32>,
pub violations: Option<u32>,
pub seconds: f64,
pub stdout_tail: Vec<String>,
pub exit_code: i32,
pub timed_out: bool,
/// stopped by kicad_cancel_run
pub cancelled: bool,
pub version: Option<String>,
pub args: Vec<String>,
}
/// `... pass 3 ...`, `Pass #3`, `pass 3 of 20`: the first integer after the word "pass".
pub fn parse_pass(line: &str) -> Option<u32> {
let lower = line.to_ascii_lowercase();
let i = lower.find("pass")?;
let rest = &lower[i + 4..];
let rest = rest.trim_start_matches(|c: char| !c.is_ascii_digit() && c != '\n').trim_start();
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() || !rest.starts_with(|c: char| c.is_ascii_digit()) {
return None;
}
// Ignore "passed", "bypass" and friends: the word must end right after "pass".
let after = &lower[i + 4..i + 5.min(lower.len() - i)];
if after.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {
return None;
}
digits.parse().ok()
}
/// `... final score: 999.99 (0 unrouted and 0 violations)` -> (0, 0).
pub fn parse_score(line: &str) -> Option<(u32, u32)> {
let i = line.rfind(" unrouted and ")?;
let before = &line[..i];
let unrouted: String = before.chars().rev().take_while(|c| c.is_ascii_digit()).collect::<Vec<_>>().into_iter().rev().collect();
let after = &line[i + " unrouted and ".len()..];
let violations: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
Some((unrouted.parse().ok()?, violations.parse().ok()?))
}
static CANCEL_JOBS: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
/// kicad_cancel_run: ask the job running on the thread named `kicad-<job>` to stop.
pub fn request_cancel(job: &str) {
CANCEL_JOBS.lock().unwrap_or_else(|e| e.into_inner()).push(format!("kicad-{job}"));
}
/// True once this thread's job has been asked to stop; the request is used up here.
fn cancel_requested() -> bool {
let name = std::thread::current().name().unwrap_or("").to_string();
let mut jobs = CANCEL_JOBS.lock().unwrap_or_else(|e| e.into_inner());
match jobs.iter().position(|j| *j == name) {
Some(i) => {
jobs.remove(i);
true
}
None => false,
}
}
/// Run Freerouting headless: `-de <dsn> -do <ses> -mp <passes> [-mt <threads>]` plus the
/// headless flags, working directory `work`, killed at `timeout`. `on_line` sees every
/// stdout line as it arrives (the caller turns pass lines into progress).
pub fn run(exe: &Path, dsn_path: &Path, ses_path: &Path, work: &Path, passes: u32, threads: Option<u32>, timeout: Duration, mut on_line: impl FnMut(&str)) -> Result<RunOutcome, String> {
let mut args: Vec<String> = HEADLESS_FLAGS.iter().map(|s| s.to_string()).collect();
args.extend(["-de".into(), dsn_path.to_string_lossy().to_string(), "-do".into(), ses_path.to_string_lossy().to_string(), "-mp".into(), passes.to_string()]);
if let Some(t) = threads {
args.extend(["-mt".into(), t.to_string()]);
}
let mut cmd = Command::new(exe);
cmd.args(&args).current_dir(work);
cli::quiet(&mut cmd);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let t0 = Instant::now();
let mut child = cmd.spawn().map_err(|e| format!("cannot start {}: {e}", exe.display()))?;
let (tx, rx) = mpsc::channel::<String>();
let mut readers = Vec::new();
if let Some(out) = child.stdout.take() {
let tx = tx.clone();
readers.push(std::thread::spawn(move || {
for line in std::io::BufReader::new(out).lines().map_while(Result::ok) {
if tx.send(line).is_err() {
break;
}
}
}));
}
if let Some(err) = child.stderr.take() {
let tx = tx.clone();
readers.push(std::thread::spawn(move || {
for line in std::io::BufReader::new(err).lines().map_while(Result::ok) {
if tx.send(format!("[stderr] {line}")).is_err() {
break;
}
}
}));
}
drop(tx);
let mut tail: VecDeque<String> = VecDeque::with_capacity(TAIL_LINES);
let mut passes_seen = 0u32;
let mut version = None;
let mut score: Option<(u32, u32)> = None;
let mut push = |line: String, tail: &mut VecDeque<String>| {
if version.is_none() {
version = parse_version(&line);
}
if let Some(p) = parse_pass(&line) {
passes_seen = passes_seen.max(p);
}
if let Some(sc) = parse_score(&line) {
score = Some(sc);
}
on_line(&line);
if tail.len() == TAIL_LINES {
tail.pop_front();
}
tail.push_back(line);
};
let exit_code;
let mut timed_out = false;
let mut cancelled = false;
loop {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(line) => push(line, &mut tail),
Err(mpsc::RecvTimeoutError::Disconnected) => {
// Both pipes closed: the process is done or about to be.
match child.wait() {
Ok(st) => exit_code = st.code().unwrap_or(-1),
Err(e) => return Err(format!("waiting on Freerouting failed: {e}")),
}
break;
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if let Ok(Some(st)) = child.try_wait() {
exit_code = st.code().unwrap_or(-1);
// Drain what the readers still hold.
while let Ok(line) = rx.recv_timeout(Duration::from_millis(200)) {
push(line, &mut tail);
}
break;
}
if t0.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
timed_out = true;
exit_code = -2;
break;
}
if cancel_requested() {
let _ = child.kill();
let _ = child.wait();
cancelled = true;
exit_code = -3;
break;
}
}
for r in readers {
let _ = r.join();
}
Ok(RunOutcome { cancelled, passes_seen, unrouted: score.map(|s| s.0), violations: score.map(|s| s.1), seconds: (t0.elapsed().as_secs_f64() * 100.0).round() / 100.0, stdout_tail: tail.into_iter().collect(), exit_code, timed_out, version, args })
}
// ---------------------------------------------------------------------------
// Apply and check
// ---------------------------------------------------------------------------
/// Splice the session's copper into the closed board file (backup `.adom-bak`), the way
/// `kicad_route` writes. Returns (backup path, segments, vias).
pub fn apply_to_file(loaded: &pcb::Loaded, copper: &[dsn::NetCopper], backup: bool) -> Result<(Option<String>, usize, usize), Value> {
let board = &loaded.board;
let mut blocks = Vec::new();
let (mut segs, mut vias) = (0usize, 0usize);
for c in copper {
let net = pcb::resolve_net(board, Some(&json!(c.net)))?;
for s in &c.segments {
blocks.push(pcb::segment_sexpr((s.start[0], s.start[1]), (s.end[0], s.end[1]), s.width, &s.layer, &net));
segs += 1;
}
for v in &c.vias {
blocks.push(pcb::via_sexpr((v.at[0], v.at[1]), v.size, v.drill, (&v.layers[0], &v.layers[1]), &net));
vias += 1;
}
}
if blocks.is_empty() {
return Ok((None, 0, 0));
}
let bak = pcb::write_board(&loaded.path, &pcb::splice(&loaded.text, &blocks), backup, Some(&loaded.text))?;
Ok((bak, segs, vias))
}
/// KiCad's verdict on the file through kicad-cli when one is installed (the same flags
/// `kicad_run_drc` uses); `{"available": false}` otherwise.
pub fn drc_file(kicad_cli: Option<&Path>, board: &Path) -> Value {
let Some(exe) = kicad_cli.filter(|p| p.is_file()) else {
return json!({"available": false, "reason": "no local kicad-cli", "_hint": "Run kicad_run_drc through the service backend, or open the board and call kicad_routing_validate."});
};
let report = std::env::temp_dir().join(format!("adom-freerouting-drc-{}-{}.json", std::process::id(), crate::schematic::new_uuid()));
let report_s = report.to_string_lossy().to_string();
let board_s = board.to_string_lossy().to_string();
let r = cli::run(exe, &["pcb", "drc", "--format", "json", "--severity-all", "--all-track-errors", "--output", &report_s, &board_s], Duration::from_secs(180));
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 mut out = cli::summarize_report(&rep);
out["available"] = json!(true);
out["elapsedMs"] = json!(r.elapsed_ms);
out
}
None => json!({"available": false, "reason": if r.timed_out { "kicad-cli DRC timed out".to_string() } else { format!("kicad-cli exit {}: {}", r.exit_code, r.stderr.trim().chars().take(300).collect::<String>()) }}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_and_pass_lines_parse() {
assert_eq!(parse_version("2026-09-12 13:09:22.106 INFO Freerouting v2.4.1 (build-date: 2026-09-03)"), Some("2.4.1".into()));
assert_eq!(parse_version("nothing here"), None);
assert_eq!(parse_pass("INFO Starting auto-router pass 3 of 20 on board x"), Some(3));
assert_eq!(parse_pass("Pass #12: 4 items routed"), Some(12));
assert_eq!(parse_pass("Autorouter pass 7"), Some(7));
assert_eq!(parse_pass("all tests passed 5 times"), None);
assert_eq!(parse_pass("bypass 3"), Some(3), "a prefixed word is tolerated");
assert_eq!(parse_pass("no number after pass"), None);
assert_eq!(parse_pass("routing finished"), None);
assert_eq!(parse_score("INFO [x] Auto-routing stage completed: started with 7 unrouted nets, completed in 0.40 seconds, final score: 999.99 (0 unrouted and 0 violations), using"), Some((0, 0)));
assert_eq!(parse_score("Auto-routing pass #4 on board 'x' was completed in 1.2 seconds with score 812.5 (3 unrouted and 1 violations)."), Some((3, 1)));
assert_eq!(parse_score("no score here"), None);
}
#[test]
fn status_never_runs_anything_and_reports_the_bundle() {
let v = status_json();
assert!(v["success"].as_bool().unwrap());
assert!(v["installed"].is_boolean());
assert!(v["install"]["call"].as_str().unwrap().contains("kicad_freerouting"));
if cfg!(target_os = "macos") {
assert_eq!(v["supported"], false);
} else {
assert_eq!(v["supported"], true);
assert_eq!(v["bundle"]["version"], VERSION);
assert!(v["bundle"]["url"].as_str().unwrap().starts_with("https://github.com/freerouting/freerouting/releases/download/v2.4.1/"));
}
let s = Status { dir: PathBuf::from("/x"), exe: None, bundle: Some(("u".into(), 88_235_097, "msi")) };
let n = not_installed(&s);
assert_eq!(n["errorCode"], "freerouting_not_installed");
assert_eq!(n["download"]["mb"], 88);
assert_eq!(n["download"]["onDiskMb"], 141);
assert!(n["_hint"].as_str().unwrap().starts_with("ASK THE USER BEFORE INSTALLING"));
assert!(n["_hint"].as_str().unwrap().contains("kicad_freerouting {\"action\":\"install\"}"));
}
#[test]
fn find_exe_sees_the_bundle_layouts() {
let dir = std::env::temp_dir().join(format!("adom-fr-find-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
assert!(find_exe(&dir).is_none());
let linux = dir.join("freerouting-2.4.1-linux-x64").join("bin");
std::fs::create_dir_all(&linux).unwrap();
std::fs::write(linux.join("freerouting"), "x").unwrap();
assert_eq!(find_exe(&dir).unwrap(), linux.join("freerouting"));
let win = dir.join("freerouting");
std::fs::create_dir_all(&win).unwrap();
std::fs::write(win.join("freerouting.exe"), "x").unwrap();
assert_eq!(find_exe(&dir).unwrap(), win.join("freerouting.exe"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unzip_refuses_zip_slip() {
let dir = std::env::temp_dir().join(format!("adom-fr-zip-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// A stored zip with one entry named ../evil.
let name = b"../evil";
let data = b"x";
let mut z: Vec<u8> = Vec::new();
z.extend_from_slice(b"PK\x03\x04");
z.extend_from_slice(&[20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
z.extend_from_slice(&[0, 0, 0, 0]); // crc
z.extend_from_slice(&(data.len() as u32).to_le_bytes());
z.extend_from_slice(&(data.len() as u32).to_le_bytes());
z.extend_from_slice(&(name.len() as u16).to_le_bytes());
z.extend_from_slice(&[0, 0]);
z.extend_from_slice(name);
z.extend_from_slice(data);
let cd = z.len() as u32;
z.extend_from_slice(b"PK\x01\x02");
z.extend_from_slice(&[20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
z.extend_from_slice(&[0, 0, 0, 0]);
z.extend_from_slice(&(data.len() as u32).to_le_bytes());
z.extend_from_slice(&(data.len() as u32).to_le_bytes());
z.extend_from_slice(&(name.len() as u16).to_le_bytes());
z.extend_from_slice(&[0; 8]);
z.extend_from_slice(&[0; 4]);
z.extend_from_slice(&0u32.to_le_bytes());
z.extend_from_slice(name);
let cd_len = z.len() as u32 - cd;
z.extend_from_slice(b"PK\x05\x06");
z.extend_from_slice(&[0, 0, 0, 0, 1, 0, 1, 0]);
z.extend_from_slice(&cd_len.to_le_bytes());
z.extend_from_slice(&cd.to_le_bytes());
z.extend_from_slice(&[0, 0]);
let archive = dir.join("evil.zip");
std::fs::write(&archive, &z).unwrap();
let err = unzip_to(&archive, &dir.join("out")).unwrap_err();
assert!(err.contains("unsafe zip entry"), "{err}");
assert!(!dir.join("evil").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}