Skip to main content

vfa_tui/gates/
executor.rs

1//! Gate DAG execution — parallel gate runner with cascading failure propagation.
2//!
3//! # Design: injectable runner
4//!
5//! The core execution logic is parameterised over a `GateRunner` trait so that:
6//! - **Production** path uses [`SubprocessRunner`] which delegates to
7//!   [`SubprocessExecutor::spawn`] (no shell, sanitized env).
8//! - **Tests / property tests** inject a [`MockRunner`] (or any closure) to
9//!   produce deterministic pass/fail outcomes without spawning real processes.
10//!
11//! `GateRunner` uses `BoxFuture` (via `Pin<Box<dyn Future>>`) to remain dyn-compatible
12//! without requiring the `async_trait` crate.
13
14use std::collections::{HashMap, HashSet};
15use std::future::Future;
16use std::path::Path;
17use std::pin::Pin;
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21use futures_util::future::join_all;
22use tokio::sync::Semaphore;
23
24use crate::models::gate::{DagGateStatus, GateDAG, GateDefinition, GateResult};
25use crate::subprocess::SubprocessExecutor;
26
27// ─────────────────────────────────────────────────────────────────────────────
28// GateRunOutcome — headless report row (Req 2.7)
29// ─────────────────────────────────────────────────────────────────────────────
30
31/// Per-gate report row suitable for headless `--report gates` JSON output.
32///
33/// All fields are serde-serializable. `duration_ms` uses `u64` so it is easy
34/// to consume from JSON (no need to parse a duration string).
35#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
36pub struct GateRunOutcome {
37    /// Unique gate name within the DAG.
38    pub name: String,
39    /// Terminal status after execution (snake_case serialization).
40    pub status: DagGateStatus,
41    /// Wall-clock execution time in milliseconds.
42    pub duration_ms: u64,
43    /// Names of prerequisite gates.
44    pub dependencies: Vec<String>,
45    /// Human-readable reason the gate was skipped, or `None`.
46    pub skip_reason: Option<String>,
47}
48
49impl GateRunOutcome {
50    /// Build a `GateRunOutcome` from a completed `GateResult` and its definition.
51    pub fn from_result(result: &GateResult, def: &GateDefinition) -> Self {
52        Self {
53            name: result.name.clone(),
54            status: result.status.clone(),
55            duration_ms: result.duration.as_millis() as u64,
56            dependencies: def.dependencies.clone(),
57            skip_reason: result.skip_reason.clone(),
58        }
59    }
60}
61
62// ─────────────────────────────────────────────────────────────────────────────
63// GateRunner trait
64// ─────────────────────────────────────────────────────────────────────────────
65
66/// Abstraction over the mechanism used to actually execute a single gate.
67///
68/// Uses a `BoxFuture` return type to remain dyn-compatible without the
69/// `async_trait` crate.
70pub trait GateRunner: Send + Sync {
71    /// Execute a single gate and return its result.
72    fn run<'a>(
73        &'a self,
74        def: &'a GateDefinition,
75        workspace_root: &'a Path,
76    ) -> Pin<Box<dyn Future<Output = GateResult> + Send + 'a>>;
77}
78
79// ─────────────────────────────────────────────────────────────────────────────
80// SubprocessRunner — production implementation
81// ─────────────────────────────────────────────────────────────────────────────
82
83/// Production gate runner that delegates to [`SubprocessExecutor`].
84///
85/// Runs `def.command` with `def.args` in `workspace_root`, bounded by
86/// `def.timeout`.  Captures combined stdout + stderr as the gate output.
87pub struct SubprocessRunner;
88
89impl GateRunner for SubprocessRunner {
90    fn run<'a>(
91        &'a self,
92        def: &'a GateDefinition,
93        workspace_root: &'a Path,
94    ) -> Pin<Box<dyn Future<Output = GateResult> + Send + 'a>> {
95        Box::pin(async move {
96            let start = Instant::now();
97            let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
98
99            let spawn_result =
100                SubprocessExecutor::spawn(&def.command, &def.args, workspace_root, def.timeout)
101                    .await;
102
103            let mut handle = match spawn_result {
104                Ok(h) => h,
105                Err(e) => {
106                    return GateResult {
107                        name: def.name.clone(),
108                        status: DagGateStatus::Failed,
109                        exit_code: None,
110                        duration: start.elapsed(),
111                        timestamp,
112                        output: format!("failed to spawn: {e}"),
113                        skip_reason: None,
114                    };
115                }
116            };
117
118            // Wait with a grace-period timeout.
119            let wait_result =
120                tokio::time::timeout(def.timeout + Duration::from_secs(5), handle.wait()).await;
121
122            let duration = start.elapsed();
123
124            // Drain any buffered output lines (best-effort).
125            let mut output_lines: Vec<String> = Vec::new();
126            while let Some(line) = handle.try_recv_stdout() {
127                output_lines.push(line.content);
128            }
129            while let Some(line) = handle.try_recv_stderr() {
130                output_lines.push(line.content);
131            }
132            let output = output_lines.join("\n");
133
134            match wait_result {
135                Ok(Ok(code)) => {
136                    let status = if code == 0 {
137                        DagGateStatus::Passed
138                    } else {
139                        DagGateStatus::Failed
140                    };
141                    GateResult {
142                        name: def.name.clone(),
143                        status,
144                        exit_code: Some(code),
145                        duration,
146                        timestamp,
147                        output,
148                        skip_reason: None,
149                    }
150                }
151                Ok(Err(e)) => GateResult {
152                    name: def.name.clone(),
153                    status: DagGateStatus::Failed,
154                    exit_code: None,
155                    duration,
156                    timestamp,
157                    output: format!("wait error: {e}"),
158                    skip_reason: None,
159                },
160                Err(_) => {
161                    handle.cancel().await.ok();
162                    GateResult {
163                        name: def.name.clone(),
164                        status: DagGateStatus::TimedOut,
165                        exit_code: None,
166                        duration,
167                        timestamp,
168                        output,
169                        skip_reason: None,
170                    }
171                }
172            }
173        })
174    }
175}
176
177// ─────────────────────────────────────────────────────────────────────────────
178// MockRunner — test double
179// ─────────────────────────────────────────────────────────────────────────────
180
181/// Deterministic gate runner for tests.
182///
183/// Given a closure `F: Fn(&str) -> DagGateStatus + Send + Sync + 'static`,
184/// produces instant results without spawning any processes.
185///
186/// # Example
187/// ```ignore
188/// let runner = MockRunner::new(|name| {
189///     if name == "lint" { DagGateStatus::Failed } else { DagGateStatus::Passed }
190/// });
191/// ```
192pub struct MockRunner<F>
193where
194    F: Fn(&str) -> DagGateStatus + Send + Sync + 'static,
195{
196    outcome_fn: F,
197}
198
199impl<F> MockRunner<F>
200where
201    F: Fn(&str) -> DagGateStatus + Send + Sync + 'static,
202{
203    /// Create a mock runner from an outcome function.
204    pub fn new(outcome_fn: F) -> Self {
205        Self { outcome_fn }
206    }
207}
208
209impl<F> GateRunner for MockRunner<F>
210where
211    F: Fn(&str) -> DagGateStatus + Send + Sync + 'static,
212{
213    fn run<'a>(
214        &'a self,
215        def: &'a GateDefinition,
216        _workspace_root: &'a Path,
217    ) -> Pin<Box<dyn Future<Output = GateResult> + Send + 'a>> {
218        let status = (self.outcome_fn)(&def.name);
219        let name = def.name.clone();
220        Box::pin(async move {
221            GateResult {
222                name,
223                status,
224                exit_code: None,
225                duration: Duration::ZERO,
226                timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
227                output: String::new(),
228                skip_reason: None,
229            }
230        })
231    }
232}
233
234// ─────────────────────────────────────────────────────────────────────────────
235// execute_all
236// ─────────────────────────────────────────────────────────────────────────────
237
238/// Execute all gates in the DAG, respecting dependency order and concurrency.
239///
240/// Gates within the same topological layer are started sequentially while
241/// honouring the semaphore-bounded `concurrency_limit`.  Dependent gates only
242/// start after their prerequisites complete.
243///
244/// # Prerequisite failure cascade (Req 2.4)
245/// When a gate FAILS or TIMES_OUT, all transitive downstream gates are marked
246/// [`DagGateStatus::Skipped`] with a `skip_reason` of `"dependency failed: <gate>"`.
247/// Skipped gates are **not** passed to the runner.
248///
249/// # Parameters
250/// - `dag`: the gate graph (from [`build_dag`]).
251/// - `concurrency_limit`: max gates running simultaneously (0 → default 4).
252/// - `workspace_root`: working directory for subprocess invocations.
253/// - `runner`: injectable runner (use [`SubprocessRunner`] in production,
254///   [`MockRunner`] in tests).
255pub async fn execute_all(
256    dag: &GateDAG,
257    concurrency_limit: usize,
258    workspace_root: &Path,
259    runner: &dyn GateRunner,
260) -> Vec<GateResult> {
261    let limit = if concurrency_limit == 0 {
262        4
263    } else {
264        concurrency_limit
265    };
266    let semaphore = Arc::new(Semaphore::new(limit));
267
268    // Map gate name → completed result.
269    let mut results: HashMap<String, GateResult> = HashMap::new();
270    // Map gate name → skip reason (populated on failure cascade).
271    let mut skip_because: HashMap<String, String> = HashMap::new();
272
273    for layer in &dag.execution_order {
274        // Before executing this layer, resolve any new skips caused by failures
275        // from previous layers.
276        for gate_name in layer {
277            if skip_because.contains_key(gate_name.as_str()) {
278                continue;
279            }
280            let def = dag.gates.iter().find(|g| &g.name == gate_name).unwrap();
281            for dep in &def.dependencies {
282                // If the dep was already skipped, propagate.
283                if let Some(reason) = skip_because.get(dep.as_str()) {
284                    skip_because.insert(gate_name.clone(), reason.clone());
285                    break;
286                }
287                // If the dep failed/timed-out, cascade.
288                if let Some(dep_result) = results.get(dep.as_str()) {
289                    if matches!(
290                        dep_result.status,
291                        DagGateStatus::Failed | DagGateStatus::TimedOut
292                    ) {
293                        skip_because.insert(gate_name.clone(), format!("dependency failed: {dep}"));
294                        break;
295                    }
296                }
297            }
298        }
299
300        let (to_run, to_skip): (Vec<_>, Vec<_>) = layer
301            .iter()
302            .partition(|name| !skip_because.contains_key(name.as_str()));
303
304        // Record skipped gates immediately and propagate further.
305        for name in &to_skip {
306            let reason = skip_because.get(name.as_str()).cloned().unwrap_or_default();
307            results.insert(
308                (*name).clone(),
309                GateResult {
310                    name: (*name).clone(),
311                    status: DagGateStatus::Skipped,
312                    exit_code: None,
313                    duration: Duration::ZERO,
314                    timestamp: chrono::Utc::now()
315                        .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
316                    output: String::new(),
317                    skip_reason: Some(reason.clone()),
318                },
319            );
320            propagate_skip(&mut skip_because, dag, name, &reason);
321        }
322
323        // Execute non-skipped gates in this layer concurrently, bounded by the semaphore.
324        let layer_futures: Vec<_> = to_run
325            .iter()
326            .map(|name| {
327                let def = dag.gates.iter().find(|g| &g.name == *name).unwrap();
328                let semaphore = Arc::clone(&semaphore);
329                let name_clone = (*name).clone();
330                async move {
331                    let _permit = semaphore.acquire().await.expect("semaphore never closed");
332                    let result = runner.run(def, workspace_root).await;
333                    (name_clone, result)
334                }
335            })
336            .collect();
337
338        let layer_results = join_all(layer_futures).await;
339        for (name, result) in layer_results {
340            // If this gate failed, propagate skip to its dependents.
341            if matches!(
342                result.status,
343                DagGateStatus::Failed | DagGateStatus::TimedOut
344            ) {
345                let reason = format!("dependency failed: {}", result.name);
346                propagate_skip(&mut skip_because, dag, &result.name, &reason);
347            }
348            results.insert(name, result);
349        }
350    }
351
352    // Return results in flattened topological order.
353    dag.execution_order
354        .iter()
355        .flatten()
356        .filter_map(|name| results.remove(name))
357        .collect()
358}
359
360/// Recursively mark all transitive dependents of `failed_gate` as skipped in
361/// `skip_because`, using the DAG adjacency map (downstream edges).
362fn propagate_skip(
363    skip_because: &mut HashMap<String, String>,
364    dag: &GateDAG,
365    failed_gate: &str,
366    reason: &str,
367) {
368    let mut stack: Vec<String> = Vec::new();
369    if let Some(dependents) = dag.adjacency.get(failed_gate) {
370        stack.extend(dependents.iter().cloned());
371    }
372    while let Some(dep) = stack.pop() {
373        if skip_because.contains_key(dep.as_str()) {
374            continue;
375        }
376        skip_because.insert(dep.clone(), reason.to_string());
377        if let Some(further) = dag.adjacency.get(dep.as_str()) {
378            stack.extend(further.iter().cloned());
379        }
380    }
381}
382
383// ─────────────────────────────────────────────────────────────────────────────
384// execute_single
385// ─────────────────────────────────────────────────────────────────────────────
386
387/// Execute a single named gate, running its unsatisfied prerequisites first.
388///
389/// Prerequisites are considered satisfied (and skipped) when the cache map
390/// contains a valid cached result for that gate.  Cache validity is determined
391/// by [`is_cache_valid`].
392///
393/// # Parameters
394/// - `gate_name`: the target gate to execute.
395/// - `dag`: the full gate graph.
396/// - `cached_results`: map from gate name → `(GateResult, content_hash)`.
397///   Pass an empty map to run all prerequisites unconditionally.
398/// - `current_hashes`: map from gate name → current content hash for validation.
399/// - `workspace_root`: working directory.
400/// - `runner`: injectable gate runner.
401pub async fn execute_single(
402    gate_name: &str,
403    dag: &GateDAG,
404    cached_results: &HashMap<String, (GateResult, String)>,
405    current_hashes: &HashMap<String, String>,
406    workspace_root: &Path,
407    runner: &dyn GateRunner,
408) -> Vec<GateResult> {
409    // Collect the transitive closure of prerequisites (including the target gate)
410    // in topological order.
411    let needed: Vec<String> = required_gates(gate_name, dag);
412
413    let mut results: Vec<GateResult> = Vec::new();
414
415    for name in &needed {
416        let def = dag.gates.iter().find(|g| &g.name == name).unwrap();
417
418        // Check if any prerequisite failed before running this gate.
419        let mut prereq_failed = false;
420        for dep in &def.dependencies {
421            if let Some(dep_result) = results.iter().find(|r| &r.name == dep) {
422                if matches!(
423                    dep_result.status,
424                    DagGateStatus::Failed | DagGateStatus::TimedOut
425                ) {
426                    prereq_failed = true;
427                    break;
428                }
429            }
430        }
431
432        // If a prerequisite failed, skip this gate.
433        if prereq_failed {
434            let failed_dep = def
435                .dependencies
436                .iter()
437                .find(|d| {
438                    results
439                        .iter()
440                        .find(|r| &r.name == *d)
441                        .map(|r| {
442                            matches!(r.status, DagGateStatus::Failed | DagGateStatus::TimedOut)
443                        })
444                        .unwrap_or(false)
445                })
446                .cloned()
447                .unwrap_or_else(|| "unknown".to_string());
448
449            results.push(GateResult {
450                name: name.clone(),
451                status: DagGateStatus::Skipped,
452                exit_code: None,
453                duration: Duration::ZERO,
454                timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
455                output: String::new(),
456                skip_reason: Some(format!("dependency failed: {failed_dep}")),
457            });
458            continue;
459        }
460
461        // Check cache validity.
462        if let Some((cached_result, cached_hash)) = cached_results.get(name.as_str()) {
463            if matches!(cached_result.status, DagGateStatus::Passed) {
464                let current_hash = current_hashes
465                    .get(name.as_str())
466                    .map(|s| s.as_str())
467                    .unwrap_or("");
468                if is_cache_valid(cached_hash, current_hash) {
469                    results.push(cached_result.clone());
470                    continue;
471                }
472            }
473        }
474
475        let result = runner.run(def, workspace_root).await;
476        results.push(result);
477    }
478
479    results
480}
481
482/// Return the ordered list of gates that must run to satisfy `gate_name`,
483/// including itself, in topological order (prerequisites first).
484fn required_gates(gate_name: &str, dag: &GateDAG) -> Vec<String> {
485    let mut needed: HashSet<String> = HashSet::new();
486    let mut stack = vec![gate_name.to_string()];
487    while let Some(current) = stack.pop() {
488        if needed.contains(&current) {
489            continue;
490        }
491        needed.insert(current.clone());
492        if let Some(def) = dag.gates.iter().find(|g| g.name == current) {
493            for dep in &def.dependencies {
494                stack.push(dep.clone());
495            }
496        }
497    }
498
499    // Return in topological order as defined in the DAG.
500    dag.execution_order
501        .iter()
502        .flatten()
503        .filter(|name| needed.contains(name.as_str()))
504        .cloned()
505        .collect()
506}
507
508// ─────────────────────────────────────────────────────────────────────────────
509// is_cache_valid
510// ─────────────────────────────────────────────────────────────────────────────
511
512/// Return `true` if the cached content hash still matches the current hash.
513///
514/// An empty `current_hash` always invalidates the cache to avoid silently
515/// accepting a stale result when the hash cannot be computed.
516pub fn is_cache_valid(cached_hash: &str, current_hash: &str) -> bool {
517    !current_hash.is_empty() && cached_hash == current_hash
518}
519
520// ─────────────────────────────────────────────────────────────────────────────
521// Tests
522// ─────────────────────────────────────────────────────────────────────────────
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use crate::gates::dag::{build_dag, GateDagExecutor};
528    use std::time::Duration;
529    use tempfile::TempDir;
530
531    fn make_gate(name: &str, deps: &[&str]) -> GateDefinition {
532        GateDefinition {
533            name: name.to_string(),
534            command: "echo".to_string(),
535            args: vec![name.to_string()],
536            dependencies: deps.iter().map(|s| s.to_string()).collect(),
537            timeout: Duration::from_secs(60),
538            description: String::new(),
539        }
540    }
541
542    fn all_pass() -> MockRunner<impl Fn(&str) -> DagGateStatus + Send + Sync + 'static> {
543        MockRunner::new(|_| DagGateStatus::Passed)
544    }
545
546    fn fail_on(
547        failing: &'static str,
548    ) -> MockRunner<impl Fn(&str) -> DagGateStatus + Send + Sync + 'static> {
549        MockRunner::new(move |name| {
550            if name == failing {
551                DagGateStatus::Failed
552            } else {
553                DagGateStatus::Passed
554            }
555        })
556    }
557
558    // ── is_cache_valid ────────────────────────────────────────────────────────
559
560    #[test]
561    fn cache_valid_same_hash() {
562        assert!(is_cache_valid("abc123", "abc123"));
563    }
564
565    #[test]
566    fn cache_invalid_different_hash() {
567        assert!(!is_cache_valid("abc123", "def456"));
568    }
569
570    #[test]
571    fn cache_invalid_empty_current() {
572        assert!(!is_cache_valid("abc123", ""));
573    }
574
575    #[test]
576    fn cache_invalid_both_empty() {
577        assert!(!is_cache_valid("", ""));
578    }
579
580    // ── GateRunOutcome ────────────────────────────────────────────────────────
581
582    #[test]
583    fn gate_run_outcome_from_result() {
584        let def = make_gate("lint", &[]);
585        let result = GateResult {
586            name: "lint".to_string(),
587            status: DagGateStatus::Passed,
588            exit_code: Some(0),
589            duration: Duration::from_millis(250),
590            timestamp: "2025-01-01T00:00:00.000Z".to_string(),
591            output: String::new(),
592            skip_reason: None,
593        };
594        let outcome = GateRunOutcome::from_result(&result, &def);
595        assert_eq!(outcome.name, "lint");
596        assert_eq!(outcome.duration_ms, 250);
597        assert!(outcome.dependencies.is_empty());
598    }
599
600    #[test]
601    fn gate_run_outcome_serde_round_trip() {
602        let def = make_gate("types", &["lint"]);
603        let result = GateResult {
604            name: "types".to_string(),
605            status: DagGateStatus::Skipped,
606            exit_code: None,
607            duration: Duration::ZERO,
608            timestamp: "2025-01-01T00:01:00.000Z".to_string(),
609            output: String::new(),
610            skip_reason: Some("dependency failed: lint".to_string()),
611        };
612        let outcome = GateRunOutcome::from_result(&result, &def);
613        let json = serde_json::to_string(&outcome).unwrap();
614        let decoded: GateRunOutcome = serde_json::from_str(&json).unwrap();
615        assert_eq!(decoded.name, "types");
616        assert_eq!(decoded.status, DagGateStatus::Skipped);
617        assert_eq!(
618            decoded.skip_reason.as_deref(),
619            Some("dependency failed: lint")
620        );
621        assert_eq!(decoded.dependencies, vec!["lint"]);
622    }
623
624    // ── execute_all — basic execution ─────────────────────────────────────────
625
626    #[tokio::test]
627    async fn execute_all_empty_dag_returns_empty() {
628        let dag = build_dag(vec![]).unwrap();
629        let tmp = TempDir::new().unwrap();
630        let runner = all_pass();
631        let results = execute_all(&dag, 4, tmp.path(), &runner).await;
632        assert!(results.is_empty());
633    }
634
635    #[tokio::test]
636    async fn execute_all_single_gate_passes() {
637        let dag = build_dag(vec![make_gate("lint", &[])]).unwrap();
638        let tmp = TempDir::new().unwrap();
639        let runner = all_pass();
640        let results = execute_all(&dag, 4, tmp.path(), &runner).await;
641        assert_eq!(results.len(), 1);
642        assert_eq!(results[0].name, "lint");
643        assert_eq!(results[0].status, DagGateStatus::Passed);
644    }
645
646    #[tokio::test]
647    async fn execute_all_chain_all_pass() {
648        let gates = vec![
649            make_gate("a", &[]),
650            make_gate("b", &["a"]),
651            make_gate("c", &["b"]),
652        ];
653        let dag = build_dag(gates).unwrap();
654        let tmp = TempDir::new().unwrap();
655        let runner = all_pass();
656        let results = execute_all(&dag, 4, tmp.path(), &runner).await;
657        assert_eq!(results.len(), 3);
658        assert!(results.iter().all(|r| r.status == DagGateStatus::Passed));
659    }
660
661    // ── execute_all — prerequisite failure cascade ────────────────────────────
662
663    #[tokio::test]
664    async fn execute_all_cascade_skip_on_failure() {
665        // lint → types → integration
666        // lint FAILS → types + integration should be Skipped
667        let gates = vec![
668            make_gate("lint", &[]),
669            make_gate("types", &["lint"]),
670            make_gate("integration", &["types"]),
671        ];
672        let dag = build_dag(gates).unwrap();
673        let tmp = TempDir::new().unwrap();
674        let runner = fail_on("lint");
675        let results = execute_all(&dag, 4, tmp.path(), &runner).await;
676
677        let lint = results.iter().find(|r| r.name == "lint").unwrap();
678        let types = results.iter().find(|r| r.name == "types").unwrap();
679        let integration = results.iter().find(|r| r.name == "integration").unwrap();
680
681        assert_eq!(lint.status, DagGateStatus::Failed);
682        assert_eq!(types.status, DagGateStatus::Skipped);
683        assert_eq!(integration.status, DagGateStatus::Skipped);
684        assert!(types.skip_reason.as_deref().unwrap_or("").contains("lint"));
685    }
686
687    #[tokio::test]
688    async fn execute_all_unrelated_gate_not_skipped_on_failure() {
689        // lint → types   AND  build (independent)
690        // lint FAILS → types Skipped, but build PASSES
691        let gates = vec![
692            make_gate("lint", &[]),
693            make_gate("types", &["lint"]),
694            make_gate("build", &[]),
695        ];
696        let dag = build_dag(gates).unwrap();
697        let tmp = TempDir::new().unwrap();
698        let runner = fail_on("lint");
699        let results = execute_all(&dag, 4, tmp.path(), &runner).await;
700
701        let types = results.iter().find(|r| r.name == "types").unwrap();
702        let build = results.iter().find(|r| r.name == "build").unwrap();
703
704        assert_eq!(types.status, DagGateStatus::Skipped);
705        assert_eq!(build.status, DagGateStatus::Passed);
706    }
707
708    #[tokio::test]
709    async fn execute_all_skip_reason_contains_failing_gate_name() {
710        let gates = vec![
711            make_gate("a", &[]),
712            make_gate("b", &["a"]),
713            make_gate("c", &["b"]),
714        ];
715        let dag = build_dag(gates).unwrap();
716        let tmp = TempDir::new().unwrap();
717        let runner = fail_on("a");
718        let results = execute_all(&dag, 4, tmp.path(), &runner).await;
719
720        let b = results.iter().find(|r| r.name == "b").unwrap();
721        let c = results.iter().find(|r| r.name == "c").unwrap();
722
723        assert_eq!(b.status, DagGateStatus::Skipped);
724        assert_eq!(c.status, DagGateStatus::Skipped);
725        assert!(b
726            .skip_reason
727            .as_deref()
728            .unwrap_or("")
729            .contains("dependency failed"));
730        assert!(c
731            .skip_reason
732            .as_deref()
733            .unwrap_or("")
734            .contains("dependency failed"));
735    }
736
737    // ── execute_all — result ordering ─────────────────────────────────────────
738
739    #[tokio::test]
740    async fn execute_all_results_in_dag_order() {
741        // diamond: a → {b, c} → d
742        let gates = vec![
743            make_gate("a", &[]),
744            make_gate("b", &["a"]),
745            make_gate("c", &["a"]),
746            make_gate("d", &["b", "c"]),
747        ];
748        let dag = build_dag(gates).unwrap();
749        let tmp = TempDir::new().unwrap();
750        let runner = all_pass();
751        let results = execute_all(&dag, 4, tmp.path(), &runner).await;
752        assert_eq!(results.len(), 4);
753        let names: Vec<&str> = results.iter().map(|r| r.name.as_str()).collect();
754        let a_pos = names.iter().position(|&n| n == "a").unwrap();
755        let b_pos = names.iter().position(|&n| n == "b").unwrap();
756        let c_pos = names.iter().position(|&n| n == "c").unwrap();
757        let d_pos = names.iter().position(|&n| n == "d").unwrap();
758        assert!(a_pos < b_pos);
759        assert!(a_pos < c_pos);
760        assert!(b_pos < d_pos);
761        assert!(c_pos < d_pos);
762    }
763
764    // ── execute_single + is_cache_valid ──────────────────────────────────────
765
766    #[tokio::test]
767    async fn execute_single_uses_cache_when_valid() {
768        // lint → types
769        // lint has a valid cached result → should NOT be re-run
770        let gates = vec![make_gate("lint", &[]), make_gate("types", &["lint"])];
771        let dag = build_dag(gates).unwrap();
772        let tmp = TempDir::new().unwrap();
773
774        let cached_lint = GateResult {
775            name: "lint".to_string(),
776            status: DagGateStatus::Passed,
777            exit_code: Some(0),
778            duration: Duration::from_millis(100),
779            timestamp: "2025-01-01T00:00:00.000Z".to_string(),
780            output: String::new(),
781            skip_reason: None,
782        };
783
784        let mut cached = HashMap::new();
785        cached.insert("lint".to_string(), (cached_lint, "abc".to_string()));
786        let mut current_hashes = HashMap::new();
787        current_hashes.insert("lint".to_string(), "abc".to_string());
788
789        // Fail if "lint" is actually run — cache should prevent execution.
790        let runner = fail_on("lint");
791        let results =
792            execute_single("types", &dag, &cached, &current_hashes, tmp.path(), &runner).await;
793
794        let lint = results.iter().find(|r| r.name == "lint").unwrap();
795        assert_eq!(
796            lint.status,
797            DagGateStatus::Passed,
798            "cached result should be used"
799        );
800    }
801
802    #[tokio::test]
803    async fn execute_single_reruns_when_cache_stale() {
804        let gates = vec![make_gate("lint", &[])];
805        let dag = build_dag(gates).unwrap();
806        let tmp = TempDir::new().unwrap();
807
808        let cached_lint = GateResult {
809            name: "lint".to_string(),
810            status: DagGateStatus::Passed,
811            exit_code: Some(0),
812            duration: Duration::from_millis(100),
813            timestamp: "2025-01-01T00:00:00.000Z".to_string(),
814            output: String::new(),
815            skip_reason: None,
816        };
817
818        let mut cached = HashMap::new();
819        cached.insert("lint".to_string(), (cached_lint, "abc".to_string()));
820        let mut current_hashes = HashMap::new();
821        current_hashes.insert("lint".to_string(), "xyz".to_string()); // different → stale
822
823        let runner = all_pass();
824        let results =
825            execute_single("lint", &dag, &cached, &current_hashes, tmp.path(), &runner).await;
826
827        assert_eq!(results.len(), 1);
828        assert_eq!(results[0].status, DagGateStatus::Passed);
829    }
830
831    // ── GateDagExecutor construction ─────────────────────────────────────────
832
833    #[test]
834    fn gate_dag_executor_default_concurrency() {
835        let ex = GateDagExecutor::new(0);
836        assert_eq!(ex.concurrency_limit, 4);
837    }
838
839    #[test]
840    fn gate_dag_executor_custom_concurrency() {
841        let ex = GateDagExecutor::new(8);
842        assert_eq!(ex.concurrency_limit, 8);
843    }
844}