Skip to main content

zagens_runtime/cli/handlers/
coverage_gate.rs

1//! L3: `zagens coverage-gate` — cross-platform Layer-2 completion gate.
2//!
3//! Replaces the PowerShell check scripts with a portable Rust implementation.
4//!
5//! ## Exit codes
6//!
7//! | Code | Meaning |
8//! |------|---------|
9//! | 0    | All gate checks passed |
10//! | 1    | One or more checks failed (unless `--no-fail`) |
11//! | 2    | Configuration error (bad args, missing workspace, etc.) |
12//!
13//! ## Checks performed
14//!
15//! 1. **fmt**  — `cargo fmt --check`
16//! 2. **clippy** — `cargo clippy -- -D warnings`
17//! 3. **compile** — `cargo test --no-run`
18//! 4. **tests** — `cargo test` *(only when `--run-tests` is set)*
19//! 5. **checklist** — all todo items in `.zagens/todo.json` are completed
20//!    *(only when `--require-checklist-complete`)*
21//! 6. **craft** — last CRAFT task in `.zagens/craft-ab-metrics.jsonl` has
22//!    `terminal_verdict == "PASS"` *(when a `task_id` is provided)*
23
24use std::path::{Path, PathBuf};
25use std::process::{Command, ExitCode};
26use std::time::Instant;
27
28use anyhow::{Result, bail};
29use serde::Serialize;
30
31use crate::cli::args::CoverageGateArgs;
32
33/// Result of a single gate check.
34#[derive(Debug, Clone, Serialize)]
35pub struct GateCheck {
36    pub name: &'static str,
37    pub passed: bool,
38    pub output: String,
39    pub duration_ms: u64,
40}
41
42impl GateCheck {
43    fn pass(name: &'static str, duration_ms: u64) -> Self {
44        Self {
45            name,
46            passed: true,
47            output: String::new(),
48            duration_ms,
49        }
50    }
51    fn fail(name: &'static str, output: impl Into<String>, duration_ms: u64) -> Self {
52        Self {
53            name,
54            passed: false,
55            output: output.into(),
56            duration_ms,
57        }
58    }
59}
60
61/// Full gate report.
62#[derive(Debug, Serialize)]
63pub struct GateReport {
64    pub checks: Vec<GateCheck>,
65    pub passed: bool,
66    pub total_ms: u64,
67}
68
69impl GateReport {
70    pub fn print_human(&self) {
71        let symbol = |ok: bool| if ok { "✓" } else { "✗" };
72        for c in &self.checks {
73            println!("{} {} ({}ms)", symbol(c.passed), c.name, c.duration_ms);
74            if !c.passed && !c.output.is_empty() {
75                for line in c.output.lines().take(20) {
76                    println!("  {line}");
77                }
78            }
79        }
80        println!();
81        if self.passed {
82            println!("✓ All gate checks passed ({} ms)", self.total_ms);
83        } else {
84            let failures: Vec<_> = self.checks.iter().filter(|c| !c.passed).collect();
85            println!(
86                "✗ {} check(s) failed — gate NOT passed ({} ms)",
87                failures.len(),
88                self.total_ms
89            );
90        }
91    }
92}
93
94/// Run a cargo command and return (passed, trimmed_output).
95fn run_cargo(workspace: &Path, args: &[&str]) -> (bool, String) {
96    let started = Instant::now();
97    let Ok(out) = Command::new("cargo")
98        .args(args)
99        .current_dir(workspace)
100        .output()
101    else {
102        return (false, "cargo not found in PATH".to_string());
103    };
104    let _ = started.elapsed(); // timing handled at call site
105    let success = out.status.success();
106    let text = if success {
107        String::new()
108    } else {
109        let stderr = String::from_utf8_lossy(&out.stderr);
110        let stdout = String::from_utf8_lossy(&out.stdout);
111        format!("{stderr}{stdout}")
112            .lines()
113            .take(60)
114            .collect::<Vec<_>>()
115            .join("\n")
116    };
117    (success, text)
118}
119
120fn check_cargo(workspace: &Path, name: &'static str, args: &[&str]) -> GateCheck {
121    let t = Instant::now();
122    let (ok, out) = run_cargo(workspace, args);
123    let ms = t.elapsed().as_millis() as u64;
124    if ok {
125        GateCheck::pass(name, ms)
126    } else {
127        GateCheck::fail(name, out, ms)
128    }
129}
130
131fn check_checklist(workspace: &Path) -> GateCheck {
132    let t = Instant::now();
133    // Read `.zagens/todo.json`
134    let todo_path_new = workspace.join(".zagens").join("todo.json");
135    let todo_path_legacy = workspace.join(".deepseek").join("todo.json");
136    let todo_path = if todo_path_new.exists() {
137        todo_path_new
138    } else if todo_path_legacy.exists() {
139        todo_path_legacy
140    } else {
141        return GateCheck::pass("checklist", t.elapsed().as_millis() as u64);
142    };
143
144    let Ok(raw) = std::fs::read_to_string(&todo_path) else {
145        let ms = t.elapsed().as_millis() as u64;
146        return GateCheck::fail("checklist", "cannot read todo.json", ms);
147    };
148
149    let Ok(val) = serde_json::from_str::<serde_json::Value>(&raw) else {
150        let ms = t.elapsed().as_millis() as u64;
151        return GateCheck::fail("checklist", "todo.json is not valid JSON", ms);
152    };
153
154    let items = val
155        .as_array()
156        .or_else(|| val.get("items").and_then(|v| v.as_array()));
157
158    let Some(items) = items else {
159        return GateCheck::pass("checklist", t.elapsed().as_millis() as u64);
160    };
161
162    let pending: Vec<String> = items
163        .iter()
164        .filter(|item| {
165            let status = item
166                .get("status")
167                .and_then(|s| s.as_str())
168                .unwrap_or("pending");
169            status == "pending" || status == "in_progress"
170        })
171        .filter_map(|item| {
172            item.get("content")
173                .and_then(|c| c.as_str())
174                .map(str::to_string)
175        })
176        .collect();
177
178    let ms = t.elapsed().as_millis() as u64;
179    if pending.is_empty() {
180        GateCheck::pass("checklist", ms)
181    } else {
182        let summary = pending
183            .iter()
184            .take(5)
185            .map(|s| format!("  - {s}"))
186            .collect::<Vec<_>>()
187            .join("\n");
188        GateCheck::fail(
189            "checklist",
190            format!("{} pending item(s):\n{summary}", pending.len()),
191            ms,
192        )
193    }
194}
195
196fn check_craft_verdict(workspace: &Path, task_id: Option<&str>) -> Option<GateCheck> {
197    let t = Instant::now();
198    let metrics_path = workspace.join(".zagens").join("craft-ab-metrics.jsonl");
199    let Ok(raw) = std::fs::read_to_string(&metrics_path) else {
200        return None; // no metrics file — skip check
201    };
202
203    let records: Vec<serde_json::Value> = raw
204        .lines()
205        .filter(|l| !l.trim().is_empty())
206        .filter_map(|l| serde_json::from_str(l).ok())
207        .collect();
208
209    if records.is_empty() {
210        return None;
211    }
212
213    // Find the relevant record
214    let record = if let Some(tid) = task_id {
215        records
216            .iter()
217            .rev()
218            .find(|r| r.get("task_id").and_then(|v| v.as_str()) == Some(tid))
219    } else {
220        records.last()
221    };
222
223    let record = record?;
224
225    let ms = t.elapsed().as_millis() as u64;
226    let verdict = record
227        .get("terminal_verdict")
228        .and_then(|v| v.as_str())
229        .unwrap_or("UNKNOWN");
230    if verdict == "PASS" {
231        Some(GateCheck::pass("craft-verdict", ms))
232    } else {
233        Some(GateCheck::fail(
234            "craft-verdict",
235            format!("terminal_verdict = {verdict} (expected PASS)"),
236            ms,
237        ))
238    }
239}
240
241/// Run all gate checks and return the report.
242pub fn run_gate(workspace: &Path, args: &CoverageGateArgs) -> GateReport {
243    let global_t = Instant::now();
244    let mut checks = Vec::new();
245
246    // fmt
247    checks.push(check_cargo(workspace, "fmt", &["fmt", "--check"]));
248
249    // clippy
250    checks.push(check_cargo(
251        workspace,
252        "clippy",
253        &["clippy", "--", "-D", "warnings"],
254    ));
255
256    // compile (test --no-run)
257    checks.push(check_cargo(
258        workspace,
259        "compile",
260        &["test", "--no-run", "--message-format=short"],
261    ));
262
263    // tests (optional, slow)
264    if args.run_tests {
265        checks.push(check_cargo(workspace, "tests", &["test"]));
266    }
267
268    // checklist
269    if args.require_checklist_complete {
270        checks.push(check_checklist(workspace));
271    }
272
273    // craft verdict
274    if let Some(check) = check_craft_verdict(workspace, args.task_id.as_deref()) {
275        checks.push(check);
276    }
277
278    let passed = checks.iter().all(|c| c.passed);
279    GateReport {
280        checks,
281        passed,
282        total_ms: global_t.elapsed().as_millis() as u64,
283    }
284}
285
286/// Entry point for `zagens coverage-gate`.
287pub fn run(args: CoverageGateArgs) -> Result<ExitCode> {
288    let workspace: PathBuf = args
289        .workspace
290        .clone()
291        .or_else(|| std::env::current_dir().ok())
292        .ok_or_else(|| anyhow::anyhow!("cannot determine workspace directory"))?;
293
294    if !workspace.is_dir() {
295        bail!("workspace {:?} does not exist", workspace);
296    }
297
298    let report = run_gate(&workspace, &args);
299
300    if args.json {
301        println!("{}", serde_json::to_string_pretty(&report)?);
302    } else {
303        report.print_human();
304    }
305
306    if report.passed || args.no_fail {
307        Ok(ExitCode::SUCCESS)
308    } else {
309        Ok(ExitCode::FAILURE)
310    }
311}
312
313// ── Tests ────────────────────────────────────────────────────────────────────
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use tempfile::TempDir;
319
320    fn tmp_workspace() -> TempDir {
321        tempfile::Builder::new()
322            .prefix("cov-gate-test-")
323            .tempdir()
324            .expect("tempdir")
325    }
326
327    fn default_args(workspace: &std::path::Path) -> CoverageGateArgs {
328        CoverageGateArgs {
329            workspace: Some(workspace.to_path_buf()),
330            require_checklist_complete: false,
331            run_tests: false,
332            json: false,
333            task_id: None,
334            no_fail: false,
335        }
336    }
337
338    #[test]
339    fn checklist_pass_when_all_done() {
340        let dir = tmp_workspace();
341        let ws = dir.path().to_path_buf();
342        std::fs::create_dir_all(ws.join(".zagens")).unwrap();
343        std::fs::write(
344            ws.join(".zagens/todo.json"),
345            r#"[{"id":1,"content":"task","status":"completed"}]"#,
346        )
347        .unwrap();
348
349        let check = check_checklist(&ws);
350        assert!(check.passed, "all-done checklist should pass");
351    }
352
353    #[test]
354    fn checklist_fail_when_pending() {
355        let dir = tmp_workspace();
356        let ws = dir.path().to_path_buf();
357        std::fs::create_dir_all(ws.join(".zagens")).unwrap();
358        std::fs::write(
359            ws.join(".zagens/todo.json"),
360            r#"[
361                {"id":1,"content":"done","status":"completed"},
362                {"id":2,"content":"pending item","status":"pending"}
363            ]"#,
364        )
365        .unwrap();
366
367        let check = check_checklist(&ws);
368        assert!(!check.passed, "pending item should fail gate");
369        assert!(check.output.contains("pending item"));
370    }
371
372    #[test]
373    fn checklist_skipped_when_no_file() {
374        let dir = tmp_workspace();
375        let ws = dir.path().to_path_buf();
376        let check = check_checklist(&ws);
377        assert!(check.passed, "no todo.json = skip = pass");
378    }
379
380    #[test]
381    fn craft_verdict_pass() {
382        let dir = tmp_workspace();
383        let ws = dir.path().to_path_buf();
384        std::fs::create_dir_all(ws.join(".zagens")).unwrap();
385        std::fs::write(
386            ws.join(".zagens/craft-ab-metrics.jsonl"),
387            r#"{"schema":1,"ts":"T","task_id":"t1","mode":"craft","implementer_rounds":1,"reviewer_rounds":1,"verifier_rounds":1,"terminal_verdict":"PASS","evidence_downgrades":0,"gate_fails":0,"duration_ms":1000}"#,
388        )
389        .unwrap();
390        let check = check_craft_verdict(&ws, None).expect("should have record");
391        assert!(check.passed);
392    }
393
394    #[test]
395    fn craft_verdict_fail_when_blocker() {
396        let dir = tmp_workspace();
397        let ws = dir.path().to_path_buf();
398        std::fs::create_dir_all(ws.join(".zagens")).unwrap();
399        std::fs::write(
400            ws.join(".zagens/craft-ab-metrics.jsonl"),
401            r#"{"schema":1,"ts":"T","task_id":"t1","mode":"craft","implementer_rounds":3,"reviewer_rounds":3,"verifier_rounds":0,"terminal_verdict":"BLOCKER","evidence_downgrades":0,"gate_fails":0,"duration_ms":9000}"#,
402        )
403        .unwrap();
404        let check = check_craft_verdict(&ws, None).expect("should have record");
405        assert!(!check.passed);
406        assert!(check.output.contains("BLOCKER"));
407    }
408
409    #[test]
410    fn report_summary_reflects_failures() {
411        let failing = GateCheck::fail("fake", "bad output", 10);
412        let passing = GateCheck::pass("ok", 5);
413        let report = GateReport {
414            checks: vec![failing, passing],
415            passed: false,
416            total_ms: 15,
417        };
418        assert!(!report.passed);
419    }
420}