1#![allow(clippy::missing_errors_doc)]
33
34use std::fs;
35use std::io::Write as _;
36use std::path::{Path, PathBuf};
37use std::process::{Command, Stdio};
38use std::time::{Duration, Instant};
39
40use crate::config::{LanguagePreset, OnFailure, ValidationConfig};
41use crate::merge_state::{CommandResult, MergeStateError, ValidationResult};
42use crate::model::types::GitOid;
43
44#[derive(Clone, Debug, PartialEq, Eq)]
50pub enum ValidateOutcome {
51 Skipped,
53 Passed(ValidationResult),
55 PassedWithWarnings(ValidationResult),
57 Blocked(ValidationResult),
59 Quarantine(ValidationResult),
61 BlockedAndQuarantine(ValidationResult),
63}
64
65impl ValidateOutcome {
66 #[must_use]
68 pub const fn may_proceed(&self) -> bool {
69 matches!(
70 self,
71 Self::Skipped | Self::Passed(_) | Self::PassedWithWarnings(_) | Self::Quarantine(_)
72 )
73 }
74
75 #[must_use]
77 pub const fn needs_quarantine(&self) -> bool {
78 matches!(self, Self::Quarantine(_) | Self::BlockedAndQuarantine(_))
79 }
80
81 #[must_use]
83 pub const fn result(&self) -> Option<&ValidationResult> {
84 match self {
85 Self::Skipped => None,
86 Self::Passed(r)
87 | Self::PassedWithWarnings(r)
88 | Self::Blocked(r)
89 | Self::Quarantine(r)
90 | Self::BlockedAndQuarantine(r) => Some(r),
91 }
92 }
93}
94
95#[derive(Clone, Debug, PartialEq, Eq)]
101pub enum ValidateError {
102 WorktreeCreate(String),
104 WorktreeRemove(String),
106 CommandSpawn(String),
108 State(MergeStateError),
110 ArtifactWrite(String),
112}
113
114impl std::fmt::Display for ValidateError {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 match self {
117 Self::WorktreeCreate(msg) => {
118 write!(f, "VALIDATE: failed to create temp worktree: {msg}")
119 }
120 Self::WorktreeRemove(msg) => {
121 write!(f, "VALIDATE: failed to remove temp worktree: {msg}")
122 }
123 Self::CommandSpawn(msg) => {
124 write!(f, "VALIDATE: failed to spawn command: {msg}")
125 }
126 Self::State(e) => write!(f, "VALIDATE: {e}"),
127 Self::ArtifactWrite(msg) => {
128 write!(f, "VALIDATE: failed to write artifact: {msg}")
129 }
130 }
131 }
132}
133
134impl std::error::Error for ValidateError {}
135
136impl From<MergeStateError> for ValidateError {
137 fn from(e: MergeStateError) -> Self {
138 Self::State(e)
139 }
140}
141
142fn create_temp_worktree(
148 repo_root: &Path,
149 candidate_oid: &GitOid,
150 worktree_path: &Path,
151) -> Result<(), ValidateError> {
152 let output = Command::new("git")
153 .args([
154 "worktree",
155 "add",
156 "--detach",
157 &worktree_path.to_string_lossy(),
158 candidate_oid.as_str(),
159 ])
160 .current_dir(repo_root)
161 .stdout(Stdio::piped())
162 .stderr(Stdio::piped())
163 .output()
164 .map_err(|e| ValidateError::WorktreeCreate(format!("spawn git: {e}")))?;
165
166 if !output.status.success() {
167 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
168 return Err(ValidateError::WorktreeCreate(stderr));
169 }
170
171 Ok(())
172}
173
174fn remove_temp_worktree(repo_root: &Path, worktree_path: &Path) -> Result<(), ValidateError> {
176 let output = Command::new("git")
177 .args([
178 "worktree",
179 "remove",
180 "--force",
181 &worktree_path.to_string_lossy(),
182 ])
183 .current_dir(repo_root)
184 .stdout(Stdio::piped())
185 .stderr(Stdio::piped())
186 .output()
187 .map_err(|e| ValidateError::WorktreeRemove(format!("spawn git: {e}")))?;
188
189 if !output.status.success() {
190 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
191 return Err(ValidateError::WorktreeRemove(stderr));
192 }
193
194 Ok(())
195}
196
197#[must_use]
211pub fn detect_language_preset(dir: &Path) -> Option<LanguagePreset> {
212 if dir.join("Cargo.toml").exists() {
213 return Some(LanguagePreset::Rust);
214 }
215 if dir.join("pyproject.toml").exists()
216 || dir.join("setup.py").exists()
217 || dir.join("setup.cfg").exists()
218 {
219 return Some(LanguagePreset::Python);
220 }
221 if dir.join("tsconfig.json").exists() {
222 return Some(LanguagePreset::TypeScript);
223 }
224 None
225}
226
227#[must_use]
236pub fn resolve_commands(config: &ValidationConfig, worktree_dir: &Path) -> Vec<String> {
237 let explicit = config.effective_commands();
239 if !explicit.is_empty() {
240 return explicit.into_iter().map(str::to_owned).collect();
241 }
242
243 let preset = match &config.preset {
245 None => return Vec::new(),
246 Some(LanguagePreset::Auto) => detect_language_preset(worktree_dir),
247 Some(p) => Some(p.clone()),
248 };
249
250 preset.map_or_else(Vec::new, |p| {
251 p.commands().iter().map(|s| (*s).to_owned()).collect()
252 })
253}
254
255pub fn run_validate_phase(
282 repo_root: &Path,
283 candidate_oid: &GitOid,
284 config: &ValidationConfig,
285) -> Result<ValidateOutcome, ValidateError> {
286 let worktree_dir = repo_root.join(".manifold").join("validate-tmp");
288 if worktree_dir.exists() {
290 let _ = remove_temp_worktree(repo_root, &worktree_dir);
291 let _ = fs::remove_dir_all(&worktree_dir);
293 }
294
295 let explicit = config.effective_commands();
301 if explicit.is_empty() && config.preset.is_none() {
302 return Ok(ValidateOutcome::Skipped);
303 }
304
305 create_temp_worktree(repo_root, candidate_oid, &worktree_dir)?;
306
307 let commands = resolve_commands(config, &worktree_dir);
309 if commands.is_empty() {
310 let _ = remove_temp_worktree(repo_root, &worktree_dir);
312 let _ = fs::remove_dir_all(&worktree_dir);
313 return Ok(ValidateOutcome::Skipped);
314 }
315
316 crate::fp!("FP_VALIDATE_BEFORE_CHECK").map_err(|e| ValidateError::CommandSpawn(e.to_string()))?;
318 let cmd_refs: Vec<&str> = commands.iter().map(String::as_str).collect();
319 let result = run_commands_pipeline(&cmd_refs, &worktree_dir, config.timeout_seconds);
320
321 let _ = remove_temp_worktree(repo_root, &worktree_dir);
323 let _ = fs::remove_dir_all(&worktree_dir);
324
325 let result = result?;
326 crate::fp!("FP_VALIDATE_AFTER_CHECK").map_err(|e| ValidateError::CommandSpawn(e.to_string()))?;
327
328 Ok(apply_policy(&result, &config.on_failure))
330}
331
332pub fn run_validate_in_dir(
337 command: &str,
338 working_dir: &Path,
339 timeout_seconds: u32,
340 on_failure: &OnFailure,
341) -> Result<ValidateOutcome, ValidateError> {
342 let result = run_commands_pipeline(&[command], working_dir, timeout_seconds)?;
343 Ok(apply_policy(&result, on_failure))
344}
345
346pub fn run_validate_pipeline_in_dir(
349 commands: &[&str],
350 working_dir: &Path,
351 timeout_seconds: u32,
352 on_failure: &OnFailure,
353) -> Result<ValidateOutcome, ValidateError> {
354 let result = run_commands_pipeline(commands, working_dir, timeout_seconds)?;
355 Ok(apply_policy(&result, on_failure))
356}
357
358pub fn run_validate_config_in_dir(
365 config: &ValidationConfig,
366 working_dir: &Path,
367) -> Result<ValidateOutcome, ValidateError> {
368 let commands = resolve_commands(config, working_dir);
369 if commands.is_empty() {
370 return Ok(ValidateOutcome::Skipped);
371 }
372 let cmd_refs: Vec<&str> = commands.iter().map(String::as_str).collect();
373 let result = run_commands_pipeline(&cmd_refs, working_dir, config.timeout_seconds)?;
374 Ok(apply_policy(&result, &config.on_failure))
375}
376
377pub fn write_validation_artifact(
398 manifold_dir: &Path,
399 merge_id: &str,
400 result: &ValidationResult,
401) -> Result<PathBuf, ValidateError> {
402 let artifact_dir = manifold_dir.join("artifacts").join("merge").join(merge_id);
403 fs::create_dir_all(&artifact_dir).map_err(|e| {
404 ValidateError::ArtifactWrite(format!("create dir {}: {e}", artifact_dir.display()))
405 })?;
406
407 let artifact_path = artifact_dir.join("validation.json");
408 let tmp_path = artifact_dir.join(".validation.json.tmp");
409
410 let json = serde_json::to_string_pretty(result)
411 .map_err(|e| ValidateError::ArtifactWrite(format!("serialize: {e}")))?;
412
413 let mut file = fs::File::create(&tmp_path)
414 .map_err(|e| ValidateError::ArtifactWrite(format!("create {}: {e}", tmp_path.display())))?;
415 file.write_all(json.as_bytes())
416 .map_err(|e| ValidateError::ArtifactWrite(format!("write {}: {e}", tmp_path.display())))?;
417 file.sync_all()
418 .map_err(|e| ValidateError::ArtifactWrite(format!("fsync {}: {e}", tmp_path.display())))?;
419 drop(file);
420
421 fs::rename(&tmp_path, &artifact_path).map_err(|e| {
422 ValidateError::ArtifactWrite(format!(
423 "rename {} → {}: {e}",
424 tmp_path.display(),
425 artifact_path.display()
426 ))
427 })?;
428
429 Ok(artifact_path)
430}
431
432fn run_commands_pipeline(
441 commands: &[&str],
442 working_dir: &Path,
443 timeout_seconds: u32,
444) -> Result<ValidationResult, ValidateError> {
445 let mut command_results = Vec::with_capacity(commands.len());
446 let mut total_duration_ms: u64 = 0;
447
448 for &cmd in commands {
449 let cr = run_single_command(cmd, working_dir, timeout_seconds)?;
450 total_duration_ms = total_duration_ms.saturating_add(cr.duration_ms);
451 let passed = cr.passed;
452 command_results.push(cr);
453
454 if !passed {
455 break; }
457 }
458
459 let summary_idx = command_results
462 .iter()
463 .position(|r| !r.passed)
464 .unwrap_or_else(|| command_results.len().saturating_sub(1));
465 let summary = &command_results[summary_idx];
466
467 let all_passed = command_results.iter().all(|r| r.passed);
468
469 Ok(ValidationResult {
470 passed: all_passed,
471 exit_code: summary.exit_code,
472 stdout: summary.stdout.clone(),
473 stderr: summary.stderr.clone(),
474 duration_ms: total_duration_ms,
475 command_results: if commands.len() > 1 {
476 command_results
477 } else {
478 Vec::new()
481 },
482 })
483}
484
485fn run_single_command(
487 command: &str,
488 working_dir: &Path,
489 timeout_seconds: u32,
490) -> Result<CommandResult, ValidateError> {
491 let timeout = Duration::from_secs(timeout_seconds.into());
492 let start = Instant::now();
493
494 let mut child = Command::new("sh")
495 .args(["-c", command])
496 .current_dir(working_dir)
497 .stdout(Stdio::piped())
498 .stderr(Stdio::piped())
499 .spawn()
500 .map_err(|e| ValidateError::CommandSpawn(format!("sh -c {command:?}: {e}")))?;
501
502 let result = loop {
504 match child.try_wait() {
505 Ok(Some(status)) => {
506 let duration = start.elapsed();
507 let stdout = child
508 .stdout
509 .take()
510 .map(|mut s| {
511 let mut buf = String::new();
512 std::io::Read::read_to_string(&mut s, &mut buf).unwrap_or(0);
513 buf
514 })
515 .unwrap_or_default();
516 let stderr = child
517 .stderr
518 .take()
519 .map(|mut s| {
520 let mut buf = String::new();
521 std::io::Read::read_to_string(&mut s, &mut buf).unwrap_or(0);
522 buf
523 })
524 .unwrap_or_default();
525
526 let exit_code = status.code();
527 let passed = exit_code == Some(0);
528
529 break CommandResult {
530 command: command.to_owned(),
531 passed,
532 exit_code,
533 stdout,
534 stderr,
535 duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
536 };
537 }
538 Ok(None) => {
539 if start.elapsed() >= timeout {
541 let _ = child.kill();
542 let _ = child.wait();
543
544 break CommandResult {
545 command: command.to_owned(),
546 passed: false,
547 exit_code: None,
548 stdout: String::new(),
549 stderr: format!("killed by timeout after {timeout_seconds}s"),
550 duration_ms: u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
551 };
552 }
553 std::thread::sleep(Duration::from_millis(50));
554 }
555 Err(e) => {
556 return Err(ValidateError::CommandSpawn(format!(
557 "wait for command: {e}"
558 )));
559 }
560 }
561 };
562
563 Ok(result)
564}
565
566fn apply_policy(result: &ValidationResult, on_failure: &OnFailure) -> ValidateOutcome {
568 if result.passed {
569 ValidateOutcome::Passed(result.clone())
570 } else {
571 match on_failure {
572 OnFailure::Warn => ValidateOutcome::PassedWithWarnings(result.clone()),
573 OnFailure::Block => ValidateOutcome::Blocked(result.clone()),
574 OnFailure::Quarantine => ValidateOutcome::Quarantine(result.clone()),
575 OnFailure::BlockQuarantine => ValidateOutcome::BlockedAndQuarantine(result.clone()),
576 }
577 }
578}
579
580#[cfg(test)]
585mod tests {
586 use super::*;
587
588 #[test]
591 fn skipped_may_proceed() {
592 assert!(ValidateOutcome::Skipped.may_proceed());
593 assert!(!ValidateOutcome::Skipped.needs_quarantine());
594 assert!(ValidateOutcome::Skipped.result().is_none());
595 }
596
597 #[test]
598 fn passed_may_proceed() {
599 let r = ValidationResult {
600 passed: true,
601 exit_code: Some(0),
602 stdout: "ok".into(),
603 stderr: String::new(),
604 duration_ms: 100,
605 command_results: Vec::new(),
606 };
607 let o = ValidateOutcome::Passed(r);
608 assert!(o.may_proceed());
609 assert!(!o.needs_quarantine());
610 assert!(o.result().is_some());
611 }
612
613 #[test]
614 fn blocked_may_not_proceed() {
615 let r = ValidationResult {
616 passed: false,
617 exit_code: Some(1),
618 stdout: String::new(),
619 stderr: "fail".into(),
620 duration_ms: 200,
621 command_results: Vec::new(),
622 };
623 let o = ValidateOutcome::Blocked(r);
624 assert!(!o.may_proceed());
625 assert!(!o.needs_quarantine());
626 }
627
628 #[test]
629 fn quarantine_may_proceed_and_needs_quarantine() {
630 let r = ValidationResult {
631 passed: false,
632 exit_code: Some(1),
633 stdout: String::new(),
634 stderr: "fail".into(),
635 duration_ms: 200,
636 command_results: Vec::new(),
637 };
638 let o = ValidateOutcome::Quarantine(r);
639 assert!(o.may_proceed());
640 assert!(o.needs_quarantine());
641 }
642
643 #[test]
644 fn block_quarantine_blocks_and_quarantines() {
645 let r = ValidationResult {
646 passed: false,
647 exit_code: Some(1),
648 stdout: String::new(),
649 stderr: "fail".into(),
650 duration_ms: 200,
651 command_results: Vec::new(),
652 };
653 let o = ValidateOutcome::BlockedAndQuarantine(r);
654 assert!(!o.may_proceed());
655 assert!(o.needs_quarantine());
656 }
657
658 #[test]
661 fn validate_passing_command() {
662 let dir = tempfile::tempdir().unwrap();
663 let outcome = run_validate_in_dir("echo hello", dir.path(), 10, &OnFailure::Block).unwrap();
664 assert!(matches!(outcome, ValidateOutcome::Passed(_)));
665 let result = outcome.result().unwrap();
666 assert!(result.passed);
667 assert_eq!(result.exit_code, Some(0));
668 assert!(result.stdout.contains("hello"));
669 assert!(result.duration_ms < 5000);
670 }
671
672 #[test]
673 fn validate_failing_command_block() {
674 let dir = tempfile::tempdir().unwrap();
675 let outcome = run_validate_in_dir("exit 1", dir.path(), 10, &OnFailure::Block).unwrap();
676 assert!(matches!(outcome, ValidateOutcome::Blocked(_)));
677 let result = outcome.result().unwrap();
678 assert!(!result.passed);
679 assert_eq!(result.exit_code, Some(1));
680 }
681
682 #[test]
683 fn validate_failing_command_warn() {
684 let dir = tempfile::tempdir().unwrap();
685 let outcome = run_validate_in_dir("exit 1", dir.path(), 10, &OnFailure::Warn).unwrap();
686 assert!(matches!(outcome, ValidateOutcome::PassedWithWarnings(_)));
687 assert!(outcome.may_proceed());
688 }
689
690 #[test]
691 fn validate_failing_command_quarantine() {
692 let dir = tempfile::tempdir().unwrap();
693 let outcome =
694 run_validate_in_dir("exit 1", dir.path(), 10, &OnFailure::Quarantine).unwrap();
695 assert!(matches!(outcome, ValidateOutcome::Quarantine(_)));
696 assert!(outcome.may_proceed());
697 assert!(outcome.needs_quarantine());
698 }
699
700 #[test]
701 fn validate_failing_command_block_quarantine() {
702 let dir = tempfile::tempdir().unwrap();
703 let outcome =
704 run_validate_in_dir("exit 1", dir.path(), 10, &OnFailure::BlockQuarantine).unwrap();
705 assert!(matches!(outcome, ValidateOutcome::BlockedAndQuarantine(_)));
706 assert!(!outcome.may_proceed());
707 assert!(outcome.needs_quarantine());
708 }
709
710 #[test]
711 fn validate_timeout_kills_command() {
712 let dir = tempfile::tempdir().unwrap();
713 let outcome = run_validate_in_dir("sleep 60", dir.path(), 1, &OnFailure::Block).unwrap();
714 assert!(matches!(outcome, ValidateOutcome::Blocked(_)));
715 let result = outcome.result().unwrap();
716 assert!(!result.passed);
717 assert!(result.exit_code.is_none()); assert!(result.stderr.contains("timeout"));
719 assert!(result.duration_ms >= 1000);
720 assert!(result.duration_ms < 5000);
721 }
722
723 #[test]
724 fn validate_captures_stderr() {
725 let dir = tempfile::tempdir().unwrap();
726 let outcome = run_validate_in_dir(
727 "echo error-output >&2 && exit 1",
728 dir.path(),
729 10,
730 &OnFailure::Block,
731 )
732 .unwrap();
733 let result = outcome.result().unwrap();
734 assert!(result.stderr.contains("error-output"));
735 }
736
737 #[test]
738 fn validate_captures_stdout_and_stderr() {
739 let dir = tempfile::tempdir().unwrap();
740 let outcome = run_validate_in_dir(
741 "echo out-text && echo err-text >&2",
742 dir.path(),
743 10,
744 &OnFailure::Block,
745 )
746 .unwrap();
747 let result = outcome.result().unwrap();
748 assert!(result.passed);
749 assert!(result.stdout.contains("out-text"));
750 assert!(result.stderr.contains("err-text"));
751 }
752
753 #[test]
754 fn validate_exit_code_nonzero() {
755 let dir = tempfile::tempdir().unwrap();
756 let outcome = run_validate_in_dir("exit 42", dir.path(), 10, &OnFailure::Block).unwrap();
757 let result = outcome.result().unwrap();
758 assert_eq!(result.exit_code, Some(42));
759 assert!(!result.passed);
760 }
761
762 #[test]
765 fn validate_skipped_when_no_command() {
766 let config = ValidationConfig {
767 command: None,
768 commands: Vec::new(),
769 timeout_seconds: 60,
770 preset: None,
771 on_failure: OnFailure::Block,
772 };
773 let oid = GitOid::new(&"a".repeat(40)).unwrap();
774 let dir = tempfile::tempdir().unwrap();
775 let outcome = run_validate_phase(dir.path(), &oid, &config).unwrap();
776 assert!(matches!(outcome, ValidateOutcome::Skipped));
777 }
778
779 #[test]
780 fn validate_skipped_when_empty_command() {
781 let config = ValidationConfig {
782 command: Some(String::new()),
783 commands: Vec::new(),
784 timeout_seconds: 60,
785 preset: None,
786 on_failure: OnFailure::Block,
787 };
788 let oid = GitOid::new(&"a".repeat(40)).unwrap();
789 let dir = tempfile::tempdir().unwrap();
790 let outcome = run_validate_phase(dir.path(), &oid, &config).unwrap();
791 assert!(matches!(outcome, ValidateOutcome::Skipped));
792 }
793
794 #[test]
795 fn validate_skipped_when_empty_commands_array() {
796 let config = ValidationConfig {
797 command: None,
798 commands: vec![String::new()],
799 timeout_seconds: 60,
800 preset: None,
801 on_failure: OnFailure::Block,
802 };
803 let oid = GitOid::new(&"a".repeat(40)).unwrap();
804 let dir = tempfile::tempdir().unwrap();
805 let outcome = run_validate_phase(dir.path(), &oid, &config).unwrap();
806 assert!(matches!(outcome, ValidateOutcome::Skipped));
807 }
808
809 #[test]
810 fn validate_phase_with_no_command_returns_skipped() {
811 let dir = tempfile::tempdir().unwrap();
812 let config = ValidationConfig::default();
813 let oid = GitOid::new(&"a".repeat(40)).unwrap();
814 let outcome = run_validate_phase(dir.path(), &oid, &config).unwrap();
815 assert!(matches!(outcome, ValidateOutcome::Skipped));
816 assert!(outcome.may_proceed());
817 }
818
819 #[test]
822 fn pipeline_all_pass() {
823 let dir = tempfile::tempdir().unwrap();
824 let outcome = run_validate_pipeline_in_dir(
825 &["echo step1", "echo step2", "echo step3"],
826 dir.path(),
827 10,
828 &OnFailure::Block,
829 )
830 .unwrap();
831 assert!(matches!(outcome, ValidateOutcome::Passed(_)));
832 let result = outcome.result().unwrap();
833 assert!(result.passed);
834 assert_eq!(result.command_results.len(), 3);
835 assert!(result.command_results.iter().all(|r| r.passed));
836 assert_eq!(result.command_results[0].command, "echo step1");
837 assert_eq!(result.command_results[1].command, "echo step2");
838 assert_eq!(result.command_results[2].command, "echo step3");
839 }
840
841 #[test]
842 fn pipeline_stops_on_first_failure() {
843 let dir = tempfile::tempdir().unwrap();
844 let outcome = run_validate_pipeline_in_dir(
845 &["echo ok", "exit 1", "echo should-not-run"],
846 dir.path(),
847 10,
848 &OnFailure::Block,
849 )
850 .unwrap();
851 assert!(matches!(outcome, ValidateOutcome::Blocked(_)));
852 let result = outcome.result().unwrap();
853 assert!(!result.passed);
854 assert_eq!(result.command_results.len(), 2);
856 assert!(result.command_results[0].passed);
857 assert!(!result.command_results[1].passed);
858 }
859
860 #[test]
861 fn pipeline_first_command_fails() {
862 let dir = tempfile::tempdir().unwrap();
863 let outcome = run_validate_pipeline_in_dir(
864 &["exit 42", "echo never"],
865 dir.path(),
866 10,
867 &OnFailure::Block,
868 )
869 .unwrap();
870 let result = outcome.result().unwrap();
871 assert!(!result.passed);
872 assert_eq!(result.exit_code, Some(42));
873 assert_eq!(result.command_results.len(), 1);
874 }
875
876 #[test]
877 fn pipeline_captures_per_command_output() {
878 let dir = tempfile::tempdir().unwrap();
879 let outcome = run_validate_pipeline_in_dir(
880 &["echo output-a", "echo output-b"],
881 dir.path(),
882 10,
883 &OnFailure::Block,
884 )
885 .unwrap();
886 let result = outcome.result().unwrap();
887 assert!(result.command_results[0].stdout.contains("output-a"));
888 assert!(result.command_results[1].stdout.contains("output-b"));
889 }
890
891 #[test]
892 fn pipeline_total_duration_is_sum() {
893 let dir = tempfile::tempdir().unwrap();
894 let outcome =
895 run_validate_pipeline_in_dir(&["true", "true"], dir.path(), 10, &OnFailure::Block)
896 .unwrap();
897 let result = outcome.result().unwrap();
898 let per_cmd_total: u64 = result.command_results.iter().map(|r| r.duration_ms).sum();
899 assert!(result.duration_ms >= per_cmd_total.saturating_sub(10));
901 }
902
903 #[test]
904 fn pipeline_timeout_per_command() {
905 let dir = tempfile::tempdir().unwrap();
906 let outcome = run_validate_pipeline_in_dir(
907 &["echo fast", "sleep 60"],
908 dir.path(),
909 1,
910 &OnFailure::Block,
911 )
912 .unwrap();
913 let result = outcome.result().unwrap();
914 assert!(!result.passed);
915 assert_eq!(result.command_results.len(), 2);
916 assert!(result.command_results[0].passed);
917 assert!(!result.command_results[1].passed);
918 assert!(result.command_results[1].stderr.contains("timeout"));
919 }
920
921 #[test]
922 fn pipeline_warn_policy_proceeds() {
923 let dir = tempfile::tempdir().unwrap();
924 let outcome =
925 run_validate_pipeline_in_dir(&["exit 1"], dir.path(), 10, &OnFailure::Warn).unwrap();
926 assert!(matches!(outcome, ValidateOutcome::PassedWithWarnings(_)));
927 assert!(outcome.may_proceed());
928 }
929
930 #[test]
933 fn single_command_omits_command_results() {
934 let dir = tempfile::tempdir().unwrap();
935 let outcome = run_validate_in_dir("echo hi", dir.path(), 10, &OnFailure::Block).unwrap();
936 let result = outcome.result().unwrap();
937 assert!(result.command_results.is_empty());
939 }
940
941 #[test]
944 fn write_artifact_creates_directory_and_file() {
945 let dir = tempfile::tempdir().unwrap();
946 let manifold_dir = dir.path().join(".manifold");
947
948 let result = ValidationResult {
949 passed: true,
950 exit_code: Some(0),
951 stdout: "all tests passed\n".into(),
952 stderr: String::new(),
953 duration_ms: 1234,
954 command_results: Vec::new(),
955 };
956
957 let path = write_validation_artifact(&manifold_dir, "test-merge-id", &result).unwrap();
958 assert!(path.exists());
959 assert_eq!(
960 path,
961 manifold_dir.join("artifacts/merge/test-merge-id/validation.json")
962 );
963
964 let contents = fs::read_to_string(&path).unwrap();
966 let decoded: ValidationResult = serde_json::from_str(&contents).unwrap();
967 assert_eq!(decoded, result);
968 }
969
970 #[test]
971 fn write_artifact_with_multi_command_results() {
972 let dir = tempfile::tempdir().unwrap();
973 let manifold_dir = dir.path().join(".manifold");
974
975 let result = ValidationResult {
976 passed: false,
977 exit_code: Some(1),
978 stdout: String::new(),
979 stderr: "test failed".into(),
980 duration_ms: 5000,
981 command_results: vec![
982 CommandResult {
983 command: "cargo check".into(),
984 passed: true,
985 exit_code: Some(0),
986 stdout: "ok\n".into(),
987 stderr: String::new(),
988 duration_ms: 2000,
989 },
990 CommandResult {
991 command: "cargo test".into(),
992 passed: false,
993 exit_code: Some(1),
994 stdout: String::new(),
995 stderr: "test failed\n".into(),
996 duration_ms: 3000,
997 },
998 ],
999 };
1000
1001 let path = write_validation_artifact(&manifold_dir, "merge-42", &result).unwrap();
1002 let contents = fs::read_to_string(&path).unwrap();
1003 let decoded: ValidationResult = serde_json::from_str(&contents).unwrap();
1004 assert_eq!(decoded.command_results.len(), 2);
1005 assert_eq!(decoded.command_results[0].command, "cargo check");
1006 assert_eq!(decoded.command_results[1].command, "cargo test");
1007 }
1008
1009 #[test]
1010 fn write_artifact_overwrites_existing() {
1011 let dir = tempfile::tempdir().unwrap();
1012 let manifold_dir = dir.path().join(".manifold");
1013
1014 let result1 = ValidationResult {
1015 passed: false,
1016 exit_code: Some(1),
1017 stdout: "first run".into(),
1018 stderr: String::new(),
1019 duration_ms: 100,
1020 command_results: Vec::new(),
1021 };
1022 write_validation_artifact(&manifold_dir, "id1", &result1).unwrap();
1023
1024 let result2 = ValidationResult {
1025 passed: true,
1026 exit_code: Some(0),
1027 stdout: "second run".into(),
1028 stderr: String::new(),
1029 duration_ms: 200,
1030 command_results: Vec::new(),
1031 };
1032 let path = write_validation_artifact(&manifold_dir, "id1", &result2).unwrap();
1033
1034 let decoded: ValidationResult =
1035 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1036 assert!(decoded.passed);
1037 assert!(decoded.stdout.contains("second run"));
1038 }
1039
1040 #[test]
1043 fn validate_rerun_same_inputs_produces_same_decision() {
1044 let dir = tempfile::tempdir().unwrap();
1045 std::fs::write(dir.path().join("ok.txt"), "ok\n").unwrap();
1046
1047 let first =
1048 run_validate_in_dir("test -f ok.txt", dir.path(), 10, &OnFailure::Block).unwrap();
1049 let second =
1050 run_validate_in_dir("test -f ok.txt", dir.path(), 10, &OnFailure::Block).unwrap();
1051
1052 assert_eq!(first.may_proceed(), second.may_proceed());
1053 assert_eq!(
1054 first.result().unwrap().exit_code,
1055 second.result().unwrap().exit_code
1056 );
1057 assert_eq!(
1058 first.result().unwrap().passed,
1059 second.result().unwrap().passed
1060 );
1061 }
1062
1063 #[test]
1064 fn validate_error_display() {
1065 let e = ValidateError::WorktreeCreate("bad".into());
1066 assert!(format!("{e}").contains("temp worktree"));
1067 assert!(format!("{e}").contains("bad"));
1068
1069 let e = ValidateError::CommandSpawn("oops".into());
1070 assert!(format!("{e}").contains("spawn command"));
1071
1072 let e = ValidateError::ArtifactWrite("disk full".into());
1073 assert!(format!("{e}").contains("artifact"));
1074 assert!(format!("{e}").contains("disk full"));
1075 }
1076
1077 #[test]
1080 fn config_effective_commands_single() {
1081 let config = ValidationConfig {
1082 command: Some("cargo check".into()),
1083 commands: Vec::new(),
1084 timeout_seconds: 60,
1085 preset: None,
1086 on_failure: OnFailure::Block,
1087 };
1088 assert_eq!(config.effective_commands(), vec!["cargo check"]);
1089 }
1090
1091 #[test]
1092 fn config_effective_commands_array() {
1093 let config = ValidationConfig {
1094 command: None,
1095 commands: vec!["cargo check".into(), "cargo test".into()],
1096 timeout_seconds: 60,
1097 preset: None,
1098 on_failure: OnFailure::Block,
1099 };
1100 assert_eq!(
1101 config.effective_commands(),
1102 vec!["cargo check", "cargo test"]
1103 );
1104 }
1105
1106 #[test]
1107 fn config_effective_commands_both() {
1108 let config = ValidationConfig {
1109 command: Some("cargo fmt --check".into()),
1110 commands: vec!["cargo check".into(), "cargo test".into()],
1111 timeout_seconds: 60,
1112 preset: None,
1113 on_failure: OnFailure::Block,
1114 };
1115 assert_eq!(
1116 config.effective_commands(),
1117 vec!["cargo fmt --check", "cargo check", "cargo test"]
1118 );
1119 }
1120
1121 #[test]
1122 fn config_effective_commands_filters_empty() {
1123 let config = ValidationConfig {
1124 command: Some(String::new()),
1125 commands: vec![String::new(), "cargo test".into(), String::new()],
1126 timeout_seconds: 60,
1127 preset: None,
1128 on_failure: OnFailure::Block,
1129 };
1130 assert_eq!(config.effective_commands(), vec!["cargo test"]);
1131 }
1132
1133 #[test]
1134 fn config_has_commands() {
1135 let empty = ValidationConfig::default();
1136 assert!(!empty.has_commands());
1137
1138 let with_cmd = ValidationConfig {
1139 command: Some("test".into()),
1140 commands: Vec::new(),
1141 timeout_seconds: 60,
1142 preset: None,
1143 on_failure: OnFailure::Block,
1144 };
1145 assert!(with_cmd.has_commands());
1146
1147 let with_cmds = ValidationConfig {
1148 command: None,
1149 commands: vec!["test".into()],
1150 timeout_seconds: 60,
1151 preset: None,
1152 on_failure: OnFailure::Block,
1153 };
1154 assert!(with_cmds.has_commands());
1155 }
1156
1157 #[test]
1160 fn detect_preset_rust_from_cargo_toml() {
1161 let dir = tempfile::tempdir().unwrap();
1162 std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
1163 assert_eq!(
1164 detect_language_preset(dir.path()),
1165 Some(LanguagePreset::Rust)
1166 );
1167 }
1168
1169 #[test]
1170 fn detect_preset_python_from_pyproject_toml() {
1171 let dir = tempfile::tempdir().unwrap();
1172 std::fs::write(dir.path().join("pyproject.toml"), "[project]\nname=\"x\"\n").unwrap();
1173 assert_eq!(
1174 detect_language_preset(dir.path()),
1175 Some(LanguagePreset::Python)
1176 );
1177 }
1178
1179 #[test]
1180 fn detect_preset_python_from_setup_py() {
1181 let dir = tempfile::tempdir().unwrap();
1182 std::fs::write(
1183 dir.path().join("setup.py"),
1184 "from setuptools import setup\n",
1185 )
1186 .unwrap();
1187 assert_eq!(
1188 detect_language_preset(dir.path()),
1189 Some(LanguagePreset::Python)
1190 );
1191 }
1192
1193 #[test]
1194 fn detect_preset_python_from_setup_cfg() {
1195 let dir = tempfile::tempdir().unwrap();
1196 std::fs::write(dir.path().join("setup.cfg"), "[metadata]\nname=x\n").unwrap();
1197 assert_eq!(
1198 detect_language_preset(dir.path()),
1199 Some(LanguagePreset::Python)
1200 );
1201 }
1202
1203 #[test]
1204 fn detect_preset_typescript_from_tsconfig() {
1205 let dir = tempfile::tempdir().unwrap();
1206 std::fs::write(dir.path().join("tsconfig.json"), "{}\n").unwrap();
1207 assert_eq!(
1208 detect_language_preset(dir.path()),
1209 Some(LanguagePreset::TypeScript)
1210 );
1211 }
1212
1213 #[test]
1214 fn detect_preset_returns_none_for_unknown_project() {
1215 let dir = tempfile::tempdir().unwrap();
1216 std::fs::write(dir.path().join("README.md"), "# hello\n").unwrap();
1217 assert_eq!(detect_language_preset(dir.path()), None);
1218 }
1219
1220 #[test]
1221 fn detect_preset_rust_wins_over_python_when_both_present() {
1222 let dir = tempfile::tempdir().unwrap();
1224 std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
1225 std::fs::write(dir.path().join("pyproject.toml"), "[project]\nname=\"x\"\n").unwrap();
1226 assert_eq!(
1227 detect_language_preset(dir.path()),
1228 Some(LanguagePreset::Rust)
1229 );
1230 }
1231
1232 #[test]
1235 fn resolve_explicit_commands_take_precedence_over_preset() {
1236 let dir = tempfile::tempdir().unwrap();
1237 std::fs::write(dir.path().join("Cargo.toml"), "[package]\n").unwrap();
1239 let config = ValidationConfig {
1240 command: Some("make test".into()),
1241 commands: Vec::new(),
1242 preset: Some(LanguagePreset::Rust),
1243 timeout_seconds: 60,
1244 on_failure: OnFailure::Block,
1245 };
1246 let cmds = resolve_commands(&config, dir.path());
1247 assert_eq!(cmds, vec!["make test"]);
1248 }
1249
1250 #[test]
1251 fn resolve_named_preset_rust() {
1252 let dir = tempfile::tempdir().unwrap();
1253 let config = ValidationConfig {
1254 command: None,
1255 commands: Vec::new(),
1256 preset: Some(LanguagePreset::Rust),
1257 timeout_seconds: 60,
1258 on_failure: OnFailure::Block,
1259 };
1260 let cmds = resolve_commands(&config, dir.path());
1261 assert_eq!(cmds, vec!["cargo check", "cargo test --no-run"]);
1262 }
1263
1264 #[test]
1265 fn resolve_named_preset_python() {
1266 let dir = tempfile::tempdir().unwrap();
1267 let config = ValidationConfig {
1268 command: None,
1269 commands: Vec::new(),
1270 preset: Some(LanguagePreset::Python),
1271 timeout_seconds: 60,
1272 on_failure: OnFailure::Block,
1273 };
1274 let cmds = resolve_commands(&config, dir.path());
1275 assert_eq!(cmds, vec!["python -m py_compile", "pytest -q --co"]);
1276 }
1277
1278 #[test]
1279 fn resolve_named_preset_typescript() {
1280 let dir = tempfile::tempdir().unwrap();
1281 let config = ValidationConfig {
1282 command: None,
1283 commands: Vec::new(),
1284 preset: Some(LanguagePreset::TypeScript),
1285 timeout_seconds: 60,
1286 on_failure: OnFailure::Block,
1287 };
1288 let cmds = resolve_commands(&config, dir.path());
1289 assert_eq!(cmds, vec!["tsc --noEmit"]);
1290 }
1291
1292 #[test]
1293 fn resolve_auto_preset_detects_rust() {
1294 let dir = tempfile::tempdir().unwrap();
1295 std::fs::write(dir.path().join("Cargo.toml"), "[package]\n").unwrap();
1296 let config = ValidationConfig {
1297 command: None,
1298 commands: Vec::new(),
1299 preset: Some(LanguagePreset::Auto),
1300 timeout_seconds: 60,
1301 on_failure: OnFailure::Block,
1302 };
1303 let cmds = resolve_commands(&config, dir.path());
1304 assert_eq!(cmds, vec!["cargo check", "cargo test --no-run"]);
1305 }
1306
1307 #[test]
1308 fn resolve_auto_preset_detects_python() {
1309 let dir = tempfile::tempdir().unwrap();
1310 std::fs::write(dir.path().join("pyproject.toml"), "[project]\n").unwrap();
1311 let config = ValidationConfig {
1312 command: None,
1313 commands: Vec::new(),
1314 preset: Some(LanguagePreset::Auto),
1315 timeout_seconds: 60,
1316 on_failure: OnFailure::Block,
1317 };
1318 let cmds = resolve_commands(&config, dir.path());
1319 assert_eq!(cmds, vec!["python -m py_compile", "pytest -q --co"]);
1320 }
1321
1322 #[test]
1323 fn resolve_auto_preset_detects_typescript() {
1324 let dir = tempfile::tempdir().unwrap();
1325 std::fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
1326 let config = ValidationConfig {
1327 command: None,
1328 commands: Vec::new(),
1329 preset: Some(LanguagePreset::Auto),
1330 timeout_seconds: 60,
1331 on_failure: OnFailure::Block,
1332 };
1333 let cmds = resolve_commands(&config, dir.path());
1334 assert_eq!(cmds, vec!["tsc --noEmit"]);
1335 }
1336
1337 #[test]
1338 fn resolve_auto_preset_unknown_project_returns_empty() {
1339 let dir = tempfile::tempdir().unwrap();
1340 let config = ValidationConfig {
1342 command: None,
1343 commands: Vec::new(),
1344 preset: Some(LanguagePreset::Auto),
1345 timeout_seconds: 60,
1346 on_failure: OnFailure::Block,
1347 };
1348 let cmds = resolve_commands(&config, dir.path());
1349 assert!(cmds.is_empty());
1350 }
1351
1352 #[test]
1353 fn resolve_no_preset_no_commands_returns_empty() {
1354 let dir = tempfile::tempdir().unwrap();
1355 let config = ValidationConfig::default();
1356 let cmds = resolve_commands(&config, dir.path());
1357 assert!(cmds.is_empty());
1358 }
1359
1360 #[test]
1363 fn config_in_dir_skipped_with_no_config() {
1364 let dir = tempfile::tempdir().unwrap();
1365 let config = ValidationConfig::default();
1366 let outcome = run_validate_config_in_dir(&config, dir.path()).unwrap();
1367 assert!(matches!(outcome, ValidateOutcome::Skipped));
1368 }
1369
1370 #[test]
1371 fn config_in_dir_skipped_when_auto_finds_nothing() {
1372 let dir = tempfile::tempdir().unwrap();
1373 let config = ValidationConfig {
1375 command: None,
1376 commands: Vec::new(),
1377 preset: Some(LanguagePreset::Auto),
1378 timeout_seconds: 60,
1379 on_failure: OnFailure::Block,
1380 };
1381 let outcome = run_validate_config_in_dir(&config, dir.path()).unwrap();
1382 assert!(matches!(outcome, ValidateOutcome::Skipped));
1383 }
1384
1385 #[test]
1386 fn config_in_dir_explicit_commands_ignore_preset() {
1387 let dir = tempfile::tempdir().unwrap();
1388 std::fs::write(dir.path().join("Cargo.toml"), "[package]\n").unwrap();
1390 let config = ValidationConfig {
1391 command: Some("echo explicit".into()),
1392 commands: Vec::new(),
1393 preset: Some(LanguagePreset::Rust),
1394 timeout_seconds: 10,
1395 on_failure: OnFailure::Block,
1396 };
1397 let outcome = run_validate_config_in_dir(&config, dir.path()).unwrap();
1398 assert!(matches!(outcome, ValidateOutcome::Passed(_)));
1399 let result = outcome.result().unwrap();
1400 assert!(result.stdout.contains("explicit"));
1401 assert!(result.command_results.is_empty());
1403 }
1404
1405 #[test]
1406 fn config_in_dir_multi_command_explicit_with_preset_ignored() {
1407 let dir = tempfile::tempdir().unwrap();
1408 let config = ValidationConfig {
1409 command: None,
1410 commands: vec!["echo step1".into(), "echo step2".into()],
1411 preset: Some(LanguagePreset::TypeScript), timeout_seconds: 10,
1413 on_failure: OnFailure::Block,
1414 };
1415 let outcome = run_validate_config_in_dir(&config, dir.path()).unwrap();
1416 assert!(matches!(outcome, ValidateOutcome::Passed(_)));
1417 let result = outcome.result().unwrap();
1418 assert_eq!(result.command_results.len(), 2);
1419 assert!(result.command_results[0].stdout.contains("step1"));
1420 assert!(result.command_results[1].stdout.contains("step2"));
1421 }
1422
1423 #[test]
1424 fn config_in_dir_auto_preset_not_skipped_when_marker_found() {
1425 let dir = tempfile::tempdir().unwrap();
1429 std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
1430 let config = ValidationConfig {
1431 command: None,
1432 commands: Vec::new(),
1433 preset: Some(LanguagePreset::Auto),
1434 timeout_seconds: 5,
1435 on_failure: OnFailure::Warn,
1436 };
1437 let outcome = run_validate_config_in_dir(&config, dir.path()).unwrap();
1438 assert!(!matches!(outcome, ValidateOutcome::Skipped));
1440 }
1441}