app
service-kicad
Public Made by Adomby adom
Shared headless KiCad 10: DRC, ERC, SVG/Gerber/STEP export, Altium library conversion, symbol/footprint/3D-model lookup. Every Adom tool shells to this CLI instead of calling the HTTP API directly.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
//! 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");
}
}