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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
//! `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(())
}