zagens_runtime/cli/handlers/
coverage_gate.rs1use 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#[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#[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
94fn 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(); 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 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; };
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 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
241pub fn run_gate(workspace: &Path, args: &CoverageGateArgs) -> GateReport {
243 let global_t = Instant::now();
244 let mut checks = Vec::new();
245
246 checks.push(check_cargo(workspace, "fmt", &["fmt", "--check"]));
248
249 checks.push(check_cargo(
251 workspace,
252 "clippy",
253 &["clippy", "--", "-D", "warnings"],
254 ));
255
256 checks.push(check_cargo(
258 workspace,
259 "compile",
260 &["test", "--no-run", "--message-format=short"],
261 ));
262
263 if args.run_tests {
265 checks.push(check_cargo(workspace, "tests", &["test"]));
266 }
267
268 if args.require_checklist_complete {
270 checks.push(check_checklist(workspace));
271 }
272
273 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
286pub 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#[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}