master
John Lauer rust: workspace with kicad-platform (windows, macos, linux), kicad-core, kicad-bridge; 13 headless verbs; alpha.2 51525a8 2mo ago
//! 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) {
    kicad_platform::native().quiet_command(cmd);
}

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"),
    })
}

/// The Python bridge's `data.violations` shape: one entry per violation and per
/// unconnected item, each with its items' description and pos.
pub fn flatten_violations(report: &Value) -> Vec<Value> {
    let mut out = Vec::new();
    let items_of = |v: &Value| -> Vec<Value> {
        v.get("items").and_then(Value::as_array).map(|a| a.iter().map(|i| json!({"description": i.get("description").cloned().unwrap_or(json!("")), "pos": i.get("pos").cloned().unwrap_or(json!({}))})).collect()).unwrap_or_default()
    };
    let mut push = |arr: Option<&Value>, forced_type: Option<&str>| {
        if let Some(items) = arr.and_then(Value::as_array) {
            for v in items {
                out.push(json!({
                    "type": forced_type.map(|t| json!(t)).unwrap_or_else(|| v.get("type").cloned().unwrap_or(json!("unknown"))),
                    "severity": v.get("severity").cloned().unwrap_or(json!(if forced_type.is_some() { "error" } else { "warning" })),
                    "description": v.get("description").cloned().unwrap_or(json!("")),
                    "excluded": v.get("excluded").and_then(Value::as_bool).unwrap_or(false),
                    "items": items_of(v),
                }));
            }
        }
    };
    push(report.get("violations"), None);
    push(report.get("unconnected_items"), Some("unconnected_items"));
    push(report.get("schematic_parity"), Some("schematic_parity"));
    if let Some(sheets) = report.get("sheets").and_then(Value::as_array) {
        for s in sheets {
            push(s.get("violations"), None);
        }
    }
    out
}