cuenv_core/tasks/
graph.rs

1//! Task graph builder using petgraph
2//!
3//! This module builds directed acyclic graphs (DAGs) from task definitions
4//! to handle dependencies and determine execution order.
5
6use super::{ParallelGroup, Task, TaskDefinition, TaskGroup, Tasks};
7use crate::Result;
8use petgraph::algo::{is_cyclic_directed, toposort};
9use petgraph::graph::{DiGraph, NodeIndex};
10use petgraph::visit::IntoNodeReferences;
11use std::collections::{HashMap, HashSet};
12use tracing::debug;
13
14/// A node in the task graph
15#[derive(Debug, Clone)]
16pub struct TaskNode {
17    /// Name of the task
18    pub name: String,
19    /// The task to execute
20    pub task: Task,
21}
22
23/// Task graph for dependency resolution and execution ordering
24pub struct TaskGraph {
25    /// The directed graph of tasks
26    graph: DiGraph<TaskNode, ()>,
27    /// Map from task names to node indices
28    name_to_node: HashMap<String, NodeIndex>,
29    /// Map from group prefix to child task names (for dependency expansion)
30    group_children: HashMap<String, Vec<String>>,
31}
32
33impl TaskGraph {
34    /// Create a new empty task graph
35    pub fn new() -> Self {
36        Self {
37            graph: DiGraph::new(),
38            name_to_node: HashMap::new(),
39            group_children: HashMap::new(),
40        }
41    }
42
43    /// Build a graph from a task definition
44    pub fn build_from_definition(
45        &mut self,
46        name: &str,
47        definition: &TaskDefinition,
48        all_tasks: &Tasks,
49    ) -> Result<Vec<NodeIndex>> {
50        match definition {
51            TaskDefinition::Single(task) => {
52                let node = self.add_task(name, task.as_ref().clone())?;
53                Ok(vec![node])
54            }
55            TaskDefinition::Group(group) => self.build_from_group(name, group, all_tasks),
56        }
57    }
58
59    /// Build a graph from a task group
60    fn build_from_group(
61        &mut self,
62        prefix: &str,
63        group: &TaskGroup,
64        all_tasks: &Tasks,
65    ) -> Result<Vec<NodeIndex>> {
66        match group {
67            TaskGroup::Sequential(tasks) => self.build_sequential_group(prefix, tasks, all_tasks),
68            TaskGroup::Parallel(group) => self.build_parallel_group(prefix, group, all_tasks),
69        }
70    }
71
72    /// Build a sequential task group (tasks run one after another)
73    fn build_sequential_group(
74        &mut self,
75        prefix: &str,
76        tasks: &[TaskDefinition],
77        all_tasks: &Tasks,
78    ) -> Result<Vec<NodeIndex>> {
79        let mut nodes = Vec::new();
80        let mut previous: Option<NodeIndex> = None;
81
82        // Track child names for group dependency expansion
83        let child_names: Vec<String> = (0..tasks.len())
84            .map(|i| format!("{}[{}]", prefix, i))
85            .collect();
86
87        for (i, task_def) in tasks.iter().enumerate() {
88            let task_name = format!("{}[{}]", prefix, i);
89            let task_nodes = self.build_from_definition(&task_name, task_def, all_tasks)?;
90
91            // For sequential execution, link previous task to current
92            if let Some(prev) = previous
93                && let Some(first) = task_nodes.first()
94            {
95                self.graph.add_edge(prev, *first, ());
96            }
97
98            if let Some(last) = task_nodes.last() {
99                previous = Some(*last);
100            }
101
102            nodes.extend(task_nodes);
103        }
104
105        // Register this group for dependency expansion
106        if !child_names.is_empty() {
107            self.group_children.insert(prefix.to_string(), child_names);
108        }
109
110        Ok(nodes)
111    }
112
113    /// Build a parallel task group (tasks can run concurrently)
114    fn build_parallel_group(
115        &mut self,
116        prefix: &str,
117        group: &ParallelGroup,
118        all_tasks: &Tasks,
119    ) -> Result<Vec<NodeIndex>> {
120        let mut nodes = Vec::new();
121
122        // Track child names for group dependency expansion
123        let child_names: Vec<String> = group
124            .tasks
125            .keys()
126            .map(|name| format!("{}.{}", prefix, name))
127            .collect();
128
129        for (name, task_def) in &group.tasks {
130            let task_name = format!("{}.{}", prefix, name);
131            let task_nodes = self.build_from_definition(&task_name, task_def, all_tasks)?;
132
133            // Apply group-level dependencies to each subtask
134            if !group.depends_on.is_empty() {
135                for node_idx in &task_nodes {
136                    let node = &mut self.graph[*node_idx];
137                    for dep in &group.depends_on {
138                        if !node.task.depends_on.contains(dep) {
139                            node.task.depends_on.push(dep.clone());
140                        }
141                    }
142                }
143            }
144
145            nodes.extend(task_nodes);
146        }
147
148        // Register this group for dependency expansion
149        if !child_names.is_empty() {
150            self.group_children.insert(prefix.to_string(), child_names);
151        }
152
153        Ok(nodes)
154    }
155
156    /// Add a single task to the graph
157    pub fn add_task(&mut self, name: &str, task: Task) -> Result<NodeIndex> {
158        // Check if task already exists
159        if let Some(&node) = self.name_to_node.get(name) {
160            return Ok(node);
161        }
162
163        let node = TaskNode {
164            name: name.to_string(),
165            task,
166        };
167
168        let node_index = self.graph.add_node(node);
169        self.name_to_node.insert(name.to_string(), node_index);
170        debug!("Added task node '{}'", name);
171
172        Ok(node_index)
173    }
174
175    /// Expand a dependency name to leaf task names
176    ///
177    /// If the dependency is a direct task, returns it as-is.
178    /// If it's a group name, recursively expands to all leaf tasks in that group.
179    fn expand_dep_to_leaf_tasks(&self, dep_name: &str) -> Vec<String> {
180        if self.name_to_node.contains_key(dep_name) {
181            // It's a leaf task (exists directly in the graph)
182            vec![dep_name.to_string()]
183        } else if let Some(children) = self.group_children.get(dep_name) {
184            // It's a group - recursively expand children
185            children
186                .iter()
187                .flat_map(|child| self.expand_dep_to_leaf_tasks(child))
188                .collect()
189        } else {
190            // Not found - will be caught as missing dependency later
191            vec![dep_name.to_string()]
192        }
193    }
194
195    /// Add dependency edges after all tasks have been added
196    /// This ensures proper cycle detection and missing dependency validation
197    fn add_dependency_edges(&mut self) -> Result<()> {
198        let mut missing_deps = Vec::new();
199        let mut edges_to_add = Vec::new();
200
201        // Collect all dependency relationships
202        for (node_index, node) in self.graph.node_references() {
203            for dep_name in &node.task.depends_on {
204                // Expand group references to leaf tasks
205                let expanded_deps = self.expand_dep_to_leaf_tasks(dep_name);
206
207                for expanded_dep in expanded_deps {
208                    if let Some(&dep_node_index) = self.name_to_node.get(&expanded_dep) {
209                        // Record edge to add later
210                        edges_to_add.push((dep_node_index, node_index));
211                    } else {
212                        missing_deps.push((node.name.clone(), expanded_dep));
213                    }
214                }
215            }
216        }
217
218        // Report missing dependencies
219        if !missing_deps.is_empty() {
220            let missing_list = missing_deps
221                .iter()
222                .map(|(task, dep)| format!("Task '{}' depends on missing task '{}'", task, dep))
223                .collect::<Vec<_>>()
224                .join(", ");
225            return Err(crate::Error::configuration(format!(
226                "Missing dependencies: {}",
227                missing_list
228            )));
229        }
230
231        // Add all edges
232        for (from, to) in edges_to_add {
233            self.graph.add_edge(from, to, ());
234        }
235
236        Ok(())
237    }
238
239    /// Check if the graph has cycles
240    pub fn has_cycles(&self) -> bool {
241        is_cyclic_directed(&self.graph)
242    }
243
244    /// Get topologically sorted list of tasks
245    pub fn topological_sort(&self) -> Result<Vec<TaskNode>> {
246        if self.has_cycles() {
247            return Err(crate::Error::configuration(
248                "Task dependency graph contains cycles".to_string(),
249            ));
250        }
251
252        match toposort(&self.graph, None) {
253            Ok(sorted_indices) => Ok(sorted_indices
254                .into_iter()
255                .map(|idx| self.graph[idx].clone())
256                .collect()),
257            Err(_) => Err(crate::Error::configuration(
258                "Failed to sort tasks topologically".to_string(),
259            )),
260        }
261    }
262
263    /// Get all tasks that can run in parallel (no dependencies between them)
264    pub fn get_parallel_groups(&self) -> Result<Vec<Vec<TaskNode>>> {
265        let sorted = self.topological_sort()?;
266
267        if sorted.is_empty() {
268            return Ok(vec![]);
269        }
270
271        // Group tasks by their dependency level
272        let mut groups: Vec<Vec<TaskNode>> = vec![];
273        let mut processed: HashMap<String, usize> = HashMap::new();
274
275        for task in sorted {
276            // Find the maximum level of all dependencies
277            let mut level = 0;
278            for dep in &task.task.depends_on {
279                if let Some(&dep_level) = processed.get(dep) {
280                    level = level.max(dep_level + 1);
281                }
282            }
283
284            // Add to appropriate group
285            if level >= groups.len() {
286                groups.resize(level + 1, vec![]);
287            }
288            groups[level].push(task.clone());
289            processed.insert(task.name.clone(), level);
290        }
291
292        Ok(groups)
293    }
294
295    /// Get the number of tasks in the graph
296    pub fn task_count(&self) -> usize {
297        self.graph.node_count()
298    }
299
300    /// Check if a task exists in the graph
301    pub fn contains_task(&self, name: &str) -> bool {
302        self.name_to_node.contains_key(name)
303    }
304
305    /// Build a complete graph from tasks with proper dependency resolution
306    /// This performs a two-pass build: first adding all nodes, then all edges
307    pub fn build_complete_graph(&mut self, tasks: &Tasks) -> Result<()> {
308        // First pass: Add all tasks as nodes
309        for (name, definition) in tasks.tasks.iter() {
310            match definition {
311                TaskDefinition::Single(task) => {
312                    self.add_task(name, task.as_ref().clone())?;
313                }
314                TaskDefinition::Group(_) => {
315                    // For groups, we'd need to expand them - this is more complex
316                    // and not needed for the current fix. Groups should be handled
317                    // by build_from_definition which already works correctly.
318                }
319            }
320        }
321
322        // Second pass: Add all dependency edges
323        self.add_dependency_edges()?;
324
325        Ok(())
326    }
327
328    /// Build graph for a specific task and all its transitive dependencies
329    pub fn build_for_task(&mut self, task_name: &str, all_tasks: &Tasks) -> Result<()> {
330        let mut to_process = vec![task_name.to_string()];
331        let mut processed = HashSet::new();
332
333        debug!(
334            "Building graph for '{}' with tasks {:?}",
335            task_name,
336            all_tasks.list_tasks()
337        );
338
339        // First pass: Collect all tasks that need to be included
340        while let Some(current_name) = to_process.pop() {
341            if processed.contains(&current_name) {
342                continue;
343            }
344            processed.insert(current_name.clone());
345
346            if let Some(definition) = all_tasks.get(&current_name) {
347                match definition {
348                    TaskDefinition::Single(task) => {
349                        self.add_task(&current_name, task.as_ref().clone())?;
350                        // Add dependencies to processing queue
351                        for dep in &task.depends_on {
352                            if !processed.contains(dep) {
353                                to_process.push(dep.clone());
354                            }
355                        }
356                    }
357                    TaskDefinition::Group(_) => {
358                        // Handle groups with build_from_definition
359                        let added_nodes =
360                            self.build_from_definition(&current_name, definition, all_tasks)?;
361                        // Collect dependencies from newly added tasks
362                        for node_idx in added_nodes {
363                            let node = &self.graph[node_idx];
364                            for dep in &node.task.depends_on {
365                                if !processed.contains(dep) {
366                                    to_process.push(dep.clone());
367                                }
368                            }
369                        }
370                    }
371                }
372            } else {
373                debug!("Task '{}' not found while building graph", current_name);
374            }
375        }
376
377        // Second pass: Add dependency edges
378        self.add_dependency_edges()?;
379
380        Ok(())
381    }
382}
383
384impl Default for TaskGraph {
385    fn default() -> Self {
386        Self::new()
387    }
388}
389
390#[cfg(test)]
391#[path = "graph_advanced_tests.rs"]
392mod graph_advanced_tests;
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::test_utils::create_task;
398
399    #[test]
400    fn test_task_graph_new() {
401        let graph = TaskGraph::new();
402        assert_eq!(graph.task_count(), 0);
403    }
404
405    #[test]
406    fn test_add_single_task() {
407        let mut graph = TaskGraph::new();
408        let task = create_task("test", vec![], vec![]);
409
410        let node = graph.add_task("test", task).unwrap();
411        assert!(graph.contains_task("test"));
412        assert_eq!(graph.task_count(), 1);
413
414        // Adding same task again should return same node
415        let task2 = create_task("test", vec![], vec![]);
416        let node2 = graph.add_task("test", task2).unwrap();
417        assert_eq!(node, node2);
418        assert_eq!(graph.task_count(), 1);
419    }
420
421    #[test]
422    fn test_task_dependencies() {
423        let mut graph = TaskGraph::new();
424
425        // Add tasks with dependencies
426        let task1 = create_task("task1", vec![], vec![]);
427        let task2 = create_task("task2", vec!["task1"], vec![]);
428        let task3 = create_task("task3", vec!["task1", "task2"], vec![]);
429
430        graph.add_task("task1", task1).unwrap();
431        graph.add_task("task2", task2).unwrap();
432        graph.add_task("task3", task3).unwrap();
433        graph.add_dependency_edges().unwrap(); // Add dependency edges after adding all tasks
434
435        assert_eq!(graph.task_count(), 3);
436        assert!(!graph.has_cycles());
437
438        let sorted = graph.topological_sort().unwrap();
439        assert_eq!(sorted.len(), 3);
440
441        // task1 should come before task2 and task3
442        let positions: HashMap<String, usize> = sorted
443            .iter()
444            .enumerate()
445            .map(|(i, node)| (node.name.clone(), i))
446            .collect();
447
448        assert!(positions["task1"] < positions["task2"]);
449        assert!(positions["task1"] < positions["task3"]);
450        assert!(positions["task2"] < positions["task3"]);
451    }
452
453    #[test]
454    fn test_cycle_detection() {
455        let mut graph = TaskGraph::new();
456
457        // Create a cycle: task1 -> task2 -> task3 -> task1
458        let task1 = create_task("task1", vec!["task3"], vec![]);
459        let task2 = create_task("task2", vec!["task1"], vec![]);
460        let task3 = create_task("task3", vec!["task2"], vec![]);
461
462        graph.add_task("task1", task1).unwrap();
463        graph.add_task("task2", task2).unwrap();
464        graph.add_task("task3", task3).unwrap();
465        graph.add_dependency_edges().unwrap(); // Add dependency edges after adding all tasks
466
467        assert!(graph.has_cycles());
468        assert!(graph.topological_sort().is_err());
469    }
470
471    #[test]
472    fn test_parallel_groups() {
473        let mut graph = TaskGraph::new();
474
475        // Create tasks that can run in parallel
476        // Level 0: task1, task2 (no dependencies)
477        // Level 1: task3 (depends on task1), task4 (depends on task2)
478        // Level 2: task5 (depends on task3 and task4)
479
480        let task1 = create_task("task1", vec![], vec![]);
481        let task2 = create_task("task2", vec![], vec![]);
482        let task3 = create_task("task3", vec!["task1"], vec![]);
483        let task4 = create_task("task4", vec!["task2"], vec![]);
484        let task5 = create_task("task5", vec!["task3", "task4"], vec![]);
485
486        graph.add_task("task1", task1).unwrap();
487        graph.add_task("task2", task2).unwrap();
488        graph.add_task("task3", task3).unwrap();
489        graph.add_task("task4", task4).unwrap();
490        graph.add_task("task5", task5).unwrap();
491        graph.add_dependency_edges().unwrap(); // Add dependency edges after adding all tasks
492
493        let groups = graph.get_parallel_groups().unwrap();
494
495        // Should have 3 levels
496        assert_eq!(groups.len(), 3);
497
498        // Level 0 should have 2 tasks
499        assert_eq!(groups[0].len(), 2);
500
501        // Level 1 should have 2 tasks
502        assert_eq!(groups[1].len(), 2);
503
504        // Level 2 should have 1 task
505        assert_eq!(groups[2].len(), 1);
506        assert_eq!(groups[2][0].name, "task5");
507    }
508
509    #[test]
510    fn test_build_from_sequential_group() {
511        let mut graph = TaskGraph::new();
512        let tasks = Tasks::new();
513
514        let task1 = create_task("t1", vec![], vec![]);
515        let task2 = create_task("t2", vec![], vec![]);
516
517        let group = TaskGroup::Sequential(vec![
518            TaskDefinition::Single(Box::new(task1)),
519            TaskDefinition::Single(Box::new(task2)),
520        ]);
521
522        let nodes = graph.build_from_group("seq", &group, &tasks).unwrap();
523        assert_eq!(nodes.len(), 2);
524
525        // Sequential tasks should have dependency chain
526        let sorted = graph.topological_sort().unwrap();
527        assert_eq!(sorted.len(), 2);
528        assert_eq!(sorted[0].name, "seq[0]");
529        assert_eq!(sorted[1].name, "seq[1]");
530    }
531
532    #[test]
533    fn test_build_from_parallel_group() {
534        let mut graph = TaskGraph::new();
535        let tasks = Tasks::new();
536
537        let task1 = create_task("t1", vec![], vec![]);
538        let task2 = create_task("t2", vec![], vec![]);
539
540        let mut parallel_tasks = HashMap::new();
541        parallel_tasks.insert("first".to_string(), TaskDefinition::Single(Box::new(task1)));
542        parallel_tasks.insert(
543            "second".to_string(),
544            TaskDefinition::Single(Box::new(task2)),
545        );
546
547        let group = TaskGroup::Parallel(ParallelGroup {
548            tasks: parallel_tasks,
549            depends_on: vec![],
550        });
551
552        let nodes = graph.build_from_group("par", &group, &tasks).unwrap();
553        assert_eq!(nodes.len(), 2);
554
555        // Parallel tasks should not have dependencies between them
556        assert!(!graph.has_cycles());
557
558        let groups = graph.get_parallel_groups().unwrap();
559        assert_eq!(groups.len(), 1); // All in same level
560        assert_eq!(groups[0].len(), 2); // Both can run in parallel
561    }
562
563    #[test]
564    fn test_three_way_cycle_detection() {
565        let mut graph = TaskGraph::new();
566
567        // Create cyclic dependencies: A -> B -> C -> A
568        let task_a = create_task("task_a", vec!["task_c"], vec![]);
569        let task_b = create_task("task_b", vec!["task_a"], vec![]);
570        let task_c = create_task("task_c", vec!["task_b"], vec![]);
571
572        graph.add_task("task_a", task_a).unwrap();
573        graph.add_task("task_b", task_b).unwrap();
574        graph.add_task("task_c", task_c).unwrap();
575        graph.add_dependency_edges().unwrap(); // Add dependency edges after adding all tasks
576
577        // This should create a cycle
578        assert!(graph.has_cycles());
579
580        // Should fail when trying to get parallel groups
581        assert!(graph.get_parallel_groups().is_err());
582    }
583
584    #[test]
585    fn test_self_dependency_cycle() {
586        let mut graph = TaskGraph::new();
587
588        // Create self-referencing task
589        let task = create_task("self_ref", vec!["self_ref"], vec![]);
590        graph.add_task("self_ref", task).unwrap();
591        graph.add_dependency_edges().unwrap(); // Add dependency edges after adding all tasks
592
593        assert!(graph.has_cycles());
594        assert!(graph.get_parallel_groups().is_err());
595    }
596
597    #[test]
598    fn test_complex_dependency_graph() {
599        let mut graph = TaskGraph::new();
600
601        // Create a diamond dependency pattern:
602        //     A
603        //    / \
604        //   B   C
605        //    \ /
606        //     D
607        let task_a = create_task("a", vec![], vec![]);
608        let task_b = create_task("b", vec!["a"], vec![]);
609        let task_c = create_task("c", vec!["a"], vec![]);
610        let task_d = create_task("d", vec!["b", "c"], vec![]);
611
612        graph.add_task("a", task_a).unwrap();
613        graph.add_task("b", task_b).unwrap();
614        graph.add_task("c", task_c).unwrap();
615        graph.add_task("d", task_d).unwrap();
616        graph.add_dependency_edges().unwrap(); // Add dependency edges after adding all tasks
617
618        assert!(!graph.has_cycles());
619        assert_eq!(graph.task_count(), 4);
620
621        let groups = graph.get_parallel_groups().unwrap();
622
623        // Should have 3 levels: [A], [B,C], [D]
624        assert_eq!(groups.len(), 3);
625        assert_eq!(groups[0].len(), 1); // A
626        assert_eq!(groups[1].len(), 2); // B and C can run in parallel
627        assert_eq!(groups[2].len(), 1); // D
628    }
629
630    #[test]
631    fn test_missing_dependency() {
632        let mut graph = TaskGraph::new();
633
634        // Create task with dependency that doesn't exist
635        let task = create_task("dependent", vec!["missing"], vec![]);
636        graph.add_task("dependent", task).unwrap();
637
638        // Should fail to get parallel groups due to missing dependency
639        assert!(graph.add_dependency_edges().is_err());
640    }
641
642    #[test]
643    fn test_empty_graph() {
644        let graph = TaskGraph::new();
645
646        assert_eq!(graph.task_count(), 0);
647        assert!(!graph.has_cycles());
648
649        let groups = graph.get_parallel_groups().unwrap();
650        assert!(groups.is_empty());
651    }
652
653    #[test]
654    fn test_single_task_no_deps() {
655        let mut graph = TaskGraph::new();
656
657        let task = create_task("solo", vec![], vec![]);
658        graph.add_task("solo", task).unwrap();
659
660        assert_eq!(graph.task_count(), 1);
661        assert!(!graph.has_cycles());
662
663        let groups = graph.get_parallel_groups().unwrap();
664        assert_eq!(groups.len(), 1);
665        assert_eq!(groups[0].len(), 1);
666    }
667
668    #[test]
669    fn test_linear_chain() {
670        let mut graph = TaskGraph::new();
671
672        // Create linear chain: A -> B -> C -> D
673        let task_a = create_task("a", vec![], vec![]);
674        let task_b = create_task("b", vec!["a"], vec![]);
675        let task_c = create_task("c", vec!["b"], vec![]);
676        let task_d = create_task("d", vec!["c"], vec![]);
677
678        graph.add_task("a", task_a).unwrap();
679        graph.add_task("b", task_b).unwrap();
680        graph.add_task("c", task_c).unwrap();
681        graph.add_task("d", task_d).unwrap();
682        graph.add_dependency_edges().unwrap(); // Add dependency edges after adding all tasks
683
684        assert!(!graph.has_cycles());
685        assert_eq!(graph.task_count(), 4);
686
687        let groups = graph.get_parallel_groups().unwrap();
688
689        // Should be 4 sequential groups
690        assert_eq!(groups.len(), 4);
691        for group in &groups {
692            assert_eq!(group.len(), 1);
693        }
694    }
695
696    #[test]
697    fn test_group_as_dependency_parallel() {
698        let mut graph = TaskGraph::new();
699        let tasks = Tasks::new();
700
701        // Create a parallel group "build" with two children
702        let deps_task = create_task("deps", vec![], vec![]);
703        let compile_task = create_task("compile", vec![], vec![]);
704
705        let mut parallel_tasks = HashMap::new();
706        parallel_tasks.insert(
707            "deps".to_string(),
708            TaskDefinition::Single(Box::new(deps_task)),
709        );
710        parallel_tasks.insert(
711            "compile".to_string(),
712            TaskDefinition::Single(Box::new(compile_task)),
713        );
714
715        let build_group = TaskGroup::Parallel(ParallelGroup {
716            tasks: parallel_tasks,
717            depends_on: vec![],
718        });
719
720        // Build the group first
721        graph
722            .build_from_group("build", &build_group, &tasks)
723            .unwrap();
724
725        // Add a task that depends on the group name "build"
726        let test_task = create_task("test", vec!["build"], vec![]);
727        graph.add_task("test", test_task).unwrap();
728
729        // This should succeed - "build" should expand to ["build.deps", "build.compile"]
730        graph.add_dependency_edges().unwrap();
731
732        assert!(!graph.has_cycles());
733        assert_eq!(graph.task_count(), 3);
734
735        // test should come after both build.deps and build.compile
736        let sorted = graph.topological_sort().unwrap();
737        let positions: HashMap<String, usize> = sorted
738            .iter()
739            .enumerate()
740            .map(|(i, node)| (node.name.clone(), i))
741            .collect();
742
743        assert!(positions["build.deps"] < positions["test"]);
744        assert!(positions["build.compile"] < positions["test"]);
745    }
746
747    #[test]
748    fn test_group_as_dependency_sequential() {
749        let mut graph = TaskGraph::new();
750        let tasks = Tasks::new();
751
752        // Create a sequential group "setup" with two children
753        let task1 = create_task("s1", vec![], vec![]);
754        let task2 = create_task("s2", vec![], vec![]);
755
756        let setup_group = TaskGroup::Sequential(vec![
757            TaskDefinition::Single(Box::new(task1)),
758            TaskDefinition::Single(Box::new(task2)),
759        ]);
760
761        // Build the group first
762        graph
763            .build_from_group("setup", &setup_group, &tasks)
764            .unwrap();
765
766        // Add a task that depends on the group name "setup"
767        let run_task = create_task("run", vec!["setup"], vec![]);
768        graph.add_task("run", run_task).unwrap();
769
770        // This should succeed - "setup" should expand to ["setup[0]", "setup[1]"]
771        graph.add_dependency_edges().unwrap();
772
773        assert!(!graph.has_cycles());
774        assert_eq!(graph.task_count(), 3);
775
776        // run should come after both setup[0] and setup[1]
777        let sorted = graph.topological_sort().unwrap();
778        let positions: HashMap<String, usize> = sorted
779            .iter()
780            .enumerate()
781            .map(|(i, node)| (node.name.clone(), i))
782            .collect();
783
784        assert!(positions["setup[0]"] < positions["run"]);
785        assert!(positions["setup[1]"] < positions["run"]);
786    }
787
788    #[test]
789    fn test_nested_group_as_dependency() {
790        let mut graph = TaskGraph::new();
791        let tasks = Tasks::new();
792
793        // Create a nested structure:
794        // build (parallel)
795        //   ├── frontend (sequential)
796        //   │   ├── frontend[0]
797        //   │   └── frontend[1]
798        //   └── backend (single task)
799
800        let frontend_t1 = create_task("fe1", vec![], vec![]);
801        let frontend_t2 = create_task("fe2", vec![], vec![]);
802        let frontend_group = TaskGroup::Sequential(vec![
803            TaskDefinition::Single(Box::new(frontend_t1)),
804            TaskDefinition::Single(Box::new(frontend_t2)),
805        ]);
806
807        let backend_task = create_task("be", vec![], vec![]);
808
809        let mut parallel_tasks = HashMap::new();
810        parallel_tasks.insert(
811            "frontend".to_string(),
812            TaskDefinition::Group(frontend_group),
813        );
814        parallel_tasks.insert(
815            "backend".to_string(),
816            TaskDefinition::Single(Box::new(backend_task)),
817        );
818
819        let build_group = TaskGroup::Parallel(ParallelGroup {
820            tasks: parallel_tasks,
821            depends_on: vec![],
822        });
823
824        // Build the nested group
825        graph
826            .build_from_group("build", &build_group, &tasks)
827            .unwrap();
828
829        // Add a task that depends on "build"
830        let deploy_task = create_task("deploy", vec!["build"], vec![]);
831        graph.add_task("deploy", deploy_task).unwrap();
832
833        // This should expand "build" -> ["build.frontend", "build.backend"]
834        // And further expand "build.frontend" -> ["build.frontend[0]", "build.frontend[1]"]
835        graph.add_dependency_edges().unwrap();
836
837        assert!(!graph.has_cycles());
838        assert_eq!(graph.task_count(), 4); // frontend[0], frontend[1], backend, deploy
839
840        // deploy should come after all leaf tasks
841        let sorted = graph.topological_sort().unwrap();
842        let positions: HashMap<String, usize> = sorted
843            .iter()
844            .enumerate()
845            .map(|(i, node)| (node.name.clone(), i))
846            .collect();
847
848        assert!(positions["build.frontend[0]"] < positions["deploy"]);
849        assert!(positions["build.frontend[1]"] < positions["deploy"]);
850        assert!(positions["build.backend"] < positions["deploy"]);
851    }
852
853    #[test]
854    fn test_mixed_exact_and_group_dependencies() {
855        let mut graph = TaskGraph::new();
856        let tasks = Tasks::new();
857
858        // Add a standalone task
859        let lint_task = create_task("lint", vec![], vec![]);
860        graph.add_task("lint", lint_task).unwrap();
861
862        // Create a parallel group
863        let deps_task = create_task("deps", vec![], vec![]);
864        let compile_task = create_task("compile", vec![], vec![]);
865
866        let mut parallel_tasks = HashMap::new();
867        parallel_tasks.insert(
868            "deps".to_string(),
869            TaskDefinition::Single(Box::new(deps_task)),
870        );
871        parallel_tasks.insert(
872            "compile".to_string(),
873            TaskDefinition::Single(Box::new(compile_task)),
874        );
875
876        let build_group = TaskGroup::Parallel(ParallelGroup {
877            tasks: parallel_tasks,
878            depends_on: vec![],
879        });
880
881        graph
882            .build_from_group("build", &build_group, &tasks)
883            .unwrap();
884
885        // Add a task that depends on both an exact task and a group
886        let test_task = create_task("test", vec!["lint", "build"], vec![]);
887        graph.add_task("test", test_task).unwrap();
888
889        graph.add_dependency_edges().unwrap();
890
891        assert!(!graph.has_cycles());
892        assert_eq!(graph.task_count(), 4);
893
894        // test should come after lint, build.deps, and build.compile
895        let sorted = graph.topological_sort().unwrap();
896        let positions: HashMap<String, usize> = sorted
897            .iter()
898            .enumerate()
899            .map(|(i, node)| (node.name.clone(), i))
900            .collect();
901
902        assert!(positions["lint"] < positions["test"]);
903        assert!(positions["build.deps"] < positions["test"]);
904        assert!(positions["build.compile"] < positions["test"]);
905    }
906
907    #[test]
908    fn test_cycle_with_group_expansion() {
909        let mut graph = TaskGraph::new();
910        let tasks = Tasks::new();
911
912        // Create a group where a child depends on a task that depends on the group
913        // This creates a cycle: setup[0] -> test, test -> setup (expands to setup[0], setup[1])
914
915        // First, add the task that will depend on the group
916        let test_task = create_task("test", vec!["setup"], vec![]);
917        graph.add_task("test", test_task).unwrap();
918
919        // Create the group where one child depends on test
920        let task1 = create_task("s1", vec!["test"], vec![]);
921        let task2 = create_task("s2", vec![], vec![]);
922
923        let setup_group = TaskGroup::Sequential(vec![
924            TaskDefinition::Single(Box::new(task1)),
925            TaskDefinition::Single(Box::new(task2)),
926        ]);
927
928        graph
929            .build_from_group("setup", &setup_group, &tasks)
930            .unwrap();
931
932        graph.add_dependency_edges().unwrap();
933
934        // This creates a cycle: test -> setup[0] -> test
935        assert!(graph.has_cycles());
936        assert!(graph.topological_sort().is_err());
937    }
938}