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
//! The count policy for native kicad-cli DRC / ERC JSON.
//!
//! kicad-cli's JSON (`https://schemas.kicad.org/drc.v1.json`,
//! `erc.v1.json`) has NO precomputed totals. Every count here is derived by
//! walking the finding collections and bucketing on each finding's
//! `severity`:
//!
//! - DRC collections: `violations`, `unconnected_items`, `schematic_parity`.
//! - ERC collections: every `sheets[*].violations`.
//! - severities: `error` and `warning` count; `exclusion` (user-excluded
//!   markers) is reported but never counted toward a pass/fail; anything
//!   else lands in `other` and is reported.
//!
//! Status is stated explicitly: `failed` when any error-severity finding
//! exists in any collection, `passed_with_warnings`, `passed`, or `unknown`
//! when the report lacks the collections the schema promises. HTTP success
//! is never a pass.
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ReportKind { Drc, Erc }

impl ReportKind {
    pub fn label(self) -> &'static str { match self { ReportKind::Drc => "DRC", ReportKind::Erc => "ERC" } }
}

#[derive(Debug, Clone, Default, Serialize, PartialEq)]
pub struct Counts {
    pub errors: usize,
    pub warnings: usize,
    pub exclusions: usize,
    pub other: usize,
    pub total: usize,
}

impl Counts {
    fn add(&mut self, severity: Option<&str>) {
        self.total += 1;
        match severity {
            Some("error") => self.errors += 1,
            Some("warning") => self.warnings += 1,
            Some("exclusion") => self.exclusions += 1,
            _ => self.other += 1,
        }
    }
    fn merge(&mut self, o: &Counts) {
        self.errors += o.errors; self.warnings += o.warnings; self.exclusions += o.exclusions;
        self.other += o.other; self.total += o.total;
    }
}

#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct Hint {
    pub level: &'static str,
    pub code: &'static str,
    pub message: String,
}

/// Exit code the CLI uses when a report completed and carries
/// error-severity findings. Distinct from 1..4 (input / transport /
/// auth / service failures) so callers can tell "DRC ran and failed"
/// from "DRC could not run".
pub const EXIT_FINDINGS: i32 = 5;

#[derive(Debug, Clone, Serialize)]
pub struct Summary {
    pub kind: ReportKind,
    /// `failed` | `passed_with_warnings` | `passed` | `unknown`
    pub status: &'static str,
    /// `true` only when status is `passed` or `passed_with_warnings`.
    pub passed: bool,
    #[serde(flatten)]
    pub counts: Counts,
    /// Per-collection counts, in schema order. A collection the schema
    /// promises but the report lacks is listed with `present: false`.
    pub collections: BTreeMap<String, CollectionCounts>,
    /// ERC only: number of sheets in the report.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sheets: Option<usize>,
    pub kicad_version: Option<String>,
    pub coordinate_units: Option<String>,
    pub included_severities: Vec<String>,
    /// Keys of checks kicad-cli reports as ignored (not run). A pass says
    /// nothing about these.
    pub ignored_checks: Vec<String>,
    pub count_policy: &'static str,
    pub hints: Vec<Hint>,
    pub exit_code: i32,
}

#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct CollectionCounts {
    pub present: bool,
    #[serde(flatten)]
    pub counts: Counts,
}

pub const COUNT_POLICY: &str = "counts derived from every native finding by severity; error => failed; exclusion never counts; a missing collection => unknown; HTTP success is not a pass";

fn count_array(v: Option<&Value>) -> Option<Counts> {
    let arr = v?.as_array()?;
    let mut c = Counts::default();
    for item in arr {
        c.add(item.get("severity").and_then(Value::as_str));
    }
    Some(c)
}

fn strings(v: Option<&Value>, key: Option<&str>) -> Vec<String> {
    v.and_then(Value::as_array).map(|a| a.iter().filter_map(|x| match key {
        Some(k) => x.get(k).and_then(Value::as_str).map(str::to_string),
        None => x.as_str().map(str::to_string),
    }).collect()).unwrap_or_default()
}

impl Summary {
    pub fn from_report(kind: ReportKind, report: &Value) -> Summary {
        let mut collections = BTreeMap::new();
        let mut counts = Counts::default();
        let mut hints = Vec::new();
        let mut missing = Vec::new();
        let mut sheets = None;

        match kind {
            ReportKind::Drc => {
                for name in ["violations", "unconnected_items", "schematic_parity"] {
                    match count_array(report.get(name)) {
                        Some(c) => { counts.merge(&c); collections.insert(name.to_string(), CollectionCounts { present: true, counts: c }); }
                        None => { missing.push(name); collections.insert(name.to_string(), CollectionCounts { present: false, counts: Counts::default() }); }
                    }
                }
            }
            ReportKind::Erc => {
                match report.get("sheets").and_then(Value::as_array) {
                    Some(list) => {
                        sheets = Some(list.len());
                        let mut c = Counts::default();
                        for sheet in list {
                            match count_array(sheet.get("violations")) {
                                Some(sc) => c.merge(&sc),
                                None => missing.push("sheets[].violations"),
                            }
                        }
                        counts.merge(&c);
                        collections.insert("violations".to_string(), CollectionCounts { present: missing.is_empty(), counts: c });
                    }
                    None => { missing.push("sheets"); collections.insert("violations".to_string(), CollectionCounts { present: false, counts: Counts::default() }); }
                }
            }
        }
        missing.dedup();

        let status = if !missing.is_empty() && counts.total == 0 {
            "unknown"
        } else if counts.errors > 0 {
            "failed"
        } else if counts.warnings > 0 {
            "passed_with_warnings"
        } else {
            "passed"
        };
        if !missing.is_empty() {
            hints.push(Hint { level: "warning", code: "REPORT_COLLECTION_MISSING", message: format!(
                "native report lacks {}; counts cover only the collections present and the {} status is {}",
                missing.join(", "), kind.label(), status) });
        }
        if counts.other > 0 {
            hints.push(Hint { level: "warning", code: "UNKNOWN_SEVERITY", message: format!(
                "{} finding(s) carry a severity other than error/warning/exclusion; they are listed but not counted toward pass/fail", counts.other) });
        }
        let ignored_checks = strings(report.get("ignored_checks"), Some("key"));
        if status != "failed" && !ignored_checks.is_empty() {
            hints.push(Hint { level: "info", code: "CHECKS_IGNORED", message: format!(
                "{} check(s) were not run ({}); a {} pass does not cover them",
                ignored_checks.len(), ignored_checks.join(", "), kind.label()) });
        }
        let exit_code = if counts.errors > 0 { EXIT_FINDINGS } else { 0 };
        if counts.errors > 0 {
            hints.push(Hint { level: "error", code: match kind { ReportKind::Drc => "DRC_FAILED", ReportKind::Erc => "ERC_FAILED" }, message: format!(
                "{} error-severity finding(s); the report was produced but the {} did not pass; exit code {}",
                counts.errors, kind.label(), EXIT_FINDINGS) });
        }
        Summary {
            kind, status, passed: matches!(status, "passed" | "passed_with_warnings"), counts, collections, sheets,
            kicad_version: report.get("kicad_version").and_then(Value::as_str).map(str::to_string),
            coordinate_units: report.get("coordinate_units").and_then(Value::as_str).map(str::to_string),
            included_severities: strings(report.get("included_severities"), None),
            ignored_checks, count_policy: COUNT_POLICY, hints, exit_code,
        }
    }

    /// Every finding across every collection, flattened, in report order,
    /// with the collection (and ERC sheet path) it came from.
    pub fn findings<'a>(kind: ReportKind, report: &'a Value) -> Vec<Finding<'a>> {
        let mut out = Vec::new();
        let push = |out: &mut Vec<Finding<'a>>, collection: &'static str, sheet: Option<&'a str>, arr: Option<&'a Value>| {
            for v in arr.and_then(Value::as_array).into_iter().flatten() {
                let first = v.get("items").and_then(Value::as_array).and_then(|a| a.first());
                out.push(Finding {
                    collection, sheet,
                    severity: v.get("severity").and_then(Value::as_str).unwrap_or("?"),
                    kind: v.get("type").and_then(Value::as_str).unwrap_or("?"),
                    description: v.get("description").or_else(|| v.get("message")).and_then(Value::as_str).unwrap_or(""),
                    first_item: first.and_then(|i| i.get("description")).and_then(Value::as_str),
                    pos: first.and_then(|i| i.get("pos")).and_then(|p| Some((p.get("x")?.as_f64()?, p.get("y")?.as_f64()?))),
                });
            }
        };
        match kind {
            ReportKind::Drc => {
                push(&mut out, "violations", None, report.get("violations"));
                push(&mut out, "unconnected_items", None, report.get("unconnected_items"));
                push(&mut out, "schematic_parity", None, report.get("schematic_parity"));
            }
            ReportKind::Erc => {
                for sheet in report.get("sheets").and_then(Value::as_array).into_iter().flatten() {
                    push(&mut out, "violations", sheet.get("path").and_then(Value::as_str), sheet.get("violations"));
                }
            }
        }
        out
    }
}

#[derive(Debug, Clone)]
pub struct Finding<'a> {
    pub collection: &'static str,
    pub sheet: Option<&'a str>,
    pub severity: &'a str,
    pub kind: &'a str,
    pub description: &'a str,
    pub first_item: Option<&'a str>,
    pub pos: Option<(f64, f64)>,
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn fixture(name: &str) -> Value {
        let p = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/");
        serde_json::from_str(&std::fs::read_to_string(format!("{p}{name}")).unwrap()).unwrap()
    }

    #[test]
    fn issue_3_native_drc_counts_come_from_violations() {
        let s = Summary::from_report(ReportKind::Drc, &fixture("drc-native-clearance.json"));
        assert_eq!((s.counts.errors, s.counts.warnings, s.counts.total), (2, 1, 3));
        assert_eq!(s.status, "failed");
        assert!(!s.passed);
        assert_eq!(s.exit_code, EXIT_FINDINGS);
        assert_eq!(s.kicad_version.as_deref(), Some("10.0.2"));
        assert_eq!(s.coordinate_units.as_deref(), Some("mm"));
        assert_eq!(s.ignored_checks.len(), 5);
        assert!(s.collections["unconnected_items"].present);
        assert!(s.hints.iter().any(|h| h.code == "DRC_FAILED"));
    }

    #[test]
    fn unconnected_and_parity_count_too() {
        let r = json!({"violations": [], "unconnected_items": [{"severity":"error"}], "schematic_parity": [{"severity":"warning"}, {"severity":"exclusion"}]});
        let s = Summary::from_report(ReportKind::Drc, &r);
        assert_eq!((s.counts.errors, s.counts.warnings, s.counts.exclusions, s.counts.total), (1, 1, 1, 3));
        assert_eq!(s.status, "failed");
    }

    #[test]
    fn exclusions_never_fail_a_board() {
        let r = json!({"violations": [{"severity":"exclusion"}], "unconnected_items": [], "schematic_parity": []});
        let s = Summary::from_report(ReportKind::Drc, &r);
        assert_eq!(s.status, "passed");
        assert!(s.passed);
        assert_eq!(s.exit_code, 0);
    }

    #[test]
    fn warnings_only_pass_with_warnings() {
        let r = json!({"violations": [{"severity":"warning"}], "unconnected_items": [], "schematic_parity": []});
        let s = Summary::from_report(ReportKind::Drc, &r);
        assert_eq!(s.status, "passed_with_warnings");
        assert_eq!(s.exit_code, 0);
    }

    #[test]
    fn missing_collections_are_unknown_not_passed() {
        let s = Summary::from_report(ReportKind::Drc, &json!({"kicad_version": "10.0.2"}));
        assert_eq!(s.status, "unknown");
        assert!(!s.passed);
        assert!(s.hints.iter().any(|h| h.code == "REPORT_COLLECTION_MISSING"));
        assert!(!s.collections["violations"].present);
    }

    #[test]
    fn erc_counts_span_sheets() {
        let s = Summary::from_report(ReportKind::Erc, &fixture("erc-native-scaled.json"));
        assert_eq!(s.sheets, Some(1));
        assert!(s.counts.errors > 0, "{s:?}");
        assert_eq!(s.status, "failed");
        let multi = json!({"sheets": [{"violations": [{"severity":"warning"}]}, {"violations": [{"severity":"error"}]}]});
        let m = Summary::from_report(ReportKind::Erc, &multi);
        assert_eq!((m.counts.errors, m.counts.warnings, m.sheets), (1, 1, Some(2)));
    }

    #[test]
    fn findings_flatten_with_positions() {
        let report = fixture("drc-native-clearance.json");
        let f = Summary::findings(ReportKind::Drc, &report);
        assert_eq!(f.len(), 3);
        assert_eq!(f[0].kind, "clearance");
        assert_eq!(f[0].pos, Some((9.45, 9.5)));
        assert_eq!(f[2].severity, "warning");
    }
}