1use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::Duration;
17use tokio::sync::RwLock;
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct VerifierSpec {
26 #[serde(rename = "verifier_type")]
28 pub verifier_type: String,
29
30 pub command: String,
32
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub cwd: Option<String>,
36
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub timeout_ms: Option<u64>,
40
41 #[serde(default = "default_required")]
43 pub required: bool,
44}
45
46fn default_required() -> bool {
47 true
48}
49
50impl Default for VerifierSpec {
51 fn default() -> Self {
52 Self {
53 verifier_type: "command".to_string(),
54 command: String::new(),
55 cwd: None,
56 timeout_ms: None,
57 required: true,
58 }
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
68pub struct VerifierResult {
69 pub status: String,
71
72 pub command: String,
74
75 pub duration_ms: u64,
77
78 pub stdout: String,
80
81 pub stderr: String,
83
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub exit_code: Option<i32>,
87
88 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub error_class: Option<String>,
92
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub suggested_next_action: Option<String>,
96}
97
98impl VerifierResult {
99 pub fn pass(command: impl Into<String>, duration_ms: u64) -> Self {
101 Self {
102 status: "pass".to_string(),
103 command: command.into(),
104 duration_ms,
105 stdout: String::new(),
106 stderr: String::new(),
107 exit_code: Some(0),
108 error_class: None,
109 suggested_next_action: None,
110 }
111 }
112
113 pub fn fail(
115 command: impl Into<String>,
116 duration_ms: u64,
117 stdout: String,
118 stderr: String,
119 exit_code: i32,
120 error_class: Option<String>,
121 suggested_next_action: Option<String>,
122 ) -> Self {
123 Self {
124 status: "fail".to_string(),
125 command: command.into(),
126 duration_ms,
127 stdout: truncate_output(&stdout),
128 stderr: truncate_output(&stderr),
129 exit_code: Some(exit_code),
130 error_class,
131 suggested_next_action,
132 }
133 }
134
135 pub fn error(
137 command: impl Into<String>,
138 error_class: impl Into<String>,
139 message: impl Into<String>,
140 ) -> Self {
141 let ec: String = error_class.into();
142 let msg: String = message.into();
143 let suggestion = match ec.as_str() {
144 "timeout" => {
145 Some("Increase timeout_ms or optimise the command to run faster.".to_string())
146 }
147 "io_error" => {
148 Some("Check that the command binary is installed and accessible.".to_string())
149 }
150 _ => Some(format!("System error: {msg}")),
151 };
152 Self {
153 status: "error".to_string(),
154 command: command.into(),
155 duration_ms: 0,
156 stdout: String::new(),
157 stderr: msg.clone(),
158 exit_code: None,
159 error_class: Some(ec),
160 suggested_next_action: suggestion,
161 }
162 }
163
164 pub fn skipped(command: impl Into<String>, reason: impl Into<String>) -> Self {
166 Self {
167 status: "skipped".to_string(),
168 command: command.into(),
169 duration_ms: 0,
170 stdout: String::new(),
171 stderr: reason.into(),
172 exit_code: None,
173 error_class: None,
174 suggested_next_action: None,
175 }
176 }
177
178 pub fn is_ok(&self) -> bool {
180 matches!(self.status.as_str(), "pass" | "skipped")
181 }
182}
183
184pub struct VerifierRunner;
190
191impl VerifierRunner {
192 pub async fn run(spec: &VerifierSpec, project_root: &Path) -> VerifierResult {
197 let command = spec.command.trim().to_string();
198 if command.is_empty() {
199 return VerifierResult::error(
200 &command,
201 "invalid_spec",
202 "verifier command must not be empty",
203 );
204 }
205
206 let cwd = spec
207 .cwd
208 .as_deref()
209 .map(PathBuf::from)
210 .map(|path| {
211 if path.is_absolute() {
212 path
213 } else {
214 project_root.join(path)
215 }
216 })
217 .unwrap_or_else(|| project_root.to_path_buf());
218
219 let started = std::time::Instant::now();
220 let timeout_dur = Duration::from_millis(spec.timeout_ms.unwrap_or(120_000));
221
222 let output_result = tokio::time::timeout(timeout_dur, async {
224 tokio::process::Command::new("sh")
225 .arg("-c")
226 .arg(&command)
227 .current_dir(&cwd)
228 .stdout(std::process::Stdio::piped())
229 .stderr(std::process::Stdio::piped())
230 .stdin(std::process::Stdio::null())
231 .kill_on_drop(true)
232 .output()
233 .await
234 })
235 .await;
236
237 let elapsed = started.elapsed().as_millis() as u64;
238
239 match output_result {
240 Ok(Ok(output)) => {
241 let stdout = truncate_output(&String::from_utf8_lossy(&output.stdout));
242 let stderr = truncate_output(&String::from_utf8_lossy(&output.stderr));
243 let exit_code = output.status.code();
244
245 if output.status.success() {
246 VerifierResult {
247 status: "pass".to_string(),
248 command,
249 duration_ms: elapsed,
250 stdout,
251 stderr,
252 exit_code: Some(0),
253 error_class: None,
254 suggested_next_action: None,
255 }
256 } else {
257 let (error_class, suggested_next_action) =
258 classify_failure(&spec.verifier_type, &stderr, &stdout, exit_code);
259 VerifierResult::fail(
260 command,
261 elapsed,
262 stdout,
263 stderr,
264 exit_code.unwrap_or(1),
265 error_class,
266 suggested_next_action,
267 )
268 }
269 }
270 Ok(Err(e)) => {
271 VerifierResult {
273 status: "error".to_string(),
274 command,
275 duration_ms: elapsed,
276 stdout: String::new(),
277 stderr: format!("failed to spawn command: {e}"),
278 exit_code: None,
279 error_class: Some("io_error".to_string()),
280 suggested_next_action: Some(
281 "Check that `sh` is available and the command is valid.".to_string(),
282 ),
283 }
284 }
285 Err(_) => {
286 VerifierResult {
288 status: "error".to_string(),
289 command,
290 duration_ms: elapsed,
291 stdout: String::new(),
292 stderr: "Command timed out.".to_string(),
293 exit_code: None,
294 error_class: Some("timeout".to_string()),
295 suggested_next_action: Some(
296 "Increase timeout_ms or optimise the command to run faster.".to_string(),
297 ),
298 }
299 }
300 }
301 }
302}
303
304#[derive(Debug, Clone, Default)]
311pub struct VerificationStore {
312 inner: Arc<RwLock<HashMap<String, VerifierResult>>>,
313}
314
315impl VerificationStore {
316 pub fn new() -> Self {
318 Self {
319 inner: Arc::new(RwLock::new(HashMap::new())),
320 }
321 }
322
323 pub async fn store(&self, key: impl Into<String>, result: VerifierResult) {
325 let mut map = self.inner.write().await;
326 map.insert(key.into(), result);
327 }
328
329 pub async fn get(&self, key: &str) -> Option<VerifierResult> {
331 let map = self.inner.read().await;
332 map.get(key).cloned()
333 }
334
335 pub async fn list(&self) -> Vec<(String, VerifierResult)> {
337 let map = self.inner.read().await;
338 let mut items: Vec<(String, VerifierResult)> =
339 map.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
340 items.reverse();
341 items
342 }
343
344 pub async fn remove(&self, key: &str) -> Option<VerifierResult> {
346 let mut map = self.inner.write().await;
347 map.remove(key)
348 }
349
350 pub async fn clear(&self) {
352 let mut map = self.inner.write().await;
353 map.clear();
354 }
355
356 pub async fn len(&self) -> usize {
358 let map = self.inner.read().await;
359 map.len()
360 }
361
362 pub async fn is_empty(&self) -> bool {
364 self.len().await == 0
365 }
366}
367
368const OUTPUT_LIMIT_BYTES: usize = 64 * 1024;
373
374fn truncate_output(s: &str) -> String {
375 if s.len() <= OUTPUT_LIMIT_BYTES {
376 s.to_string()
377 } else {
378 let mut truncated = s[..OUTPUT_LIMIT_BYTES].to_string();
379 truncated.push_str("\n... <truncated>");
380 truncated
381 }
382}
383
384fn classify_failure(
386 verifier_type: &str,
387 stderr: &str,
388 stdout: &str,
389 exit_code: Option<i32>,
390) -> (Option<String>, Option<String>) {
391 let combined = format!("{stdout}\n{stderr}").to_lowercase();
392
393 match verifier_type {
394 "build" => {
395 if combined.contains("error[E") || combined.contains("error:") {
396 (
397 Some("compile_error".to_string()),
398 Some("Fix the compilation error(s) shown above and rebuild.".to_string()),
399 )
400 } else if exit_code == Some(101) {
401 (
402 Some("compile_error".to_string()),
403 Some(
404 "The build failed with a non-zero exit code. Inspect the output."
405 .to_string(),
406 ),
407 )
408 } else {
409 (
410 Some("build_failure".to_string()),
411 Some("The build did not complete successfully. Check the output.".to_string()),
412 )
413 }
414 }
415 "test" => (
416 Some("test_failure".to_string()),
417 Some("Fix the failing test(s) and re-run.".to_string()),
418 ),
419 "typecheck" => {
420 if combined.contains("error[E") {
421 (
422 Some("type_error".to_string()),
423 Some("Fix the type errors shown above.".to_string()),
424 )
425 } else {
426 (
427 Some("type_error".to_string()),
428 Some(
429 "Type checking failed. Inspect the output and fix type issues.".to_string(),
430 ),
431 )
432 }
433 }
434 "lint" => (
435 Some("lint_violation".to_string()),
436 Some("Fix the lint violations shown above.".to_string()),
437 ),
438 _ => {
439 if combined.contains("not found")
440 || combined.contains("no such file")
441 || combined.contains("command not found")
442 {
443 (
444 Some("command_not_found".to_string()),
445 Some("The command was not found. Check that it is installed.".to_string()),
446 )
447 } else {
448 (
449 Some("execution_failure".to_string()),
450 Some(
451 "The command exited with a non-zero status. Inspect the output."
452 .to_string(),
453 ),
454 )
455 }
456 }
457 }
458}
459
460#[cfg(test)]
465mod tests {
466 use super::*;
467 use serde_json;
468
469 #[test]
472 fn verifier_spec_serde_roundtrip_full() {
473 let spec = VerifierSpec {
474 verifier_type: "build".to_string(),
475 command: "cargo build".to_string(),
476 cwd: Some("/tmp".to_string()),
477 timeout_ms: Some(300_000),
478 required: true,
479 };
480 let json = serde_json::to_value(&spec).unwrap();
481 let restored: VerifierSpec = serde_json::from_value(json).unwrap();
482 assert_eq!(restored.verifier_type, "build");
483 assert_eq!(restored.command, "cargo build");
484 assert_eq!(restored.cwd, Some("/tmp".to_string()));
485 assert_eq!(restored.timeout_ms, Some(300_000));
486 assert!(restored.required);
487 }
488
489 #[test]
490 fn verifier_spec_serde_roundtrip_minimal() {
491 let spec = VerifierSpec {
492 verifier_type: "test".to_string(),
493 command: "cargo test".to_string(),
494 ..Default::default()
495 };
496 let json = serde_json::to_value(&spec).unwrap();
497 let restored: VerifierSpec = serde_json::from_value(json).unwrap();
498 assert_eq!(restored.verifier_type, "test");
499 assert_eq!(restored.command, "cargo test");
500 assert!(restored.required);
501 assert!(restored.cwd.is_none());
502 assert!(restored.timeout_ms.is_none());
503 }
504
505 #[test]
506 fn verifier_spec_default_required_true() {
507 assert!(default_required());
508 }
509
510 #[test]
511 fn verifier_spec_serde_optional_fields_omit_when_none() {
512 let spec = VerifierSpec {
513 verifier_type: "lint".to_string(),
514 command: "cargo clippy".to_string(),
515 cwd: None,
516 timeout_ms: None,
517 required: false,
518 };
519 let json = serde_json::to_value(&spec).unwrap();
520 assert!(json.get("cwd").is_none());
522 assert!(json.get("timeout_ms").is_none());
523 assert_eq!(json["required"], false);
524 }
525
526 #[test]
529 fn verifier_result_pass() {
530 let r = VerifierResult::pass("echo ok", 42);
531 assert_eq!(r.status, "pass");
532 assert_eq!(r.command, "echo ok");
533 assert_eq!(r.duration_ms, 42);
534 assert!(r.is_ok());
535 }
536
537 #[test]
538 fn verifier_result_fail() {
539 let r = VerifierResult::fail(
540 "cargo build",
541 100,
542 "stdout".to_string(),
543 "error[E0425]".to_string(),
544 101,
545 Some("compile_error".to_string()),
546 Some("Fix errors.".to_string()),
547 );
548 assert_eq!(r.status, "fail");
549 assert_eq!(r.exit_code, Some(101));
550 assert!(!r.is_ok());
551 }
552
553 #[test]
554 fn verifier_result_error() {
555 let r = VerifierResult::error("cargo build", "io_error", "command not found");
556 assert_eq!(r.status, "error");
557 assert!(!r.is_ok());
558 assert!(r.suggested_next_action.is_some());
559 }
560
561 #[test]
562 fn verifier_result_skipped() {
563 let r = VerifierResult::skipped("cargo build", "not needed");
564 assert_eq!(r.status, "skipped");
565 assert!(r.is_ok());
566 }
567
568 #[test]
569 fn verifier_result_is_ok_returns_false_for_error() {
570 let r = VerifierResult::error("cmd", "io_error", "msg");
571 assert!(!r.is_ok());
572 }
573
574 #[test]
575 fn verifier_result_is_ok_returns_false_for_fail() {
576 let r = VerifierResult::fail("cmd", 0, String::new(), String::new(), 1, None, None);
577 assert!(!r.is_ok());
578 }
579
580 #[test]
583 fn verifier_result_serde_roundtrip() {
584 let r = VerifierResult::fail(
585 "cargo check",
586 150,
587 "".to_string(),
588 "error[E0308]: mismatched types".to_string(),
589 101,
590 Some("compile_error".to_string()),
591 Some("Fix type mismatch.".to_string()),
592 );
593 let json = serde_json::to_value(&r).unwrap();
594 let restored: VerifierResult = serde_json::from_value(json).unwrap();
595 assert_eq!(restored.status, "fail");
596 assert_eq!(restored.command, "cargo check");
597 assert_eq!(restored.exit_code, Some(101));
598 assert_eq!(restored.error_class, Some("compile_error".to_string()));
599 }
600
601 #[tokio::test]
604 async fn verifier_runner_runs_simple_pass() {
605 let spec = VerifierSpec {
606 verifier_type: "command".to_string(),
607 command: "echo hello".to_string(),
608 ..Default::default()
609 };
610 let dir = tempfile::tempdir().unwrap();
611 let result = VerifierRunner::run(&spec, dir.path()).await;
612 assert_eq!(result.status, "pass", "echo should succeed: {:?}", result);
613 assert_eq!(result.exit_code, Some(0));
614 assert!(result.stdout.contains("hello"), "stdout: {}", result.stdout);
615 assert!(result.duration_ms > 0);
616 }
617
618 #[tokio::test]
619 async fn verifier_runner_captures_exit_code() {
620 let spec = VerifierSpec {
621 verifier_type: "command".to_string(),
622 command: "exit 42".to_string(),
623 ..Default::default()
624 };
625 let dir = tempfile::tempdir().unwrap();
626 let result = VerifierRunner::run(&spec, dir.path()).await;
627 assert_eq!(result.status, "fail", "exit 42 should fail");
628 assert_eq!(result.exit_code, Some(42));
629 }
630
631 #[tokio::test]
632 async fn verifier_runner_captures_stderr() {
633 let spec = VerifierSpec {
634 verifier_type: "command".to_string(),
635 command: "echo error >&2 && exit 1".to_string(),
636 ..Default::default()
637 };
638 let dir = tempfile::tempdir().unwrap();
639 let result = VerifierRunner::run(&spec, dir.path()).await;
640 assert_eq!(result.status, "fail");
641 assert!(result.stderr.contains("error"), "stderr: {}", result.stderr);
642 }
643
644 #[tokio::test]
645 async fn verifier_runner_resolves_relative_cwd_under_project_root() {
646 let dir = tempfile::tempdir().unwrap();
647 let subdir = dir.path().join("checks");
648 std::fs::create_dir_all(&subdir).unwrap();
649 std::fs::write(subdir.join("marker.txt"), "ok").unwrap();
650 let spec = VerifierSpec {
651 verifier_type: "command".to_string(),
652 command: "cat marker.txt".to_string(),
653 cwd: Some("checks".to_string()),
654 ..Default::default()
655 };
656
657 let result = VerifierRunner::run(&spec, dir.path()).await;
658 assert_eq!(result.status, "pass", "{result:?}");
659 assert_eq!(result.stdout, "ok");
660 }
661
662 #[tokio::test]
663 async fn verifier_runner_empty_command_returns_error() {
664 let spec = VerifierSpec {
665 verifier_type: "command".to_string(),
666 command: "".to_string(),
667 ..Default::default()
668 };
669 let dir = tempfile::tempdir().unwrap();
670 let result = VerifierRunner::run(&spec, dir.path()).await;
671 assert_eq!(result.status, "error");
672 assert_eq!(result.error_class, Some("invalid_spec".to_string()));
673 }
674
675 #[tokio::test]
676 async fn verifier_runner_timeout_returns_error() {
677 let spec = VerifierSpec {
678 verifier_type: "command".to_string(),
679 command: "sleep 10".to_string(),
680 timeout_ms: Some(1),
681 ..Default::default()
682 };
683 let dir = tempfile::tempdir().unwrap();
684 let result = VerifierRunner::run(&spec, dir.path()).await;
685 assert_eq!(
686 result.status, "error",
687 "sleep should time out: {:?}",
688 result
689 );
690 assert_eq!(result.error_class, Some("timeout".to_string()));
691 }
692
693 #[tokio::test]
696 async fn verification_store_store_and_get() {
697 let store = VerificationStore::new();
698 let result = VerifierResult::pass("echo hi", 10);
699 store.store("feature-1", result.clone()).await;
700 let retrieved = store.get("feature-1").await;
701 assert!(retrieved.is_some());
702 assert_eq!(retrieved.unwrap().status, "pass");
703 }
704
705 #[tokio::test]
706 async fn verification_store_get_missing_returns_none() {
707 let store = VerificationStore::new();
708 let retrieved = store.get("nonexistent").await;
709 assert!(retrieved.is_none());
710 }
711
712 #[tokio::test]
713 async fn verification_store_list_returns_all() {
714 let store = VerificationStore::new();
715 store.store("a", VerifierResult::pass("cmd1", 1)).await;
716 store.store("b", VerifierResult::pass("cmd2", 2)).await;
717 let items = store.list().await;
718 assert_eq!(items.len(), 2);
719 }
720
721 #[tokio::test]
722 async fn verification_store_remove() {
723 let store = VerificationStore::new();
724 store.store("x", VerifierResult::pass("cmd", 1)).await;
725 let removed = store.remove("x").await;
726 assert!(removed.is_some());
727 assert!(store.get("x").await.is_none());
728 }
729
730 #[tokio::test]
731 async fn verification_store_clear() {
732 let store = VerificationStore::new();
733 store.store("a", VerifierResult::pass("cmd", 1)).await;
734 store.clear().await;
735 assert!(store.is_empty().await);
736 }
737
738 #[tokio::test]
739 async fn verification_store_is_empty() {
740 let store = VerificationStore::new();
741 assert!(store.is_empty().await);
742 store.store("k", VerifierResult::pass("cmd", 1)).await;
743 assert!(!store.is_empty().await);
744 }
745
746 #[test]
749 fn classify_failure_build_compile_error() {
750 let (class, _) =
751 classify_failure("build", "error[E0425]: cannot find value", "", Some(101));
752 assert_eq!(class, Some("compile_error".to_string()));
753 }
754
755 #[test]
756 fn classify_failure_test_failure() {
757 let (class, _) = classify_failure("test", "test result: FAILED", "", Some(1));
758 assert_eq!(class, Some("test_failure".to_string()));
759 }
760
761 #[test]
762 fn classify_failure_typecheck_type_error() {
763 let (class, _) =
764 classify_failure("typecheck", "error[E0308]: mismatched types", "", Some(1));
765 assert_eq!(class, Some("type_error".to_string()));
766 }
767
768 #[test]
769 fn classify_failure_lint_violation() {
770 let (class, _) = classify_failure("lint", "clippy::style", "", Some(1));
771 assert_eq!(class, Some("lint_violation".to_string()));
772 }
773
774 #[test]
775 fn classify_failure_command_not_found() {
776 let (class, _) = classify_failure("command", "command not found: foo", "", Some(127));
777 assert_eq!(class, Some("command_not_found".to_string()));
778 }
779
780 #[test]
781 fn classify_failure_generic() {
782 let (class, _) = classify_failure("command", "something broke", "", Some(1));
783 assert_eq!(class, Some("execution_failure".to_string()));
784 }
785
786 #[test]
787 fn classify_failure_build_no_rust_error() {
788 let (class, _) = classify_failure("build", "linking failed", "", Some(1));
789 assert_eq!(class, Some("build_failure".to_string()));
790 }
791}