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);
    }
}