1use crate::{Error, Result, TaskNodeData, TaskResolution, TaskResolver};
7use petgraph::algo::{is_cyclic_directed, toposort};
8use petgraph::graph::{DiGraph, NodeIndex};
9use petgraph::visit::IntoNodeReferences;
10use std::collections::{HashMap, HashSet};
11use tracing::debug;
12
13#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)]
15pub enum NodeKind {
16 #[default]
18 Task,
19 Service,
21 Image,
23}
24
25impl std::fmt::Display for NodeKind {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 match self {
28 Self::Task => write!(f, "task"),
29 Self::Service => write!(f, "service"),
30 Self::Image => write!(f, "image"),
31 }
32 }
33}
34
35#[derive(Debug, Clone)]
37pub struct GraphNode<T> {
38 pub name: String,
40 pub task: T,
42 pub kind: NodeKind,
44}
45
46pub struct TaskGraph<T: TaskNodeData> {
52 graph: DiGraph<GraphNode<T>, ()>,
54 name_to_node: HashMap<String, NodeIndex>,
56 group_children: HashMap<String, Vec<String>>,
58}
59
60impl<T: TaskNodeData> TaskGraph<T> {
61 #[must_use]
63 pub fn new() -> Self {
64 Self {
65 graph: DiGraph::new(),
66 name_to_node: HashMap::new(),
67 group_children: HashMap::new(),
68 }
69 }
70
71 fn add_node_with_kind(&mut self, name: &str, task: T, kind: NodeKind) -> Result<NodeIndex> {
78 if let Some(&existing) = self.name_to_node.get(name) {
79 let existing_kind = self.graph[existing].kind;
80 if existing_kind != kind {
81 return Err(Error::DuplicateNodeName {
82 name: name.to_string(),
83 existing_kind: existing_kind.to_string(),
84 new_kind: kind.to_string(),
85 });
86 }
87 return Ok(existing);
88 }
89
90 let node = GraphNode {
91 name: name.to_string(),
92 task,
93 kind,
94 };
95
96 let node_index = self.graph.add_node(node);
97 self.name_to_node.insert(name.to_string(), node_index);
98 debug!("Added {kind} node '{name}'");
99
100 Ok(node_index)
101 }
102
103 pub fn add_task(&mut self, name: &str, task: T) -> Result<NodeIndex> {
109 self.add_node_with_kind(name, task, NodeKind::Task)
110 }
111
112 pub fn add_service(&mut self, name: &str, task: T) -> Result<NodeIndex> {
118 self.add_node_with_kind(name, task, NodeKind::Service)
119 }
120
121 pub fn add_image(&mut self, name: &str, task: T) -> Result<NodeIndex> {
127 self.add_node_with_kind(name, task, NodeKind::Image)
128 }
129
130 pub fn get_node_mut(&mut self, index: NodeIndex) -> Option<&mut GraphNode<T>> {
132 self.graph.node_weight_mut(index)
133 }
134
135 #[must_use]
137 pub fn get_node_by_name(&self, name: &str) -> Option<&GraphNode<T>> {
138 self.name_to_node
139 .get(name)
140 .and_then(|&idx| self.graph.node_weight(idx))
141 }
142
143 pub fn register_group(&mut self, prefix: &str, children: Vec<String>) {
148 if !children.is_empty() {
149 self.group_children.insert(prefix.to_string(), children);
150 }
151 }
152
153 fn expand_dep_to_leaf_tasks(&self, dep_name: &str) -> Vec<String> {
158 if self.name_to_node.contains_key(dep_name) {
159 vec![dep_name.to_string()]
161 } else if let Some(children) = self.group_children.get(dep_name) {
162 children
164 .iter()
165 .flat_map(|child| self.expand_dep_to_leaf_tasks(child))
166 .collect()
167 } else {
168 vec![dep_name.to_string()]
170 }
171 }
172
173 pub fn add_dependency_edges(&mut self) -> Result<()> {
181 let mut missing_deps = Vec::new();
182 let mut edges_to_add = Vec::new();
183
184 for (node_index, node) in self.graph.node_references() {
186 for dep_name in node.task.dependency_names() {
187 let expanded_deps = self.expand_dep_to_leaf_tasks(dep_name);
189
190 for expanded_dep in expanded_deps {
191 if let Some(&dep_node_index) = self.name_to_node.get(&expanded_dep) {
192 edges_to_add.push((dep_node_index, node_index));
194 } else {
195 missing_deps.push((node.name.clone(), expanded_dep));
196 }
197 }
198 }
199 }
200
201 if !missing_deps.is_empty() {
203 return Err(Error::MissingDependencies {
204 missing: missing_deps,
205 });
206 }
207
208 for (from, to) in edges_to_add {
210 self.graph.add_edge(from, to, ());
211 }
212
213 Ok(())
214 }
215
216 pub fn add_edge(&mut self, from: NodeIndex, to: NodeIndex) {
221 self.graph.add_edge(from, to, ());
222 }
223
224 #[must_use]
226 pub fn has_cycles(&self) -> bool {
227 is_cyclic_directed(&self.graph)
228 }
229
230 pub fn topological_sort(&self) -> Result<Vec<GraphNode<T>>> {
236 if self.has_cycles() {
237 return Err(Error::CycleDetected {
238 message: "Task dependency graph contains cycles".to_string(),
239 });
240 }
241
242 match toposort(&self.graph, None) {
243 Ok(sorted_indices) => Ok(sorted_indices
244 .into_iter()
245 .map(|idx| self.graph[idx].clone())
246 .collect()),
247 Err(_) => Err(Error::TopologicalSortFailed {
248 reason: "petgraph toposort failed".to_string(),
249 }),
250 }
251 }
252
253 pub fn get_parallel_groups(&self) -> Result<Vec<Vec<GraphNode<T>>>> {
262 let sorted = self.topological_sort()?;
263
264 if sorted.is_empty() {
265 return Ok(vec![]);
266 }
267
268 let mut groups: Vec<Vec<GraphNode<T>>> = vec![];
270 let mut processed: HashMap<String, usize> = HashMap::new();
271
272 for task in sorted {
273 let mut level = 0;
275 for dep in task.task.dependency_names() {
276 if let Some(&dep_level) = processed.get(dep) {
277 level = level.max(dep_level + 1);
278 }
279 }
280
281 if level >= groups.len() {
283 groups.resize(level + 1, vec![]);
284 }
285 groups[level].push(task.clone());
286 processed.insert(task.name.clone(), level);
287 }
288
289 Ok(groups)
290 }
291
292 #[must_use]
294 pub fn task_count(&self) -> usize {
295 self.graph.node_count()
296 }
297
298 #[must_use]
300 pub fn contains_task(&self, name: &str) -> bool {
301 self.name_to_node.contains_key(name)
302 }
303
304 #[must_use]
306 pub fn get_node_index(&self, name: &str) -> Option<NodeIndex> {
307 self.name_to_node.get(name).copied()
308 }
309
310 pub fn get_task_mut(&mut self, name: &str) -> Option<&mut T> {
312 let idx = self.name_to_node.get(name).copied()?;
313 self.graph.node_weight_mut(idx).map(|node| &mut node.task)
314 }
315
316 pub fn iter_nodes(&self) -> impl Iterator<Item = (NodeIndex, &GraphNode<T>)> {
318 self.graph.node_references()
319 }
320
321 pub fn build_for_task<F>(&mut self, task_name: &str, mut get_task: F) -> Result<()>
335 where
336 F: FnMut(&str) -> Option<T>,
337 {
338 let mut to_process = vec![task_name.to_string()];
339 let mut processed = HashSet::new();
340
341 debug!("Building graph for '{}'", task_name);
342
343 while let Some(current_name) = to_process.pop() {
345 if processed.contains(¤t_name) {
346 continue;
347 }
348 processed.insert(current_name.clone());
349
350 if let Some(task) = get_task(¤t_name) {
351 let deps: Vec<String> = task.dependency_names().map(String::from).collect();
353
354 self.add_task(¤t_name, task)?;
355
356 for dep in deps {
358 if !processed.contains(&dep) {
359 to_process.push(dep);
360 }
361 }
362 } else {
363 debug!("Task '{}' not found while building graph", current_name);
364 }
365 }
366
367 self.add_dependency_edges()?;
369
370 Ok(())
371 }
372
373 pub fn build_for_task_with_resolver<R>(&mut self, task_name: &str, resolver: &R) -> Result<()>
387 where
388 R: TaskResolver<T>,
389 {
390 let mut to_process = vec![task_name.to_string()];
391 let mut processed = HashSet::new();
392 let mut sequential_orderings: Vec<Vec<String>> = Vec::new();
394 let mut pending_group_deps: HashMap<String, Vec<String>> = HashMap::new();
396
397 debug!("Building graph with resolver for '{}'", task_name);
398
399 while let Some(current_name) = to_process.pop() {
401 if processed.contains(¤t_name) {
402 continue;
403 }
404 processed.insert(current_name.clone());
405
406 match resolver.resolve(¤t_name) {
407 Some(TaskResolution::Single(mut task)) => {
408 let path_parts: Vec<&str> = current_name.split('.').collect();
411 for i in 1..path_parts.len() {
412 let parent_path = path_parts[..i].join(".");
413 if let Some(deps) = pending_group_deps.get(&parent_path) {
414 for dep in deps {
415 task.add_dependency(dep.clone());
416 }
417 }
418 }
419 if let Some(bracket_idx) = current_name.find('[') {
421 let parent_path = ¤t_name[..bracket_idx];
422 if let Some(deps) = pending_group_deps.get(parent_path) {
423 for dep in deps {
424 task.add_dependency(dep.clone());
425 }
426 }
427 }
428
429 self.add_task(¤t_name, task.clone())?;
430
431 for dep in task.dependency_names() {
433 if !processed.contains(dep) {
434 to_process.push(dep.to_string());
435 }
436 }
437 }
438 Some(TaskResolution::Sequential { children }) => {
439 self.register_group(¤t_name, children.clone());
440 sequential_orderings.push(children.clone());
442 for child in children {
443 if !processed.contains(&child) {
444 to_process.push(child);
445 }
446 }
447 }
448 Some(TaskResolution::Parallel {
449 children,
450 depends_on,
451 }) => {
452 self.register_group(¤t_name, children.clone());
453 if !depends_on.is_empty() {
455 pending_group_deps.insert(current_name.clone(), depends_on.clone());
456 for dep in &depends_on {
458 if !processed.contains(dep) {
459 to_process.push(dep.clone());
460 }
461 }
462 }
463 for child in children {
464 if !processed.contains(&child) {
465 to_process.push(child);
466 }
467 }
468 }
469 None => {
470 debug!("Task '{}' not found while building graph", current_name);
471 }
472 }
473 }
474
475 for ordering in sequential_orderings {
477 for window in ordering.windows(2) {
478 if let [prev, next] = window {
479 if let (Some(prev_idx), Some(next_idx)) =
481 (self.get_node_index(prev), self.get_node_index(next))
482 {
483 self.add_edge(prev_idx, next_idx);
484 }
485 }
486 }
487 }
488
489 self.add_dependency_edges()
491 }
492
493 #[allow(clippy::needless_pass_by_value)] pub fn compute_affected<F, E>(
529 &self,
530 pipeline_tasks: &[impl AsRef<str>],
531 is_directly_affected: F,
532 is_external_affected: Option<E>,
533 ) -> Vec<String>
534 where
535 F: Fn(&T) -> bool,
536 E: Fn(&str) -> bool,
537 {
538 use std::collections::HashSet;
539
540 let mut affected = HashSet::new();
541
542 for task_name in pipeline_tasks {
544 let task_name = task_name.as_ref();
545 if let Some(node) = self.get_node_by_name(task_name)
546 && is_directly_affected(&node.task)
547 {
548 affected.insert(task_name.to_string());
549 }
550 }
551
552 let mut changed = true;
554 while changed {
555 changed = false;
556 for task_name in pipeline_tasks {
557 let task_name = task_name.as_ref();
558 if affected.contains(task_name) {
559 continue;
560 }
561
562 if let Some(node) = self.get_node_by_name(task_name) {
563 for dep in node.task.dependency_names() {
564 if dep.starts_with('#') {
567 if is_external_affected
568 .as_ref()
569 .is_some_and(|resolver| resolver(dep))
570 {
571 affected.insert(task_name.to_string());
572 changed = true;
573 break;
574 }
575 continue;
576 }
577
578 let leaf_deps = self.expand_dep_to_leaf_tasks(dep);
580 for leaf_dep in leaf_deps {
581 if affected.contains(&leaf_dep) {
582 affected.insert(task_name.to_string());
583 changed = true;
584 break;
585 }
586 }
587 if changed {
588 break;
589 }
590 }
591 }
592 }
593 }
594
595 pipeline_tasks
597 .iter()
598 .map(|t| t.as_ref().to_string())
599 .filter(|t| affected.contains(t))
600 .collect()
601 }
602}
603
604impl<T: TaskNodeData> Default for TaskGraph<T> {
605 fn default() -> Self {
606 Self::new()
607 }
608}
609
610#[must_use]
639pub fn compute_transitive_closure<'a>(
640 initial: impl IntoIterator<Item = &'a str>,
641 get_deps: impl Fn(&str) -> Option<&'a [String]>,
642) -> std::collections::HashSet<String> {
643 use std::collections::HashSet;
644
645 let mut all = HashSet::new();
646 let mut frontier: Vec<&str> = Vec::new();
647
648 for name in initial {
650 if all.insert(name.to_string()) {
651 frontier.push(name);
652 }
653 }
654
655 while let Some(task_id) = frontier.pop() {
657 if let Some(deps) = get_deps(task_id) {
658 for dep in deps {
659 if all.insert(dep.clone()) {
660 frontier.push(dep.as_str());
663 }
664 }
665 }
666 }
667
668 all
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674
675 #[derive(Clone, Debug, Default)]
677 struct TestTask {
678 depends_on: Vec<String>,
679 }
680
681 impl TestTask {
682 fn new(deps: &[&str]) -> Self {
683 Self {
684 depends_on: deps.iter().map(|s| (*s).to_string()).collect(),
685 }
686 }
687 }
688
689 impl TaskNodeData for TestTask {
690 fn dependency_names(&self) -> impl Iterator<Item = &str> {
691 self.depends_on.iter().map(String::as_str)
692 }
693
694 fn add_dependency(&mut self, dep: String) {
695 if !self.depends_on.contains(&dep) {
696 self.depends_on.push(dep);
697 }
698 }
699 }
700
701 #[test]
702 fn test_task_graph_new() {
703 let graph: TaskGraph<TestTask> = TaskGraph::new();
704 assert_eq!(graph.task_count(), 0);
705 }
706
707 #[test]
708 fn test_add_single_task() {
709 let mut graph = TaskGraph::new();
710 let task = TestTask::new(&[]);
711
712 let node = graph.add_task("test", task).unwrap();
713 assert!(graph.contains_task("test"));
714 assert_eq!(graph.task_count(), 1);
715
716 let task2 = TestTask::new(&[]);
718 let node2 = graph.add_task("test", task2).unwrap();
719 assert_eq!(node, node2);
720 assert_eq!(graph.task_count(), 1);
721 }
722
723 #[test]
724 fn test_task_dependencies() {
725 let mut graph = TaskGraph::new();
726
727 let task1 = TestTask::new(&[]);
729 let task2 = TestTask::new(&["task1"]);
730 let task3 = TestTask::new(&["task1", "task2"]);
731
732 graph.add_task("task1", task1).unwrap();
733 graph.add_task("task2", task2).unwrap();
734 graph.add_task("task3", task3).unwrap();
735 graph.add_dependency_edges().unwrap();
736
737 assert_eq!(graph.task_count(), 3);
738 assert!(!graph.has_cycles());
739
740 let sorted = graph.topological_sort().unwrap();
741 assert_eq!(sorted.len(), 3);
742
743 let positions: HashMap<String, usize> = sorted
745 .iter()
746 .enumerate()
747 .map(|(i, node)| (node.name.clone(), i))
748 .collect();
749
750 assert!(positions["task1"] < positions["task2"]);
751 assert!(positions["task1"] < positions["task3"]);
752 assert!(positions["task2"] < positions["task3"]);
753 }
754
755 #[test]
756 fn test_cycle_detection() {
757 let mut graph = TaskGraph::new();
758
759 let task1 = TestTask::new(&["task3"]);
761 let task2 = TestTask::new(&["task1"]);
762 let task3 = TestTask::new(&["task2"]);
763
764 graph.add_task("task1", task1).unwrap();
765 graph.add_task("task2", task2).unwrap();
766 graph.add_task("task3", task3).unwrap();
767 graph.add_dependency_edges().unwrap();
768
769 assert!(graph.has_cycles());
770 assert!(graph.topological_sort().is_err());
771 }
772
773 #[test]
774 fn test_parallel_groups() {
775 let mut graph = TaskGraph::new();
776
777 let task1 = TestTask::new(&[]);
783 let task2 = TestTask::new(&[]);
784 let task3 = TestTask::new(&["task1"]);
785 let task4 = TestTask::new(&["task2"]);
786 let task5 = TestTask::new(&["task3", "task4"]);
787
788 graph.add_task("task1", task1).unwrap();
789 graph.add_task("task2", task2).unwrap();
790 graph.add_task("task3", task3).unwrap();
791 graph.add_task("task4", task4).unwrap();
792 graph.add_task("task5", task5).unwrap();
793 graph.add_dependency_edges().unwrap();
794
795 let groups = graph.get_parallel_groups().unwrap();
796
797 assert_eq!(groups.len(), 3);
799
800 assert_eq!(groups[0].len(), 2);
802
803 assert_eq!(groups[1].len(), 2);
805
806 assert_eq!(groups[2].len(), 1);
808 assert_eq!(groups[2][0].name, "task5");
809 }
810
811 #[test]
812 fn test_group_dependency_expansion() {
813 let mut graph = TaskGraph::new();
814
815 graph.register_group(
817 "build",
818 vec!["build.deps".to_string(), "build.compile".to_string()],
819 );
820
821 let deps_task = TestTask::new(&[]);
823 let compile_task = TestTask::new(&[]);
824 graph.add_task("build.deps", deps_task).unwrap();
825 graph.add_task("build.compile", compile_task).unwrap();
826
827 let test_task = TestTask::new(&["build"]);
829 graph.add_task("test", test_task).unwrap();
830
831 graph.add_dependency_edges().unwrap();
833
834 assert!(!graph.has_cycles());
835 assert_eq!(graph.task_count(), 3);
836
837 let sorted = graph.topological_sort().unwrap();
839 let positions: HashMap<String, usize> = sorted
840 .iter()
841 .enumerate()
842 .map(|(i, node)| (node.name.clone(), i))
843 .collect();
844
845 assert!(positions["build.deps"] < positions["test"]);
846 assert!(positions["build.compile"] < positions["test"]);
847 }
848
849 #[test]
850 fn test_missing_dependency() {
851 let mut graph = TaskGraph::new();
852
853 let task = TestTask::new(&["missing"]);
855 graph.add_task("dependent", task).unwrap();
856
857 assert!(graph.add_dependency_edges().is_err());
859 }
860
861 #[test]
862 fn test_empty_graph() {
863 let graph: TaskGraph<TestTask> = TaskGraph::new();
864
865 assert_eq!(graph.task_count(), 0);
866 assert!(!graph.has_cycles());
867
868 let groups = graph.get_parallel_groups().unwrap();
869 assert!(groups.is_empty());
870 }
871
872 #[test]
873 fn test_diamond_dependency() {
874 let mut graph = TaskGraph::new();
875
876 let task_a = TestTask::new(&[]);
883 let task_b = TestTask::new(&["a"]);
884 let task_c = TestTask::new(&["a"]);
885 let task_d = TestTask::new(&["b", "c"]);
886
887 graph.add_task("a", task_a).unwrap();
888 graph.add_task("b", task_b).unwrap();
889 graph.add_task("c", task_c).unwrap();
890 graph.add_task("d", task_d).unwrap();
891 graph.add_dependency_edges().unwrap();
892
893 assert!(!graph.has_cycles());
894 assert_eq!(graph.task_count(), 4);
895
896 let groups = graph.get_parallel_groups().unwrap();
897
898 assert_eq!(groups.len(), 3);
900 assert_eq!(groups[0].len(), 1); assert_eq!(groups[1].len(), 2); assert_eq!(groups[2].len(), 1); }
904
905 #[test]
906 fn test_self_dependency_cycle() {
907 let mut graph = TaskGraph::new();
908
909 let task = TestTask::new(&["self_ref"]);
911 graph.add_task("self_ref", task).unwrap();
912 graph.add_dependency_edges().unwrap();
913
914 assert!(graph.has_cycles());
915 assert!(graph.get_parallel_groups().is_err());
916 }
917
918 #[test]
923 fn test_shared_dependency_deduplication() {
924 let mut graph = TaskGraph::new();
925
926 let task_c = TestTask::new(&[]);
931 let task_a = TestTask::new(&["c"]);
932 let task_b = TestTask::new(&["c"]);
933
934 graph.add_task("c", task_c).unwrap();
935 graph.add_task("a", task_a).unwrap();
936 graph.add_task("b", task_b).unwrap();
937 graph.add_dependency_edges().unwrap();
938
939 assert_eq!(graph.task_count(), 3, "Should have exactly 3 tasks");
941
942 let sorted = graph.topological_sort().unwrap();
944 let c_count = sorted.iter().filter(|node| node.name == "c").count();
945 assert_eq!(c_count, 1, "Task C should appear exactly once in the DAG");
946
947 let positions: std::collections::HashMap<String, usize> = sorted
949 .iter()
950 .enumerate()
951 .map(|(i, node)| (node.name.clone(), i))
952 .collect();
953 assert!(positions["c"] < positions["a"], "C should execute before A");
954 assert!(positions["c"] < positions["b"], "C should execute before B");
955
956 let groups = graph.get_parallel_groups().unwrap();
958 assert_eq!(groups.len(), 2, "Should have 2 execution levels");
959 assert_eq!(groups[0].len(), 1, "Level 0 should have 1 task (C)");
960 assert_eq!(groups[0][0].name, "c");
961 assert_eq!(groups[1].len(), 2, "Level 1 should have 2 tasks (A and B)");
962 }
963
964 #[test]
965 fn test_build_for_task() {
966 let mut graph = TaskGraph::new();
967
968 let mut all_tasks = HashMap::new();
970 all_tasks.insert("a".to_string(), TestTask::new(&[]));
971 all_tasks.insert("b".to_string(), TestTask::new(&["a"]));
972 all_tasks.insert("c".to_string(), TestTask::new(&["b"]));
973 all_tasks.insert("d".to_string(), TestTask::new(&[])); graph
977 .build_for_task("c", |name| all_tasks.get(name).cloned())
978 .unwrap();
979
980 assert_eq!(graph.task_count(), 3);
981 assert!(graph.contains_task("a"));
982 assert!(graph.contains_task("b"));
983 assert!(graph.contains_task("c"));
984 assert!(!graph.contains_task("d"));
985 }
986
987 use crate::{TaskResolution, TaskResolver};
990
991 struct TestResolver {
993 tasks: HashMap<String, TestTask>,
994 sequential_groups: HashMap<String, Vec<String>>,
995 parallel_groups: HashMap<String, (Vec<String>, Vec<String>)>, }
997
998 impl TestResolver {
999 fn new() -> Self {
1000 Self {
1001 tasks: HashMap::new(),
1002 sequential_groups: HashMap::new(),
1003 parallel_groups: HashMap::new(),
1004 }
1005 }
1006
1007 fn add_task(&mut self, name: &str, task: TestTask) {
1008 self.tasks.insert(name.to_string(), task);
1009 }
1010
1011 fn add_sequential_group(&mut self, name: &str, children: &[&str]) {
1012 self.sequential_groups.insert(
1013 name.to_string(),
1014 children.iter().map(|s| (*s).to_string()).collect(),
1015 );
1016 }
1017
1018 fn add_parallel_group(&mut self, name: &str, children: &[&str], depends_on: &[&str]) {
1019 self.parallel_groups.insert(
1020 name.to_string(),
1021 (
1022 children.iter().map(|s| (*s).to_string()).collect(),
1023 depends_on.iter().map(|s| (*s).to_string()).collect(),
1024 ),
1025 );
1026 }
1027 }
1028
1029 impl TaskResolver<TestTask> for TestResolver {
1030 fn resolve(&self, name: &str) -> Option<TaskResolution<TestTask>> {
1031 if let Some(task) = self.tasks.get(name) {
1033 return Some(TaskResolution::Single(task.clone()));
1034 }
1035 if let Some(children) = self.sequential_groups.get(name) {
1037 return Some(TaskResolution::Sequential {
1038 children: children.clone(),
1039 });
1040 }
1041 if let Some((children, depends_on)) = self.parallel_groups.get(name) {
1043 return Some(TaskResolution::Parallel {
1044 children: children.clone(),
1045 depends_on: depends_on.clone(),
1046 });
1047 }
1048 None
1049 }
1050 }
1051
1052 #[test]
1053 fn test_resolver_single_task() {
1054 let mut resolver = TestResolver::new();
1055 resolver.add_task("build", TestTask::new(&[]));
1056 resolver.add_task("test", TestTask::new(&["build"]));
1057
1058 let mut graph = TaskGraph::new();
1059 graph
1060 .build_for_task_with_resolver("test", &resolver)
1061 .unwrap();
1062
1063 assert_eq!(graph.task_count(), 2);
1064 assert!(graph.contains_task("build"));
1065 assert!(graph.contains_task("test"));
1066
1067 let sorted = graph.topological_sort().unwrap();
1068 let positions: HashMap<String, usize> = sorted
1069 .iter()
1070 .enumerate()
1071 .map(|(i, n)| (n.name.clone(), i))
1072 .collect();
1073
1074 assert!(positions["build"] < positions["test"]);
1075 }
1076
1077 #[test]
1078 fn test_resolver_sequential_group() {
1079 let mut resolver = TestResolver::new();
1080 resolver.add_sequential_group("build", &["build[0]", "build[1]", "build[2]"]);
1082 resolver.add_task("build[0]", TestTask::new(&[]));
1083 resolver.add_task("build[1]", TestTask::new(&[]));
1084 resolver.add_task("build[2]", TestTask::new(&[]));
1085
1086 let mut graph = TaskGraph::new();
1087 graph
1088 .build_for_task_with_resolver("build", &resolver)
1089 .unwrap();
1090
1091 assert_eq!(graph.task_count(), 3);
1092
1093 let sorted = graph.topological_sort().unwrap();
1094 let positions: HashMap<String, usize> = sorted
1095 .iter()
1096 .enumerate()
1097 .map(|(i, n)| (n.name.clone(), i))
1098 .collect();
1099
1100 assert!(positions["build[0]"] < positions["build[1]"]);
1102 assert!(positions["build[1]"] < positions["build[2]"]);
1103 }
1104
1105 #[test]
1106 fn test_resolver_parallel_group() {
1107 let mut resolver = TestResolver::new();
1108 resolver.add_parallel_group(
1110 "build",
1111 &["build.frontend", "build.backend"],
1112 &[], );
1114 resolver.add_task("build.frontend", TestTask::new(&[]));
1115 resolver.add_task("build.backend", TestTask::new(&[]));
1116
1117 let mut graph = TaskGraph::new();
1118 graph
1119 .build_for_task_with_resolver("build", &resolver)
1120 .unwrap();
1121
1122 assert_eq!(graph.task_count(), 2);
1123 assert!(graph.contains_task("build.frontend"));
1124 assert!(graph.contains_task("build.backend"));
1125
1126 let groups = graph.get_parallel_groups().unwrap();
1128 assert_eq!(groups.len(), 1); assert_eq!(groups[0].len(), 2); }
1131
1132 #[test]
1133 fn test_resolver_parallel_group_with_depends_on() {
1134 let mut resolver = TestResolver::new();
1135 resolver.add_task("setup", TestTask::new(&[]));
1137 resolver.add_parallel_group(
1139 "build",
1140 &["build.frontend", "build.backend"],
1141 &["setup"], );
1143 resolver.add_task("build.frontend", TestTask::new(&[]));
1144 resolver.add_task("build.backend", TestTask::new(&[]));
1145
1146 let mut graph = TaskGraph::new();
1147 graph
1148 .build_for_task_with_resolver("build", &resolver)
1149 .unwrap();
1150
1151 assert_eq!(graph.task_count(), 3);
1152
1153 let sorted = graph.topological_sort().unwrap();
1154 let positions: HashMap<String, usize> = sorted
1155 .iter()
1156 .enumerate()
1157 .map(|(i, n)| (n.name.clone(), i))
1158 .collect();
1159
1160 assert!(positions["setup"] < positions["build.frontend"]);
1162 assert!(positions["setup"] < positions["build.backend"]);
1163 }
1164
1165 #[test]
1166 fn test_resolver_nested_groups() {
1167 let mut resolver = TestResolver::new();
1168 resolver.add_parallel_group("build", &["build.frontend", "build.backend"], &[]);
1170 resolver.add_sequential_group(
1172 "build.frontend",
1173 &["build.frontend[0]", "build.frontend[1]"],
1174 );
1175 resolver.add_task("build.frontend[0]", TestTask::new(&[]));
1176 resolver.add_task("build.frontend[1]", TestTask::new(&[]));
1177 resolver.add_task("build.backend", TestTask::new(&[]));
1178
1179 let mut graph = TaskGraph::new();
1180 graph
1181 .build_for_task_with_resolver("build", &resolver)
1182 .unwrap();
1183
1184 assert_eq!(graph.task_count(), 3);
1185
1186 let sorted = graph.topological_sort().unwrap();
1187 let positions: HashMap<String, usize> = sorted
1188 .iter()
1189 .enumerate()
1190 .map(|(i, n)| (n.name.clone(), i))
1191 .collect();
1192
1193 assert!(positions["build.frontend[0]"] < positions["build.frontend[1]"]);
1195 }
1196
1197 #[test]
1202 fn test_compute_affected_direct() {
1203 let mut graph = TaskGraph::new();
1204 graph.add_task("build", TestTask::new(&[])).unwrap();
1205 graph.add_task("test", TestTask::new(&["build"])).unwrap();
1206 graph.add_task("deploy", TestTask::new(&["test"])).unwrap();
1207 graph.add_dependency_edges().unwrap();
1208
1209 let affected = graph.compute_affected(
1211 &["build", "test", "deploy"],
1212 |task| {
1213 task.depends_on.is_empty()
1215 },
1216 None::<fn(&str) -> bool>,
1217 );
1218
1219 assert_eq!(affected, vec!["build", "test", "deploy"]);
1221 }
1222
1223 #[test]
1224 fn test_compute_affected_none() {
1225 let mut graph = TaskGraph::new();
1226 graph.add_task("build", TestTask::new(&[])).unwrap();
1227 graph.add_task("test", TestTask::new(&["build"])).unwrap();
1228 graph.add_dependency_edges().unwrap();
1229
1230 let affected =
1232 graph.compute_affected(&["build", "test"], |_task| false, None::<fn(&str) -> bool>);
1233
1234 assert!(affected.is_empty());
1235 }
1236
1237 #[test]
1238 fn test_compute_affected_preserves_pipeline_order() {
1239 let mut graph = TaskGraph::new();
1240 graph.add_task("deploy", TestTask::new(&["test"])).unwrap();
1241 graph.add_task("test", TestTask::new(&["build"])).unwrap();
1242 graph.add_task("build", TestTask::new(&[])).unwrap();
1243 graph.add_dependency_edges().unwrap();
1244
1245 let affected = graph.compute_affected(
1247 &["build", "test", "deploy"],
1248 |_| true,
1249 None::<fn(&str) -> bool>,
1250 );
1251
1252 assert_eq!(affected, vec!["build", "test", "deploy"]);
1254 }
1255
1256 #[test]
1257 fn test_compute_affected_transitive_only() {
1258 let mut graph = TaskGraph::new();
1259 graph.add_task("build", TestTask::new(&[])).unwrap();
1260 graph.add_task("test", TestTask::new(&["build"])).unwrap();
1261 graph.add_task("deploy", TestTask::new(&["test"])).unwrap();
1262 graph.add_dependency_edges().unwrap();
1263
1264 let affected = graph.compute_affected(
1266 &["build", "test", "deploy"],
1267 |task| {
1268 task.depends_on.len() == 1 && task.depends_on[0] == "build"
1270 },
1271 None::<fn(&str) -> bool>,
1272 );
1273
1274 assert_eq!(affected, vec!["test", "deploy"]);
1277 }
1278
1279 #[test]
1280 fn test_compute_affected_with_external_resolver() {
1281 let mut graph = TaskGraph::new();
1282 graph
1284 .add_task("build", TestTask::new(&["#external:lib"]))
1285 .unwrap();
1286 graph.add_task("test", TestTask::new(&["build"])).unwrap();
1287 let build_idx = *graph.name_to_node.get("build").unwrap();
1290 let test_idx = *graph.name_to_node.get("test").unwrap();
1291 graph.add_edge(build_idx, test_idx);
1292
1293 let affected = graph.compute_affected(
1295 &["build", "test"],
1296 |_task| false, Some(|dep: &str| dep == "#external:lib"),
1298 );
1299
1300 assert_eq!(affected, vec!["build", "test"]);
1302 }
1303
1304 #[test]
1305 fn test_compute_affected_external_not_affected() {
1306 let mut graph = TaskGraph::new();
1307 graph
1308 .add_task("build", TestTask::new(&["#external:lib"]))
1309 .unwrap();
1310 graph.add_task("test", TestTask::new(&["build"])).unwrap();
1311 let build_idx = *graph.name_to_node.get("build").unwrap();
1313 let test_idx = *graph.name_to_node.get("test").unwrap();
1314 graph.add_edge(build_idx, test_idx);
1315
1316 let affected =
1318 graph.compute_affected(&["build", "test"], |_task| false, Some(|_dep: &str| false));
1319
1320 assert!(affected.is_empty());
1321 }
1322
1323 #[test]
1328 fn test_transitive_closure_empty() {
1329 let deps: std::collections::HashMap<&str, Vec<String>> = std::collections::HashMap::new();
1330 let closure = compute_transitive_closure(std::iter::empty::<&str>(), |name| {
1331 deps.get(name).map(|v| v.as_slice())
1332 });
1333 assert!(closure.is_empty());
1334 }
1335
1336 #[test]
1337 fn test_transitive_closure_single_node_no_deps() {
1338 let deps: std::collections::HashMap<&str, Vec<String>> =
1339 [("build", vec![])].into_iter().collect();
1340 let closure =
1341 compute_transitive_closure(["build"], |name| deps.get(name).map(|v| v.as_slice()));
1342 assert_eq!(closure.len(), 1);
1343 assert!(closure.contains("build"));
1344 }
1345
1346 #[test]
1347 fn test_transitive_closure_chain() {
1348 let deps: std::collections::HashMap<&str, Vec<String>> = [
1350 ("build", vec![]),
1351 ("test", vec!["build".to_string()]),
1352 ("deploy", vec!["test".to_string()]),
1353 ]
1354 .into_iter()
1355 .collect();
1356
1357 let closure =
1358 compute_transitive_closure(["deploy"], |name| deps.get(name).map(|v| v.as_slice()));
1359
1360 assert_eq!(closure.len(), 3);
1361 assert!(closure.contains("deploy"));
1362 assert!(closure.contains("test"));
1363 assert!(closure.contains("build"));
1364 }
1365
1366 #[test]
1367 fn test_transitive_closure_diamond() {
1368 let deps: std::collections::HashMap<&str, Vec<String>> = [
1374 ("D", vec![]),
1375 ("B", vec!["D".to_string()]),
1376 ("C", vec!["D".to_string()]),
1377 ("A", vec!["B".to_string(), "C".to_string()]),
1378 ]
1379 .into_iter()
1380 .collect();
1381
1382 let closure =
1383 compute_transitive_closure(["A"], |name| deps.get(name).map(|v| v.as_slice()));
1384
1385 assert_eq!(closure.len(), 4);
1386 assert!(closure.contains("A"));
1387 assert!(closure.contains("B"));
1388 assert!(closure.contains("C"));
1389 assert!(closure.contains("D"));
1390 }
1391
1392 #[test]
1393 fn test_transitive_closure_multiple_initial() {
1394 let deps: std::collections::HashMap<&str, Vec<String>> = [
1396 ("B", vec![]),
1397 ("A", vec!["B".to_string()]),
1398 ("D", vec![]),
1399 ("C", vec!["D".to_string()]),
1400 ]
1401 .into_iter()
1402 .collect();
1403
1404 let closure =
1405 compute_transitive_closure(["A", "C"], |name| deps.get(name).map(|v| v.as_slice()));
1406
1407 assert_eq!(closure.len(), 4);
1408 assert!(closure.contains("A"));
1409 assert!(closure.contains("B"));
1410 assert!(closure.contains("C"));
1411 assert!(closure.contains("D"));
1412 }
1413
1414 #[test]
1415 fn test_transitive_closure_missing_dep() {
1416 let deps: std::collections::HashMap<&str, Vec<String>> =
1418 [("A", vec!["B".to_string()])].into_iter().collect();
1419
1420 let closure =
1421 compute_transitive_closure(["A"], |name| deps.get(name).map(|v| v.as_slice()));
1422
1423 assert_eq!(closure.len(), 2);
1425 assert!(closure.contains("A"));
1426 assert!(closure.contains("B"));
1427 }
1428
1429 #[test]
1434 fn test_node_kind_default() {
1435 let kind = NodeKind::default();
1436 assert_eq!(kind, NodeKind::Task);
1437 }
1438
1439 #[test]
1440 fn test_node_kind_display() {
1441 assert_eq!(NodeKind::Task.to_string(), "task");
1442 assert_eq!(NodeKind::Service.to_string(), "service");
1443 }
1444
1445 #[test]
1446 fn test_node_kind_equality() {
1447 assert_eq!(NodeKind::Task, NodeKind::Task);
1448 assert_eq!(NodeKind::Service, NodeKind::Service);
1449 assert_ne!(NodeKind::Task, NodeKind::Service);
1450 }
1451
1452 #[test]
1453 fn test_add_task_sets_kind() {
1454 let mut graph = TaskGraph::new();
1455 graph.add_task("build", TestTask::new(&[])).unwrap();
1456
1457 let node = graph.get_node_by_name("build").unwrap();
1458 assert_eq!(node.kind, NodeKind::Task);
1459 }
1460
1461 #[test]
1462 fn test_add_service_sets_kind() {
1463 let mut graph = TaskGraph::new();
1464 graph.add_service("db", TestTask::new(&[])).unwrap();
1465
1466 let node = graph.get_node_by_name("db").unwrap();
1467 assert_eq!(node.kind, NodeKind::Service);
1468 }
1469
1470 #[test]
1471 fn test_mixed_graph_tasks_and_services() {
1472 let mut graph = TaskGraph::new();
1473
1474 graph.add_task("build", TestTask::new(&[])).unwrap();
1476 graph.add_service("db", TestTask::new(&["build"])).unwrap();
1477 graph.add_dependency_edges().unwrap();
1478
1479 assert_eq!(graph.task_count(), 2);
1480 assert!(!graph.has_cycles());
1481
1482 let build_node = graph.get_node_by_name("build").unwrap();
1484 assert_eq!(build_node.kind, NodeKind::Task);
1485
1486 let db_node = graph.get_node_by_name("db").unwrap();
1487 assert_eq!(db_node.kind, NodeKind::Service);
1488
1489 let sorted = graph.topological_sort().unwrap();
1491 let positions: HashMap<String, usize> = sorted
1492 .iter()
1493 .enumerate()
1494 .map(|(i, node)| (node.name.clone(), i))
1495 .collect();
1496 assert!(positions["build"] < positions["db"]);
1497 }
1498
1499 #[test]
1500 fn test_add_service_deduplication() {
1501 let mut graph = TaskGraph::new();
1502 let idx1 = graph.add_service("db", TestTask::new(&[])).unwrap();
1503 let idx2 = graph.add_service("db", TestTask::new(&[])).unwrap();
1504 assert_eq!(idx1, idx2);
1505 assert_eq!(graph.task_count(), 1);
1506 }
1507
1508 #[test]
1509 fn test_duplicate_node_name_across_kinds() {
1510 let mut graph = TaskGraph::new();
1511 graph.add_task("api", TestTask::new(&[])).unwrap();
1512
1513 let err = graph
1514 .add_image("api", TestTask::new(&[]))
1515 .expect_err("should reject image with same name as task");
1516 assert!(
1517 matches!(err, Error::DuplicateNodeName { ref name, .. } if name == "api"),
1518 "expected DuplicateNodeName error, got: {err}"
1519 );
1520
1521 let mut graph2 = TaskGraph::new();
1523 graph2.add_image("worker", TestTask::new(&[])).unwrap();
1524
1525 let err2 = graph2
1526 .add_service("worker", TestTask::new(&[]))
1527 .expect_err("should reject service with same name as image");
1528 assert!(
1529 matches!(err2, Error::DuplicateNodeName { ref name, .. } if name == "worker"),
1530 "expected DuplicateNodeName error, got: {err2}"
1531 );
1532 }
1533
1534 #[test]
1535 fn test_add_image_deduplication() {
1536 let mut graph = TaskGraph::new();
1537 let idx1 = graph.add_image("api", TestTask::new(&[])).unwrap();
1538 let idx2 = graph.add_image("api", TestTask::new(&[])).unwrap();
1539 assert_eq!(idx1, idx2);
1540 assert_eq!(graph.task_count(), 1);
1541 }
1542}