1use 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
36pub struct GateRunOutcome {
37 pub name: String,
39 pub status: DagGateStatus,
41 pub duration_ms: u64,
43 pub dependencies: Vec<String>,
45 pub skip_reason: Option<String>,
47}
48
49impl GateRunOutcome {
50 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
62pub trait GateRunner: Send + Sync {
71 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
79pub 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 let wait_result =
120 tokio::time::timeout(def.timeout + Duration::from_secs(5), handle.wait()).await;
121
122 let duration = start.elapsed();
123
124 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
177pub 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 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
234pub 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 let mut results: HashMap<String, GateResult> = HashMap::new();
270 let mut skip_because: HashMap<String, String> = HashMap::new();
272
273 for layer in &dag.execution_order {
274 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 let Some(reason) = skip_because.get(dep.as_str()) {
284 skip_because.insert(gate_name.clone(), reason.clone());
285 break;
286 }
287 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 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 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 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 dag.execution_order
354 .iter()
355 .flatten()
356 .filter_map(|name| results.remove(name))
357 .collect()
358}
359
360fn 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
383pub 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 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 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 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 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
482fn 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(¤t) {
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 dag.execution_order
501 .iter()
502 .flatten()
503 .filter(|name| needed.contains(name.as_str()))
504 .cloned()
505 .collect()
506}
507
508pub fn is_cache_valid(cached_hash: &str, current_hash: &str) -> bool {
517 !current_hash.is_empty() && cached_hash == current_hash
518}
519
520#[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 #[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 #[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 #[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 #[tokio::test]
664 async fn execute_all_cascade_skip_on_failure() {
665 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 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 #[tokio::test]
740 async fn execute_all_results_in_dag_order() {
741 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 #[tokio::test]
767 async fn execute_single_uses_cache_when_valid() {
768 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 let runner = fail_on("lint");
791 let results =
792 execute_single("types", &dag, &cached, ¤t_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()); let runner = all_pass();
824 let results =
825 execute_single("lint", &dag, &cached, ¤t_hashes, tmp.path(), &runner).await;
826
827 assert_eq!(results.len(), 1);
828 assert_eq!(results[0].status, DagGateStatus::Passed);
829 }
830
831 #[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}