master
John Lauer Honest DRC/ERC reports: derive counts from native findings, exit 5 on error findings, ERC coordinate-scale check, load… fa1d9ce 1mo ago
//! `pcb drc` / `sch erc`: post the file, then read the native report
//! honestly (adom/service-kicad#2, #3).
//!
//! - Counts come from the findings, never from keys kicad-cli does not
//!   write. Text, JSON and exit code all derive from ONE `Summary`.
//! - The native report is preserved byte-for-byte under `--format native`;
//!   `--format json` adds a single top-level `adom_summary` key and touches
//!   nothing else.
//! - Error-severity findings exit `5` (EXIT_FINDINGS) unless `--exit-zero`.
//!   HTTP 200 is "the report was produced", never "the board passed".
//! - ERC positions are checked against the uploaded schematic; a scale
//!   mismatch is reported as a hint with the factor. Nothing is rescaled.
//! - A "Failed to load" from the service is diagnosed locally (truncation,
//!   wrong file kind, legacy format) and exits `1` (input invalid).
use anyhow::{Context, Result};
use serde_json::{json, Value};
use service_kicad_report::{check_erc_scale, diagnose, summary::Finding, Hint, ReportKind, Summary};
use sha2::{Digest, Sha256};
use std::{io::Write, path::Path};

pub struct Request<'a> {
    pub kind: ReportKind,
    pub endpoint: &'a str,
    pub file: &'a Path,
    /// `text` | `json` | `native`
    pub format: &'a str,
    pub out: Option<&'a Path>,
    pub exit_zero: bool,
}

/// Returns the process exit code. Never returns for transport/auth
/// failures (those exit inline, same codes as every other verb).
pub fn run(req: Request<'_>, base_url: &str, client: &reqwest::blocking::Client) -> Result<i32> {
    if !matches!(req.format, "text" | "json" | "native") {
        anyhow::bail!("--format must be text, json or native (got {:?})", req.format);
    }
    let root = match req.kind { ReportKind::Drc => "kicad_pcb", ReportKind::Erc => "kicad_sch" };
    let body = std::fs::read(req.file).with_context(|| format!("read {}", req.file.display()))?;

    // Pre-flight: an obviously broken file never leaves the machine.
    let local = diagnose(&body, root);
    if !local.is_empty() {
        eprintln!("service-kicad: {} is not a loadable .{root} file:", req.file.display());
        for d in &local { eprintln!("  - {d}"); }
        eprintln!("{}", json!({"level":"error","code":"INPUT_UNLOADABLE","diagnostics":local}));
        return Ok(1);
    }

    let resp = client.post(format!("{base_url}{}", req.endpoint)).body(body.clone()).send().unwrap_or_else(|e| {
        eprintln!("service-kicad: unreachable ({e})");
        std::process::exit(2);
    });
    let status = resp.status();
    let bytes = resp.bytes().map(|b| b.to_vec()).unwrap_or_default();
    if !status.is_success() {
        let text = String::from_utf8_lossy(&bytes).into_owned();
        eprintln!("service-kicad: {status} — {}", text.trim_end());
        if status.as_u16() == 400 && text.contains("Failed to load") {
            eprintln!("{}", json!({"level":"error","code":"INPUT_UNLOADABLE","hint":
                format!("kicad-cli could not load the file; local structure looks fine, so the failure is semantic (unknown lib_id, unsupported format version, bad token). Open it in KiCad {} for the parser message.", "10")}));
            return Ok(1);
        }
        return Ok(super::http_error_exit_code(status.as_u16()));
    }

    let mut report: Value = match serde_json::from_slice(&bytes) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("service-kicad: service returned non-JSON for {} ({e}); raw bytes follow", req.endpoint);
            super::write_or_stdout(&bytes, req.out)?;
            return Ok(4);
        }
    };

    let mut summary = Summary::from_report(req.kind, &report);
    let scale = (req.kind == ReportKind::Erc).then(|| check_erc_scale(&body, &report));
    if let Some(s) = &scale {
        if s.status == "scaled" {
            let f = s.factor.unwrap_or(1.0);
            summary.hints.insert(0, Hint { level: "warning", code: "ERC_COORDINATES_SCALED", message: format!(
                "item positions are {f:.0}x smaller than the source schematic: multiply pos.x/pos.y by {f:.0} to get {} (KiCad {} writes ERC JSON positions with the PCB unit scale; upstream eeschema/erc/erc_report.cpp WriteJsonReport). Raw report preserved, nothing rescaled. {}",
                summary.coordinate_units.as_deref().unwrap_or("mm"),
                summary.kicad_version.as_deref().unwrap_or("?"), s.reason) });
        } else if s.status == "not_checked" {
            summary.hints.push(Hint { level: "info", code: "ERC_COORDINATES_UNCHECKED", message: format!(
                "could not cross-check item positions against the source: {}", s.reason) });
        }
    }

    let input = json!({
        "path": req.file.display().to_string(),
        "bytes": body.len(),
        "sha256": format!("{:x}", Sha256::digest(&body)),
    });
    let service = client.get(format!("{base_url}/version")).send().ok()
        .filter(|r| r.status().is_success())
        .and_then(|r| r.json::<Value>().ok())
        .unwrap_or(Value::Null);

    let exit_code = if req.exit_zero { 0 } else { summary.exit_code };
    let mut adom_summary = serde_json::to_value(&summary)?;
    adom_summary["exit_code"] = json!(exit_code);
    adom_summary["input"] = input;
    adom_summary["service"] = service.clone();
    if let Some(s) = &scale { adom_summary["erc_scale"] = serde_json::to_value(s)?; }

    match (req.format, req.out) {
        ("native", out) => super::write_or_stdout(&bytes, out)?,
        ("json", out) => {
            report["adom_summary"] = adom_summary.clone();
            let mut pretty = serde_json::to_vec_pretty(&report)?;
            pretty.push(b'\n');
            super::write_or_stdout(&pretty, out)?;
        }
        (_, Some(out)) => {
            // text on the terminal, augmented JSON in the file
            report["adom_summary"] = adom_summary.clone();
            let mut pretty = serde_json::to_vec_pretty(&report)?;
            pretty.push(b'\n');
            super::write_or_stdout(&pretty, Some(out))?;
            print_text(&summary, &Summary::findings(req.kind, &report), &adom_summary)?;
        }
        (_, None) => print_text(&summary, &Summary::findings(req.kind, &report), &adom_summary)?,
    }
    if req.exit_zero && summary.exit_code != 0 {
        eprintln!("service-kicad: --exit-zero: {} findings present but exiting 0 as requested", summary.counts.errors);
    }
    Ok(exit_code)
}

/// `service-kicad pcb drc x | head` must not turn into an error exit.
fn print_text(summary: &Summary, findings: &[Finding<'_>], adom: &Value) -> Result<()> {
    match print_text_inner(summary, findings, adom) {
        Err(e) if e.downcast_ref::<std::io::Error>().map(|io| io.kind() == std::io::ErrorKind::BrokenPipe).unwrap_or(false) => Ok(()),
        r => r,
    }
}

fn print_text_inner(summary: &Summary, findings: &[Finding<'_>], adom: &Value) -> Result<()> {
    let mut o = std::io::stdout().lock();
    let label = summary.kind.label();
    let verdict = match summary.status {
        "failed" => "FAILED",
        "passed_with_warnings" => "PASSED with warnings",
        "passed" => "PASSED",
        _ => "UNKNOWN",
    };
    let collections: Vec<String> = summary.collections.iter().map(|(name, c)| {
        if c.present { format!("{name} {}", c.counts.total) } else { format!("{name} absent") }
    }).collect();
    writeln!(o, "{label} {verdict}: {} errors, {} warnings{}  ({}; {} checks ignored)",
        summary.counts.errors, summary.counts.warnings,
        if summary.counts.exclusions > 0 { format!(", {} excluded", summary.counts.exclusions) } else { String::new() },
        collections.join(" · "), summary.ignored_checks.len())?;
    let sha = adom.pointer("/input/sha256").and_then(Value::as_str).map(|s| &s[..12]).unwrap_or("?");
    let svc = match (adom.pointer("/service/version").and_then(Value::as_str), adom.pointer("/service/git_sha_short").and_then(Value::as_str)) {
        (Some(v), Some(g)) => format!("service {v} @{g}"),
        _ => "service version unknown".to_string(),
    };
    writeln!(o, "kicad {} · units {} · input {} ({} B, sha256 {sha}…) · {svc}",
        summary.kicad_version.as_deref().unwrap_or("?"),
        summary.coordinate_units.as_deref().unwrap_or("?"),
        adom.pointer("/input/path").and_then(Value::as_str).unwrap_or("?"),
        adom.pointer("/input/bytes").and_then(Value::as_u64).unwrap_or(0))?;
    // Warnings about the report itself (scaled coordinates, missing
    // collections) go ABOVE the findings they qualify.
    for h in summary.hints.iter().filter(|h| h.level == "warning") {
        writeln!(o, "hint [{}]: {}", h.code, h.message)?;
    }
    for f in findings.iter().take(40) {
        let pos = f.pos.map(|(x, y)| format!("  @ ({x}, {y})")).unwrap_or_default();
        let sheet = f.sheet.filter(|s| *s != "/").map(|s| format!(" [{s}]")).unwrap_or_default();
        let coll = if f.collection == "violations" { String::new() } else { format!(" ({})", f.collection) };
        writeln!(o, "  [{}] {}{coll}{sheet}: {}{pos}", f.severity, f.kind, f.description)?;
        if let Some(item) = f.first_item { writeln!(o, "        {item}")?; }
    }
    if findings.len() > 40 { writeln!(o, "  … {} more (use --format json)", findings.len() - 40)?; }
    for h in summary.hints.iter().filter(|h| h.level != "warning") {
        writeln!(o, "hint [{}]: {}", h.code, h.message)?;
    }
    Ok(())
}