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
Merge PR #99: Add complete-project DRC with input hashes and explicit rule probes
2b18ad1
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
//! Project-aware DRC; kept separate from the legacy single-board endpoint.
use axum::{
body::Bytes,
extract::Query,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::{path::Path, sync::OnceLock, time::Duration};
use tokio::{process::Command, sync::Semaphore};
#[derive(Deserialize)]
pub struct Options {
pub board: String,
#[serde(default = "require_rules_default")]
pub require_rules: bool,
pub expected_rule: Option<String>,
}
fn require_rules_default() -> bool {
true
}
fn failure(status: StatusCode, code: &str, message: impl ToString) -> Response {
(status, Json(json!({"ok":false,"errorCode":code,"error":message.to_string(),"hints":["No fallback to single-file/default-rule DRC occurred. Inspect the selected project and custom rules."]}))).into_response()
}
pub async fn run(Query(options): Query<Options>, body: Bytes) -> Response {
static JOBS: OnceLock<Semaphore> = OnceLock::new();
let Ok(_permit) = JOBS.get_or_init(|| Semaphore::new(2)).try_acquire() else {
return failure(
StatusCode::TOO_MANY_REQUESTS,
"project_drc_busy",
"Two project jobs are already running; retry this request later.",
);
};
run_with_executable(options, body, &super::kicad_cli()).await
}
async fn run_with_executable(options: Options, body: Bytes, executable: &str) -> Response {
let project = match service_kicad_project::unpack(&body, &options.board, options.require_rules)
{
Ok(p) => p,
Err(e) => return failure(StatusCode::BAD_REQUEST, "invalid_project", e),
};
let output_file = match tempfile::NamedTempFile::with_suffix(".json") {
Ok(f) => f,
Err(e) => return failure(StatusCode::INTERNAL_SERVER_ERROR, "output_create_failed", e),
};
let native = match native_command(
executable,
project.directory.path(),
&project.board,
output_file.path(),
)
.await
{
Ok(v) => v,
Err(e) => return failure(StatusCode::BAD_REQUEST, "native_drc_failed", e),
};
let report: Value = match std::fs::read(output_file.path())
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
{
Some(v) => v,
None => {
return failure(
StatusCode::BAD_GATEWAY,
"invalid_native_report",
"KiCad did not produce valid JSON",
)
}
};
let Some(violations) = report.get("violations").and_then(Value::as_array) else {
return failure(
StatusCode::BAD_GATEWAY,
"invalid_native_report",
"Native JSON lacks the violations array",
);
};
let expected_observed = options.expected_rule.as_ref().map(|name| {
!name.trim().is_empty()
&& violations.iter().any(|v| {
v.get("description")
.and_then(Value::as_str)
.is_some_and(|d| d.contains(name))
})
});
let hints = if project.rules.is_some() {
vec!["Project and custom-rule files were supplied at the matching board stem. Input hashes alone do not prove each constraint was enforced; use named negative-control cases."]
} else {
vec!["Explicit no-custom-rules mode: project settings were supplied; custom-rule enforcement is not claimed."]
};
let result = json!({
"ok":expected_observed!=Some(false),
"operation":"pcb.drc-project",
"serviceVersion":env!("CARGO_PKG_VERSION"),"serviceGitSha":super::GIT_SHA,
"nativeKicadVersion":report.get("kicad_version"),
"board":options.board,"project":project.project.to_string_lossy(),
"customRules":project.rules.as_ref().map(|p|p.to_string_lossy()),
"inputManifest":project.files,"nativeStdout":native.0,"nativeStderr":native.1,
"expectedRule":options.expected_rule,"expectedRuleObservedInViolation":expected_observed,
"report":report,"hints":hints,
"errorCode":if expected_observed==Some(false){Some("expected_rule_not_observed")}else{None},
});
(
if expected_observed == Some(false) {
StatusCode::UNPROCESSABLE_ENTITY
} else {
StatusCode::OK
},
Json(result),
)
.into_response()
}
/// Argument vectors, selected project cwd and a bounded child lifetime; no shell.
async fn native_command(
executable: &str,
directory: &Path,
board: &Path,
out: &Path,
) -> anyhow::Result<(String, String)> {
let model_dir = super::dir_or_default("KICAD_PACKAGES3D_DIR", "/usr/share/kicad/3dmodels");
let mut cmd = Command::new(executable);
cmd.current_dir(directory)
.arg("pcb")
.arg("drc")
.arg(board)
.arg("-o")
.arg(out)
.args(["--format", "json"])
.kill_on_drop(true);
for version in 6..=10 {
cmd.env(format!("KICAD{version}_3DMODEL_DIR"), &model_dir);
}
let result = tokio::time::timeout(Duration::from_secs(110), cmd.output())
.await
.map_err(|_| anyhow::anyhow!("native_project_drc_timeout: 110 seconds"))??;
let stdout = String::from_utf8_lossy(&result.stdout).into_owned();
let stderr = String::from_utf8_lossy(&result.stderr).into_owned();
if !result.status.success() {
anyhow::bail!(
"native exit {:?}; stderr: {}; stdout: {}",
result.status.code(),
stderr,
stdout
);
}
Ok((stdout, stderr))
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::{fs, os::unix::fs::PermissionsExt};
fn fixture() -> (tempfile::TempDir, Vec<u8>) {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("board with spaces.kicad_pcb"), "(board)").unwrap();
fs::write(dir.path().join("board with spaces.kicad_pro"), "{}").unwrap();
fs::write(
dir.path().join("board with spaces.kicad_dru"),
"(version 1)\n(rule named-negative-control (constraint disallow via))",
)
.unwrap();
let bytes =
service_kicad_project::pack(dir.path(), "board with spaces.kicad_pcb", &[], true)
.unwrap();
(dir, bytes)
}
fn stub(dir: &Path, name: &str, script: &str) -> String {
let path = dir.join(name);
fs::write(&path, script).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
path.to_str().unwrap().into()
}
fn options(expected: Option<&str>) -> Options {
Options {
board: "board with spaces.kicad_pcb".into(),
require_rules: true,
expected_rule: expected.map(Into::into),
}
}
async fn value(response: Response) -> Value {
serde_json::from_slice(
&axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap(),
)
.unwrap()
}
#[tokio::test]
async fn subprocess_receives_same_stem_project_and_reports_exact_inputs() {
let (dir, bytes) = fixture();
let exe = stub(
dir.path(),
"fake-kicad",
r#"#!/bin/sh
set -eu
[ "$1" = pcb ] && [ "$2" = drc ]
[ -f "${3%.kicad_pcb}.kicad_pro" ]
[ -f "${3%.kicad_pcb}.kicad_dru" ]
cat > "$5" <<'JSON'
{"kicad_version":"test-double-not-native","violations":[{"severity":"error","type":"disallowed","description":"named-negative-control"}]}
JSON
"#,
);
let response =
run_with_executable(options(Some("named-negative-control")), bytes.into(), &exe).await;
assert_eq!(response.status(), StatusCode::OK);
let v = value(response).await;
assert_eq!(v["inputManifest"].as_array().unwrap().len(), 3);
assert_eq!(v["expectedRuleObservedInViolation"], true);
assert_eq!(v["nativeKicadVersion"], "test-double-not-native");
}
#[tokio::test]
async fn malformed_native_rules_are_errors_without_default_fallback() {
let (dir, bytes) = fixture();
let exe = stub(
dir.path(),
"error-kicad",
"#!/bin/sh\nprintf 'Error parsing custom rules' >&2\nexit 2\n",
);
let response = run_with_executable(options(None), bytes.into(), &exe).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let v = value(response).await;
assert_eq!(v["errorCode"], "native_drc_failed");
assert!(v["error"]
.as_str()
.unwrap()
.contains("Error parsing custom rules"));
}
#[tokio::test]
async fn missing_expected_rule_is_not_a_successful_validation() {
let (dir, bytes) = fixture();
let exe = stub(
dir.path(),
"empty-kicad",
"#!/bin/sh\necho '{\"kicad_version\":\"test-double\",\"violations\":[]}' > \"$5\"\n",
);
let response =
run_with_executable(options(Some("named-negative-control")), bytes.into(), &exe).await;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let v = value(response).await;
assert_eq!(v["errorCode"], "expected_rule_not_observed");
assert!(v["report"]["violations"].is_array());
}
}