Skip to main content

maw/merge/
validate.rs

1//! VALIDATE phase of the epoch advancement state machine.
2//!
3//! Materializes the candidate commit into a temporary git worktree, runs
4//! the configured validation command(s) with a timeout, and enforces the
5//! `on_failure` policy.
6//!
7//! # Multi-command pipelines
8//!
9//! When multiple commands are configured (via the `commands` array or both
10//! `command` and `commands`), they run in sequence. Execution stops on the
11//! first failure. Each command's result is captured individually.
12//!
13//! # Crash safety
14//!
15//! If a crash occurs during VALIDATE:
16//!
17//! - The merge-state file records `Validate` phase.
18//! - Recovery re-runs validation (inputs are frozen in PREPARE, so this is
19//!   safe and deterministic).
20//! - Temp worktrees are cleaned up on recovery.
21//!
22//! # Process
23//!
24//! 1. Create a temporary git worktree at the candidate commit.
25//! 2. Run validation command(s) via `sh -c` with per-command timeout.
26//! 3. Capture stdout, stderr, exit code, and wall-clock duration for each.
27//! 4. Record the [`ValidationResult`] in the merge-state file.
28//! 5. Write diagnostics to `.manifold/artifacts/merge/<id>/validation.json`.
29//! 6. Enforce the [`OnFailure`] policy.
30//! 7. Clean up the temporary worktree.
31
32#![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// ---------------------------------------------------------------------------
45// ValidateOutcome
46// ---------------------------------------------------------------------------
47
48/// The outcome of the VALIDATE phase after applying the `on_failure` policy.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub enum ValidateOutcome {
51    /// No validation command configured — validation is skipped.
52    Skipped,
53    /// Validation passed (all commands exited 0).
54    Passed(ValidationResult),
55    /// Validation failed but policy is `Warn` — merge may continue.
56    PassedWithWarnings(ValidationResult),
57    /// Validation failed and policy blocks the merge.
58    Blocked(ValidationResult),
59    /// Validation failed and policy requests quarantine.
60    Quarantine(ValidationResult),
61    /// Validation failed and policy blocks + quarantines.
62    BlockedAndQuarantine(ValidationResult),
63}
64
65impl ValidateOutcome {
66    /// Returns `true` if the merge should proceed (passed, skipped, or warn).
67    #[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    /// Returns `true` if a quarantine workspace should be created.
76    #[must_use]
77    pub const fn needs_quarantine(&self) -> bool {
78        matches!(self, Self::Quarantine(_) | Self::BlockedAndQuarantine(_))
79    }
80
81    /// Extract the validation result, if any.
82    #[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// ---------------------------------------------------------------------------
96// ValidateError
97// ---------------------------------------------------------------------------
98
99/// Errors that can occur during the VALIDATE phase.
100#[derive(Clone, Debug, PartialEq, Eq)]
101pub enum ValidateError {
102    /// Failed to create the temporary worktree.
103    WorktreeCreate(String),
104    /// Failed to remove the temporary worktree.
105    WorktreeRemove(String),
106    /// Failed to spawn the validation command.
107    CommandSpawn(String),
108    /// Merge-state I/O error.
109    State(MergeStateError),
110    /// Artifacts I/O error.
111    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
142// ---------------------------------------------------------------------------
143// Temp worktree helpers
144// ---------------------------------------------------------------------------
145
146/// Create a temporary detached git worktree at the given commit.
147fn 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
174/// Remove a temporary git worktree.
175fn 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// ---------------------------------------------------------------------------
198// Per-language preset auto-detection
199// ---------------------------------------------------------------------------
200
201/// Detect the language preset for a project directory by inspecting
202/// well-known marker files.
203///
204/// Detection order (first match wins):
205/// 1. `Cargo.toml` → [`LanguagePreset::Rust`]
206/// 2. `pyproject.toml` / `setup.py` / `setup.cfg` → [`LanguagePreset::Python`]
207/// 3. `tsconfig.json` → [`LanguagePreset::TypeScript`]
208///
209/// Returns `None` if no known marker is found.
210#[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/// Resolve the effective command list for a validation config in a given
228/// directory, incorporating preset auto-detection.
229///
230/// Resolution order:
231/// 1. Explicit `command`/`commands` (always wins).
232/// 2. Named preset (`rust`, `python`, `typescript`).
233/// 3. `auto` preset — detect from directory markers.
234/// 4. No commands (empty — validation is skipped).
235#[must_use]
236pub fn resolve_commands(config: &ValidationConfig, worktree_dir: &Path) -> Vec<String> {
237    // Explicit commands always take precedence.
238    let explicit = config.effective_commands();
239    if !explicit.is_empty() {
240        return explicit.into_iter().map(str::to_owned).collect();
241    }
242
243    // Fall back to preset.
244    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
255// ---------------------------------------------------------------------------
256// run_validate_phase
257// ---------------------------------------------------------------------------
258
259/// Execute the VALIDATE phase of the merge state machine.
260///
261/// 1. If no validation command is configured, return [`ValidateOutcome::Skipped`].
262/// 2. Create a temporary git worktree at `candidate_oid`.
263/// 3. Run validation command(s) in sequence with per-command timeout.
264/// 4. Capture diagnostics (stdout, stderr, exit code, timing) per command.
265/// 5. Apply the `on_failure` policy.
266/// 6. Clean up the temporary worktree.
267///
268/// # Arguments
269///
270/// * `repo_root` - Path to the git repository root.
271/// * `candidate_oid` - The candidate merge commit to validate.
272/// * `config` - The validation configuration from `.manifold/config.toml`.
273///
274/// # Returns
275///
276/// A [`ValidateOutcome`] describing the result and policy decision.
277///
278/// # Errors
279///
280/// Returns [`ValidateError`] on worktree or command spawn failures.
281pub fn run_validate_phase(
282    repo_root: &Path,
283    candidate_oid: &GitOid,
284    config: &ValidationConfig,
285) -> Result<ValidateOutcome, ValidateError> {
286    // 2. Create temp worktree first (needed for preset auto-detection)
287    let worktree_dir = repo_root.join(".manifold").join("validate-tmp");
288    // Clean up any stale worktree from a previous crash
289    if worktree_dir.exists() {
290        let _ = remove_temp_worktree(repo_root, &worktree_dir);
291        // Also try just removing the directory if git worktree remove failed
292        let _ = fs::remove_dir_all(&worktree_dir);
293    }
294
295    // 1. Resolve commands — includes preset auto-detection against the
296    //    worktree dir if `preset = "auto"`.  We need the worktree to exist
297    //    for auto-detection, but we only create it when there might be
298    //    commands to run. If explicit commands are configured we skip
299    //    auto-detection entirely.
300    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    // Resolve the full command list (explicit wins; preset is fallback).
308    let commands = resolve_commands(config, &worktree_dir);
309    if commands.is_empty() {
310        // Preset was configured but auto-detection found nothing.
311        let _ = remove_temp_worktree(repo_root, &worktree_dir);
312        let _ = fs::remove_dir_all(&worktree_dir);
313        return Ok(ValidateOutcome::Skipped);
314    }
315
316    // 3. Run validation commands in sequence
317    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    // 4. Clean up worktree (best-effort)
322    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    // 5. Apply on_failure policy
329    Ok(apply_policy(&result, &config.on_failure))
330}
331
332/// Run the VALIDATE phase without creating a real git worktree.
333///
334/// Instead of calling `git worktree`, runs the command(s) in the provided
335/// directory. Useful for testing the validation logic without a git repo.
336pub 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
346/// Run multiple validation commands in a directory and return the aggregate
347/// result. Useful for testing multi-command pipelines without a git repo.
348pub 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
358/// Run the full validation config (including preset resolution) in a
359/// directory without creating a git worktree.
360///
361/// This is the testing counterpart of [`run_validate_phase`]: it exercises
362/// the complete command-resolution path (explicit → preset → auto-detect)
363/// in an isolated temp directory.
364pub 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
377// ---------------------------------------------------------------------------
378// Diagnostics / artifacts
379// ---------------------------------------------------------------------------
380
381/// Write validation diagnostics to the artifacts directory.
382///
383/// Writes to `.manifold/artifacts/merge/<merge_id>/validation.json`.
384/// The write is atomic (write-to-temp + rename).
385///
386/// # Arguments
387///
388/// * `manifold_dir` - Path to the `.manifold/` directory.
389/// * `merge_id` - An identifier for this merge (typically the candidate OID
390///   or a derived hash).
391/// * `result` - The validation result to persist.
392///
393/// # Errors
394///
395/// Returns [`ValidateError::ArtifactWrite`] on I/O failure. This is
396/// non-fatal — callers may choose to log and continue.
397pub 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
432// ---------------------------------------------------------------------------
433// Internal: command execution pipeline
434// ---------------------------------------------------------------------------
435
436/// Run multiple commands in sequence, stopping on first failure.
437///
438/// Returns a single [`ValidationResult`] summarizing the pipeline, plus
439/// per-command [`CommandResult`] entries.
440fn 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; // Stop on first failure
456        }
457    }
458
459    // Summarize: top-level fields reflect the first failing command
460    // (or the last command if all passed)
461    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            // For single-command runs, omit per-command results for
479            // backward compatibility with existing merge-state files.
480            Vec::new()
481        },
482    })
483}
484
485/// Run a single shell command with timeout, capturing all output.
486fn 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    // Wait with timeout
503    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                // Still running — check timeout
540                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
566/// Apply the `on_failure` policy to a validation result.
567fn 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// ---------------------------------------------------------------------------
581// Tests
582// ---------------------------------------------------------------------------
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    // -- ValidateOutcome --
589
590    #[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    // -- Single command: run_validate_in_dir --
659
660    #[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()); // killed by timeout
718        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    // -- run_validate_phase skip scenarios --
763
764    #[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    // -- Multi-command pipeline --
820
821    #[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        // Only 2 commands ran (the third was skipped)
855        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        // Total duration should be at least the sum of per-command durations
900        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    // -- Single command backward compatibility --
931
932    #[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        // Single-command runs don't populate command_results for backward compat
938        assert!(result.command_results.is_empty());
939    }
940
941    // -- Artifacts --
942
943    #[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        // Verify contents
965        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    // -- Error display --
1041
1042    #[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    // -- Config integration --
1078
1079    #[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    // -- detect_language_preset --
1158
1159    #[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        // Cargo.toml takes precedence (first in detection order)
1223        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    // -- resolve_commands --
1233
1234    #[test]
1235    fn resolve_explicit_commands_take_precedence_over_preset() {
1236        let dir = tempfile::tempdir().unwrap();
1237        // Cargo.toml present — would trigger Rust preset — but explicit command wins.
1238        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        // No marker files
1341        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    // -- run_validate_config_in_dir (preset integration) --
1361
1362    #[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        // No marker files — auto-detect returns None → skipped
1374        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        // Rust preset present but explicit commands win
1389        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        // Single-command run: command_results empty for backward compat
1402        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), // ignored — explicit commands present
1412            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        // Create a dir with Cargo.toml so auto-detection fires.
1426        // The Rust preset commands will likely fail (not a real project),
1427        // but with Warn policy the outcome is PassedWithWarnings (not Skipped).
1428        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        // Must NOT be skipped — preset was resolved
1439        assert!(!matches!(outcome, ValidateOutcome::Skipped));
1440    }
1441}