master
John Lauer Merge PR #99: Add complete-project DRC with input hashes and explicit rule probes 2b18ad1 1mo ago
//! 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());
    }
}