Skip to main content

cuenv_core/tasks/
graph.rs

1//! Task graph builder using cuenv-task-graph.
2//!
3//! This module builds directed acyclic graphs (DAGs) from task definitions
4//! to handle dependencies and determine execution order.
5//!
6//! It wraps the generic `cuenv_task_graph` crate with cuenv-core specific
7//! types like `TaskNode`, `TaskGroup`, and `TaskList`.
8
9use super::{Task, TaskDependency, TaskGroup, TaskNode, Tasks};
10use crate::Result;
11use cuenv_task_graph::{GraphNode, TaskNodeData, TaskResolution, TaskResolver};
12use petgraph::graph::NodeIndex;
13use tracing::debug;
14
15// ============================================================================
16// TaskResolver Implementation for Tasks
17// ============================================================================
18
19impl TaskResolver<Task> for Tasks {
20    fn resolve(&self, name: &str) -> Option<TaskResolution<Task>> {
21        // Parse the path and walk down to find the TaskNode
22        let node = self.resolve_path(name)?;
23        Some(self.node_to_resolution(name, node))
24    }
25}
26
27impl Tasks {
28    /// Walk a dotted/bracketed path to find the TaskNode.
29    ///
30    /// This method first tries a direct lookup (for flat task names like `bun.setup`),
31    /// then falls back to walking the nested path structure.
32    ///
33    /// Examples:
34    /// - `"build"` → top-level lookup
35    /// - `"build.frontend"` → first tries `tasks["build.frontend"]`, then `tasks["build"].parallel["frontend"]`
36    /// - `"build[0]"` → first tries `tasks["build[0]"]`, then `tasks["build"].steps[0]`
37    /// - `"build.frontend[0]"` → nested: parallel then sequential
38    fn resolve_path(&self, path: &str) -> Option<&TaskNode> {
39        // First: try direct lookup (handles flat task names like "bun.setup", "bun.hooks.beforeInstall[0]")
40        if let Some(task) = self.tasks.get(path) {
41            return Some(task);
42        }
43
44        // Second: try walking nested structure
45        let segments = parse_path_segments(path);
46        if segments.is_empty() {
47            return None;
48        }
49
50        // Get the root definition
51        let root_name = &segments[0];
52        let root_segment = match root_name {
53            PathSegment::Name(n) => n.as_str(),
54            PathSegment::Index(_) => return None, // Can't start with index
55        };
56
57        let mut current = self.tasks.get(root_segment)?;
58
59        // Walk remaining segments
60        for segment in &segments[1..] {
61            current = match (current, segment) {
62                // Parallel group child access (dot notation)
63                (TaskNode::Group(group), PathSegment::Name(name)) => group.children.get(name)?,
64                // Sequential list child access (bracket notation)
65                (TaskNode::Sequence(steps), PathSegment::Index(idx)) => steps.get(*idx)?,
66                // Invalid access pattern
67                _ => return None,
68            };
69        }
70
71        Some(current)
72    }
73
74    /// Convert a TaskNode to a TaskResolution.
75    fn node_to_resolution(&self, name: &str, node: &TaskNode) -> TaskResolution<Task> {
76        match node {
77            TaskNode::Task(task) => TaskResolution::Single(task.as_ref().clone()),
78            TaskNode::Sequence(steps) => {
79                let children: Vec<String> = (0..steps.len())
80                    .map(|i| format!("{}[{}]", name, i))
81                    .collect();
82                TaskResolution::Sequential { children }
83            }
84            TaskNode::Group(group) => {
85                let children: Vec<String> = group
86                    .children
87                    .keys()
88                    .map(|k| format!("{}.{}", name, k))
89                    .collect();
90                TaskResolution::Parallel {
91                    children,
92                    depends_on: group
93                        .depends_on
94                        .iter()
95                        .map(|d| d.task_name().to_string())
96                        .collect(),
97                }
98            }
99        }
100    }
101}
102
103/// Segment of a task path.
104#[derive(Debug, Clone, PartialEq, Eq)]
105enum PathSegment {
106    /// Named segment (dot notation): `frontend` in `build.frontend`
107    Name(String),
108    /// Indexed segment (bracket notation): `0` in `build[0]`
109    Index(usize),
110}
111
112/// Parse a task path into segments.
113///
114/// Examples:
115/// - `"build"` → `[Name("build")]`
116/// - `"build.frontend"` → `[Name("build"), Name("frontend")]`
117/// - `"build[0]"` → `[Name("build"), Index(0)]`
118/// - `"build.frontend[0]"` → `[Name("build"), Name("frontend"), Index(0)]`
119fn parse_path_segments(path: &str) -> Vec<PathSegment> {
120    let mut segments = Vec::new();
121    let mut current_name = String::new();
122    let mut chars = path.chars().peekable();
123
124    while let Some(c) = chars.next() {
125        match c {
126            '.' => {
127                if !current_name.is_empty() {
128                    segments.push(PathSegment::Name(current_name.clone()));
129                    current_name.clear();
130                }
131            }
132            '[' => {
133                if !current_name.is_empty() {
134                    segments.push(PathSegment::Name(current_name.clone()));
135                    current_name.clear();
136                }
137                // Parse index
138                let mut index_str = String::new();
139                for c in chars.by_ref() {
140                    if c == ']' {
141                        break;
142                    }
143                    index_str.push(c);
144                }
145                if let Ok(idx) = index_str.parse::<usize>() {
146                    segments.push(PathSegment::Index(idx));
147                }
148            }
149            _ => {
150                current_name.push(c);
151            }
152        }
153    }
154
155    // Push final name if any
156    if !current_name.is_empty() {
157        segments.push(PathSegment::Name(current_name));
158    }
159
160    segments
161}
162
163// Implement the TaskNodeData trait for Task
164impl TaskNodeData for Task {
165    fn dependency_names(&self) -> impl Iterator<Item = &str> {
166        self.depends_on.iter().map(|d| d.task_name())
167    }
168
169    fn add_dependency(&mut self, dep: String) {
170        if !self.has_dependency(&dep) {
171            self.depends_on.push(TaskDependency::from_name(dep));
172        }
173    }
174}
175
176/// A node in the task graph containing a task name and the task itself.
177pub type TaskGraphNode = GraphNode<Task>;
178
179/// Task graph for dependency resolution and execution ordering.
180///
181/// This wraps `cuenv_task_graph::TaskGraph` with cuenv-core specific
182/// functionality for building graphs from `TaskDefinition`, `TaskGroup`, etc.
183pub struct TaskGraph {
184    /// The underlying generic task graph.
185    inner: cuenv_task_graph::TaskGraph<Task>,
186}
187
188impl TaskGraph {
189    /// Create a new empty task graph.
190    #[must_use]
191    pub fn new() -> Self {
192        Self {
193            inner: cuenv_task_graph::TaskGraph::new(),
194        }
195    }
196
197    /// Build a graph from a task node.
198    pub fn build_from_node(
199        &mut self,
200        name: &str,
201        node: &TaskNode,
202        all_tasks: &Tasks,
203    ) -> Result<Vec<NodeIndex>> {
204        match node {
205            TaskNode::Task(task) => {
206                let idx = self.add_task(name, task.as_ref().clone())?;
207                Ok(vec![idx])
208            }
209            TaskNode::Group(group) => self.build_parallel_group(name, group, all_tasks),
210            TaskNode::Sequence(steps) => self.build_sequential_list(name, steps, all_tasks),
211        }
212    }
213
214    /// Build a sequential task list (steps run one after another).
215    fn build_sequential_list(
216        &mut self,
217        prefix: &str,
218        steps: &[TaskNode],
219        all_tasks: &Tasks,
220    ) -> Result<Vec<NodeIndex>> {
221        let mut nodes = Vec::new();
222        let mut previous: Option<NodeIndex> = None;
223
224        // Track child names for group dependency expansion
225        let child_names: Vec<String> = (0..steps.len())
226            .map(|i| format!("{}[{}]", prefix, i))
227            .collect();
228
229        for (i, step) in steps.iter().enumerate() {
230            let task_name = format!("{}[{}]", prefix, i);
231            let task_nodes = self.build_from_node(&task_name, step, all_tasks)?;
232
233            // For sequential execution, link previous task to current
234            if let Some(prev) = previous
235                && let Some(first) = task_nodes.first()
236            {
237                self.inner.add_edge(prev, *first);
238            }
239
240            if let Some(last) = task_nodes.last() {
241                previous = Some(*last);
242            }
243
244            nodes.extend(task_nodes);
245        }
246
247        // Register this group for dependency expansion
248        self.inner.register_group(prefix, child_names);
249
250        Ok(nodes)
251    }
252
253    /// Build a parallel task group (tasks can run concurrently).
254    fn build_parallel_group(
255        &mut self,
256        prefix: &str,
257        group: &TaskGroup,
258        all_tasks: &Tasks,
259    ) -> Result<Vec<NodeIndex>> {
260        let mut nodes = Vec::new();
261
262        // Track child names for group dependency expansion
263        let child_names: Vec<String> = group
264            .children
265            .keys()
266            .map(|name| format!("{}.{}", prefix, name))
267            .collect();
268
269        for (name, child_node) in &group.children {
270            let task_name = format!("{}.{}", prefix, name);
271            let task_nodes = self.build_from_node(&task_name, child_node, all_tasks)?;
272
273            // Apply group-level dependencies to each subtask
274            if !group.depends_on.is_empty() {
275                for node_idx in &task_nodes {
276                    if let Some(node) = self.inner.get_node_mut(*node_idx) {
277                        for dep in &group.depends_on {
278                            node.task.add_dependency(dep.task_name().to_string());
279                        }
280                    }
281                }
282            }
283
284            nodes.extend(task_nodes);
285        }
286
287        // Register this group for dependency expansion
288        self.inner.register_group(prefix, child_names);
289
290        Ok(nodes)
291    }
292
293    /// Add a single task to the graph.
294    pub fn add_task(&mut self, name: &str, task: Task) -> Result<NodeIndex> {
295        self.inner
296            .add_task(name, task)
297            .map_err(|e| crate::Error::configuration(e.to_string()))
298    }
299
300    /// Add dependency edges after all tasks have been added.
301    /// This ensures proper cycle detection and missing dependency validation.
302    pub fn add_dependency_edges(&mut self) -> Result<()> {
303        self.inner
304            .add_dependency_edges()
305            .map_err(|e| crate::Error::configuration(e.to_string()))
306    }
307
308    /// Check if the graph has cycles.
309    #[must_use]
310    pub fn has_cycles(&self) -> bool {
311        self.inner.has_cycles()
312    }
313
314    /// Get topologically sorted list of tasks.
315    pub fn topological_sort(&self) -> Result<Vec<TaskGraphNode>> {
316        self.inner
317            .topological_sort()
318            .map_err(|e| crate::Error::configuration(e.to_string()))
319    }
320
321    /// Get all tasks that can run in parallel (no dependencies between them).
322    pub fn get_parallel_groups(&self) -> Result<Vec<Vec<TaskGraphNode>>> {
323        self.inner
324            .get_parallel_groups()
325            .map_err(|e| crate::Error::configuration(e.to_string()))
326    }
327
328    /// Get the number of tasks in the graph.
329    #[must_use]
330    pub fn task_count(&self) -> usize {
331        self.inner.task_count()
332    }
333
334    /// Check if a task exists in the graph.
335    #[must_use]
336    pub fn contains_task(&self, name: &str) -> bool {
337        self.inner.contains_task(name)
338    }
339
340    /// Build a complete graph from tasks with proper dependency resolution.
341    /// This performs a two-pass build: first adding all nodes, then all edges.
342    pub fn build_complete_graph(&mut self, tasks: &Tasks) -> Result<()> {
343        // First pass: Add all tasks as nodes
344        for (name, node) in tasks.tasks.iter() {
345            if let TaskNode::Task(task) = node {
346                self.add_task(name, task.as_ref().clone())?;
347            }
348            // Groups and Lists are handled by build_from_node
349        }
350
351        // Second pass: Add all dependency edges
352        self.add_dependency_edges()
353    }
354
355    /// Build graph for a specific task and all its transitive dependencies.
356    ///
357    /// This uses the [`TaskResolver`] trait implementation for [`Tasks`] to handle
358    /// nested paths and group expansion in a unified way.
359    pub fn build_for_task(&mut self, task_name: &str, all_tasks: &Tasks) -> Result<()> {
360        debug!(
361            "Building graph for '{}' with tasks {:?}",
362            task_name,
363            all_tasks.list_tasks()
364        );
365
366        self.inner
367            .build_for_task_with_resolver(task_name, all_tasks)
368            .map_err(|e| crate::Error::configuration(e.to_string()))
369    }
370}
371
372impl Default for TaskGraph {
373    fn default() -> Self {
374        Self::new()
375    }
376}
377
378#[cfg(test)]
379#[path = "graph_advanced_tests.rs"]
380mod graph_advanced_tests;
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::tasks::{TaskDependency, TaskGroup, TaskNode};
386    use crate::test_utils::create_task;
387    use std::collections::HashMap;
388
389    #[test]
390    fn test_task_graph_new() {
391        let graph = TaskGraph::new();
392        assert_eq!(graph.task_count(), 0);
393    }
394
395    #[test]
396    fn test_add_single_task() {
397        let mut graph = TaskGraph::new();
398        let task = create_task("test", vec![], vec![]);
399
400        let node = graph.add_task("test", task).unwrap();
401        assert!(graph.contains_task("test"));
402        assert_eq!(graph.task_count(), 1);
403
404        // Adding same task again should return same node
405        let task2 = create_task("test", vec![], vec![]);
406        let node2 = graph.add_task("test", task2).unwrap();
407        assert_eq!(node, node2);
408        assert_eq!(graph.task_count(), 1);
409    }
410
411    #[test]
412    fn test_task_dependencies() {
413        let mut graph = TaskGraph::new();
414
415        // Add tasks with dependencies
416        let task1 = create_task("task1", vec![], vec![]);
417        let task2 = create_task("task2", vec!["task1"], vec![]);
418        let task3 = create_task("task3", vec!["task1", "task2"], vec![]);
419
420        graph.add_task("task1", task1).unwrap();
421        graph.add_task("task2", task2).unwrap();
422        graph.add_task("task3", task3).unwrap();
423        graph.add_dependency_edges().unwrap();
424
425        assert_eq!(graph.task_count(), 3);
426        assert!(!graph.has_cycles());
427
428        let sorted = graph.topological_sort().unwrap();
429        assert_eq!(sorted.len(), 3);
430
431        // task1 should come before task2 and task3
432        let positions: HashMap<String, usize> = sorted
433            .iter()
434            .enumerate()
435            .map(|(i, node)| (node.name.clone(), i))
436            .collect();
437
438        assert!(positions["task1"] < positions["task2"]);
439        assert!(positions["task1"] < positions["task3"]);
440        assert!(positions["task2"] < positions["task3"]);
441    }
442
443    #[test]
444    fn test_cycle_detection() {
445        let mut graph = TaskGraph::new();
446
447        // Create a cycle: task1 -> task2 -> task3 -> task1
448        let task1 = create_task("task1", vec!["task3"], vec![]);
449        let task2 = create_task("task2", vec!["task1"], vec![]);
450        let task3 = create_task("task3", vec!["task2"], vec![]);
451
452        graph.add_task("task1", task1).unwrap();
453        graph.add_task("task2", task2).unwrap();
454        graph.add_task("task3", task3).unwrap();
455        graph.add_dependency_edges().unwrap();
456
457        assert!(graph.has_cycles());
458        assert!(graph.topological_sort().is_err());
459    }
460
461    #[test]
462    fn test_parallel_groups() {
463        let mut graph = TaskGraph::new();
464
465        // Create tasks that can run in parallel
466        // Level 0: task1, task2 (no dependencies)
467        // Level 1: task3 (depends on task1), task4 (depends on task2)
468        // Level 2: task5 (depends on task3 and task4)
469
470        let task1 = create_task("task1", vec![], vec![]);
471        let task2 = create_task("task2", vec![], vec![]);
472        let task3 = create_task("task3", vec!["task1"], vec![]);
473        let task4 = create_task("task4", vec!["task2"], vec![]);
474        let task5 = create_task("task5", vec!["task3", "task4"], vec![]);
475
476        graph.add_task("task1", task1).unwrap();
477        graph.add_task("task2", task2).unwrap();
478        graph.add_task("task3", task3).unwrap();
479        graph.add_task("task4", task4).unwrap();
480        graph.add_task("task5", task5).unwrap();
481        graph.add_dependency_edges().unwrap();
482
483        let groups = graph.get_parallel_groups().unwrap();
484
485        // Should have 3 levels
486        assert_eq!(groups.len(), 3);
487
488        // Level 0 should have 2 tasks
489        assert_eq!(groups[0].len(), 2);
490
491        // Level 1 should have 2 tasks
492        assert_eq!(groups[1].len(), 2);
493
494        // Level 2 should have 1 task
495        assert_eq!(groups[2].len(), 1);
496        assert_eq!(groups[2][0].name, "task5");
497    }
498
499    #[test]
500    fn test_build_from_sequential_group() {
501        let mut graph = TaskGraph::new();
502        let tasks = Tasks::new();
503
504        let task1 = create_task("t1", vec![], vec![]);
505        let task2 = create_task("t2", vec![], vec![]);
506
507        let node = TaskNode::Sequence(vec![
508            TaskNode::Task(Box::new(task1)),
509            TaskNode::Task(Box::new(task2)),
510        ]);
511        let nodes = graph.build_from_node("seq", &node, &tasks).unwrap();
512        assert_eq!(nodes.len(), 2);
513
514        // Sequential tasks should have dependency chain
515        let sorted = graph.topological_sort().unwrap();
516        assert_eq!(sorted.len(), 2);
517        assert_eq!(sorted[0].name, "seq[0]");
518        assert_eq!(sorted[1].name, "seq[1]");
519    }
520
521    #[test]
522    fn test_build_from_parallel_group() {
523        let mut graph = TaskGraph::new();
524        let tasks = Tasks::new();
525
526        let task1 = create_task("t1", vec![], vec![]);
527        let task2 = create_task("t2", vec![], vec![]);
528
529        let mut parallel_tasks = HashMap::new();
530        parallel_tasks.insert("first".to_string(), TaskNode::Task(Box::new(task1)));
531        parallel_tasks.insert("second".to_string(), TaskNode::Task(Box::new(task2)));
532
533        let group = TaskGroup {
534            type_: "group".to_string(),
535            children: parallel_tasks,
536            depends_on: vec![],
537            description: None,
538            max_concurrency: None,
539        };
540
541        let node = TaskNode::Group(group);
542        let nodes = graph.build_from_node("par", &node, &tasks).unwrap();
543        assert_eq!(nodes.len(), 2);
544
545        // Parallel tasks should not have dependencies between them
546        assert!(!graph.has_cycles());
547
548        let groups = graph.get_parallel_groups().unwrap();
549        assert_eq!(groups.len(), 1); // All in same level
550        assert_eq!(groups[0].len(), 2); // Both can run in parallel
551    }
552
553    #[test]
554    fn test_three_way_cycle_detection() {
555        let mut graph = TaskGraph::new();
556
557        // Create cyclic dependencies: A -> B -> C -> A
558        let task_a = create_task("task_a", vec!["task_c"], vec![]);
559        let task_b = create_task("task_b", vec!["task_a"], vec![]);
560        let task_c = create_task("task_c", vec!["task_b"], vec![]);
561
562        graph.add_task("task_a", task_a).unwrap();
563        graph.add_task("task_b", task_b).unwrap();
564        graph.add_task("task_c", task_c).unwrap();
565        graph.add_dependency_edges().unwrap();
566
567        // This should create a cycle
568        assert!(graph.has_cycles());
569
570        // Should fail when trying to get parallel groups
571        assert!(graph.get_parallel_groups().is_err());
572    }
573
574    #[test]
575    fn test_self_dependency_cycle() {
576        let mut graph = TaskGraph::new();
577
578        // Create self-referencing task
579        let task = create_task("self_ref", vec!["self_ref"], vec![]);
580        graph.add_task("self_ref", task).unwrap();
581        graph.add_dependency_edges().unwrap();
582
583        assert!(graph.has_cycles());
584        assert!(graph.get_parallel_groups().is_err());
585    }
586
587    #[test]
588    fn test_complex_dependency_graph() {
589        let mut graph = TaskGraph::new();
590
591        // Create a diamond dependency pattern:
592        //     A
593        //    / \
594        //   B   C
595        //    \ /
596        //     D
597        let task_a = create_task("a", vec![], vec![]);
598        let task_b = create_task("b", vec!["a"], vec![]);
599        let task_c = create_task("c", vec!["a"], vec![]);
600        let task_d = create_task("d", vec!["b", "c"], vec![]);
601
602        graph.add_task("a", task_a).unwrap();
603        graph.add_task("b", task_b).unwrap();
604        graph.add_task("c", task_c).unwrap();
605        graph.add_task("d", task_d).unwrap();
606        graph.add_dependency_edges().unwrap();
607
608        assert!(!graph.has_cycles());
609        assert_eq!(graph.task_count(), 4);
610
611        let groups = graph.get_parallel_groups().unwrap();
612
613        // Should have 3 levels: [A], [B,C], [D]
614        assert_eq!(groups.len(), 3);
615        assert_eq!(groups[0].len(), 1); // A
616        assert_eq!(groups[1].len(), 2); // B and C can run in parallel
617        assert_eq!(groups[2].len(), 1); // D
618    }
619
620    #[test]
621    fn test_missing_dependency() {
622        let mut graph = TaskGraph::new();
623
624        // Create task with dependency that doesn't exist
625        let task = create_task("dependent", vec!["missing"], vec![]);
626        graph.add_task("dependent", task).unwrap();
627
628        // Should fail to get parallel groups due to missing dependency
629        assert!(graph.add_dependency_edges().is_err());
630    }
631
632    #[test]
633    fn test_empty_graph() {
634        let graph = TaskGraph::new();
635
636        assert_eq!(graph.task_count(), 0);
637        assert!(!graph.has_cycles());
638
639        let groups = graph.get_parallel_groups().unwrap();
640        assert!(groups.is_empty());
641    }
642
643    #[test]
644    fn test_single_task_no_deps() {
645        let mut graph = TaskGraph::new();
646
647        let task = create_task("solo", vec![], vec![]);
648        graph.add_task("solo", task).unwrap();
649
650        assert_eq!(graph.task_count(), 1);
651        assert!(!graph.has_cycles());
652
653        let groups = graph.get_parallel_groups().unwrap();
654        assert_eq!(groups.len(), 1);
655        assert_eq!(groups[0].len(), 1);
656    }
657
658    #[test]
659    fn test_linear_chain() {
660        let mut graph = TaskGraph::new();
661
662        // Create linear chain: A -> B -> C -> D
663        let task_a = create_task("a", vec![], vec![]);
664        let task_b = create_task("b", vec!["a"], vec![]);
665        let task_c = create_task("c", vec!["b"], vec![]);
666        let task_d = create_task("d", vec!["c"], vec![]);
667
668        graph.add_task("a", task_a).unwrap();
669        graph.add_task("b", task_b).unwrap();
670        graph.add_task("c", task_c).unwrap();
671        graph.add_task("d", task_d).unwrap();
672        graph.add_dependency_edges().unwrap();
673
674        assert!(!graph.has_cycles());
675        assert_eq!(graph.task_count(), 4);
676
677        let groups = graph.get_parallel_groups().unwrap();
678
679        // Should be 4 sequential groups
680        assert_eq!(groups.len(), 4);
681        for group in &groups {
682            assert_eq!(group.len(), 1);
683        }
684    }
685
686    #[test]
687    fn test_group_as_dependency_parallel() {
688        let mut graph = TaskGraph::new();
689        let tasks = Tasks::new();
690
691        // Create a parallel group "build" with two children
692        let deps_task = create_task("deps", vec![], vec![]);
693        let compile_task = create_task("compile", vec![], vec![]);
694
695        let mut parallel_tasks = HashMap::new();
696        parallel_tasks.insert("deps".to_string(), TaskNode::Task(Box::new(deps_task)));
697        parallel_tasks.insert(
698            "compile".to_string(),
699            TaskNode::Task(Box::new(compile_task)),
700        );
701
702        let build_group = TaskGroup {
703            type_: "group".to_string(),
704            children: parallel_tasks,
705            depends_on: vec![],
706            description: None,
707            max_concurrency: None,
708        };
709
710        // Build the group first
711        let build_node = TaskNode::Group(build_group);
712        graph.build_from_node("build", &build_node, &tasks).unwrap();
713
714        // Add a task that depends on the group name "build"
715        let test_task = create_task("test", vec!["build"], vec![]);
716        graph.add_task("test", test_task).unwrap();
717
718        // This should succeed - "build" should expand to ["build.deps", "build.compile"]
719        graph.add_dependency_edges().unwrap();
720
721        assert!(!graph.has_cycles());
722        assert_eq!(graph.task_count(), 3);
723
724        // test should come after both build.deps and build.compile
725        let sorted = graph.topological_sort().unwrap();
726        let positions: HashMap<String, usize> = sorted
727            .iter()
728            .enumerate()
729            .map(|(i, node)| (node.name.clone(), i))
730            .collect();
731
732        assert!(positions["build.deps"] < positions["test"]);
733        assert!(positions["build.compile"] < positions["test"]);
734    }
735
736    #[test]
737    fn test_group_as_dependency_sequential() {
738        let mut graph = TaskGraph::new();
739        let tasks = Tasks::new();
740
741        // Create a sequential group "setup" with two children
742        let task1 = create_task("s1", vec![], vec![]);
743        let task2 = create_task("s2", vec![], vec![]);
744
745        // Build the sequence first
746        let setup_node = TaskNode::Sequence(vec![
747            TaskNode::Task(Box::new(task1)),
748            TaskNode::Task(Box::new(task2)),
749        ]);
750        graph.build_from_node("setup", &setup_node, &tasks).unwrap();
751
752        // Add a task that depends on the group name "setup"
753        let run_task = create_task("run", vec!["setup"], vec![]);
754        graph.add_task("run", run_task).unwrap();
755
756        // This should succeed - "setup" should expand to ["setup[0]", "setup[1]"]
757        graph.add_dependency_edges().unwrap();
758
759        assert!(!graph.has_cycles());
760        assert_eq!(graph.task_count(), 3);
761
762        // run should come after both setup[0] and setup[1]
763        let sorted = graph.topological_sort().unwrap();
764        let positions: HashMap<String, usize> = sorted
765            .iter()
766            .enumerate()
767            .map(|(i, node)| (node.name.clone(), i))
768            .collect();
769
770        assert!(positions["setup[0]"] < positions["run"]);
771        assert!(positions["setup[1]"] < positions["run"]);
772    }
773
774    #[test]
775    fn test_nested_group_as_dependency() {
776        let mut graph = TaskGraph::new();
777        let tasks = Tasks::new();
778
779        // Create a nested structure:
780        // build (parallel)
781        //   ├── frontend (sequential)
782        //   │   ├── frontend[0]
783        //   │   └── frontend[1]
784        //   └── backend (single task)
785
786        let frontend_t1 = create_task("fe1", vec![], vec![]);
787        let frontend_t2 = create_task("fe2", vec![], vec![]);
788        let frontend_seq = TaskNode::Sequence(vec![
789            TaskNode::Task(Box::new(frontend_t1)),
790            TaskNode::Task(Box::new(frontend_t2)),
791        ]);
792
793        let backend_task = create_task("be", vec![], vec![]);
794
795        let mut parallel_tasks = HashMap::new();
796        parallel_tasks.insert("frontend".to_string(), frontend_seq);
797        parallel_tasks.insert(
798            "backend".to_string(),
799            TaskNode::Task(Box::new(backend_task)),
800        );
801
802        let build_group = TaskGroup {
803            type_: "group".to_string(),
804            children: parallel_tasks,
805            depends_on: vec![],
806            description: None,
807            max_concurrency: None,
808        };
809
810        // Build the nested group
811        let build_node = TaskNode::Group(build_group);
812        graph.build_from_node("build", &build_node, &tasks).unwrap();
813
814        // Add a task that depends on "build"
815        let deploy_task = create_task("deploy", vec!["build"], vec![]);
816        graph.add_task("deploy", deploy_task).unwrap();
817
818        // This should expand "build" -> ["build.frontend", "build.backend"]
819        // And further expand "build.frontend" -> ["build.frontend[0]", "build.frontend[1]"]
820        graph.add_dependency_edges().unwrap();
821
822        assert!(!graph.has_cycles());
823        assert_eq!(graph.task_count(), 4); // frontend[0], frontend[1], backend, deploy
824
825        // deploy should come after all leaf tasks
826        let sorted = graph.topological_sort().unwrap();
827        let positions: HashMap<String, usize> = sorted
828            .iter()
829            .enumerate()
830            .map(|(i, node)| (node.name.clone(), i))
831            .collect();
832
833        assert!(positions["build.frontend[0]"] < positions["deploy"]);
834        assert!(positions["build.frontend[1]"] < positions["deploy"]);
835        assert!(positions["build.backend"] < positions["deploy"]);
836    }
837
838    #[test]
839    fn test_mixed_exact_and_group_dependencies() {
840        let mut graph = TaskGraph::new();
841        let tasks = Tasks::new();
842
843        // Add a standalone task
844        let lint_task = create_task("lint", vec![], vec![]);
845        graph.add_task("lint", lint_task).unwrap();
846
847        // Create a parallel group
848        let deps_task = create_task("deps", vec![], vec![]);
849        let compile_task = create_task("compile", vec![], vec![]);
850
851        let mut parallel_tasks = HashMap::new();
852        parallel_tasks.insert("deps".to_string(), TaskNode::Task(Box::new(deps_task)));
853        parallel_tasks.insert(
854            "compile".to_string(),
855            TaskNode::Task(Box::new(compile_task)),
856        );
857
858        let build_group = TaskGroup {
859            type_: "group".to_string(),
860            children: parallel_tasks,
861            depends_on: vec![],
862            description: None,
863            max_concurrency: None,
864        };
865
866        let build_node = TaskNode::Group(build_group);
867        graph.build_from_node("build", &build_node, &tasks).unwrap();
868
869        // Add a task that depends on both an exact task and a group
870        let test_task = create_task("test", vec!["lint", "build"], vec![]);
871        graph.add_task("test", test_task).unwrap();
872
873        graph.add_dependency_edges().unwrap();
874
875        assert!(!graph.has_cycles());
876        assert_eq!(graph.task_count(), 4);
877
878        // test should come after lint, build.deps, and build.compile
879        let sorted = graph.topological_sort().unwrap();
880        let positions: HashMap<String, usize> = sorted
881            .iter()
882            .enumerate()
883            .map(|(i, node)| (node.name.clone(), i))
884            .collect();
885
886        assert!(positions["lint"] < positions["test"]);
887        assert!(positions["build.deps"] < positions["test"]);
888        assert!(positions["build.compile"] < positions["test"]);
889    }
890
891    #[test]
892    fn test_cycle_with_group_expansion() {
893        let mut graph = TaskGraph::new();
894        let tasks = Tasks::new();
895
896        // Create a group where a child depends on a task that depends on the group
897        // This creates a cycle: setup[0] -> test, test -> setup (expands to setup[0], setup[1])
898
899        // First, add the task that will depend on the group
900        let test_task = create_task("test", vec!["setup"], vec![]);
901        graph.add_task("test", test_task).unwrap();
902
903        // Create the group where one child depends on test
904        let task1 = create_task("s1", vec!["test"], vec![]);
905        let task2 = create_task("s2", vec![], vec![]);
906
907        let setup_node = TaskNode::Sequence(vec![
908            TaskNode::Task(Box::new(task1)),
909            TaskNode::Task(Box::new(task2)),
910        ]);
911        graph.build_from_node("setup", &setup_node, &tasks).unwrap();
912
913        graph.add_dependency_edges().unwrap();
914
915        // This creates a cycle: test -> setup[0] -> test
916        assert!(graph.has_cycles());
917        assert!(graph.topological_sort().is_err());
918    }
919
920    // =========================================================================
921    // Behavioral Contract Tests
922    // =========================================================================
923    // These tests document and verify behavioral contracts that must hold.
924    // They are written to catch subtle bugs that might not break line coverage.
925
926    #[test]
927    fn contract_diamond_dependency_executes_shared_dep_once() {
928        // Contract: In a diamond dependency (A -> B, A -> C, B -> D, C -> D),
929        // task D should appear exactly once in the topological sort.
930        let mut graph = TaskGraph::new();
931
932        let task_d = create_task("d", vec![], vec![]);
933        let task_b = create_task("b", vec!["d"], vec![]);
934        let task_c = create_task("c", vec!["d"], vec![]);
935        let task_a = create_task("a", vec!["b", "c"], vec![]);
936
937        graph.add_task("d", task_d).unwrap();
938        graph.add_task("b", task_b).unwrap();
939        graph.add_task("c", task_c).unwrap();
940        graph.add_task("a", task_a).unwrap();
941        graph.add_dependency_edges().unwrap();
942
943        let sorted = graph.topological_sort().unwrap();
944        let names: Vec<&str> = sorted.iter().map(|n| n.name.as_str()).collect();
945
946        // D should appear exactly once
947        let d_count = names.iter().filter(|&&n| n == "d").count();
948        assert_eq!(
949            d_count, 1,
950            "Diamond dependency: shared task should appear exactly once"
951        );
952
953        // D must come before B and C
954        let d_pos = names.iter().position(|&n| n == "d").unwrap();
955        let b_pos = names.iter().position(|&n| n == "b").unwrap();
956        let c_pos = names.iter().position(|&n| n == "c").unwrap();
957        let a_pos = names.iter().position(|&n| n == "a").unwrap();
958
959        assert!(d_pos < b_pos, "D must execute before B");
960        assert!(d_pos < c_pos, "D must execute before C");
961        assert!(b_pos < a_pos, "B must execute before A");
962        assert!(c_pos < a_pos, "C must execute before A");
963    }
964
965    #[test]
966    fn contract_parallel_group_children_have_no_implicit_ordering() {
967        // Contract: Children in a parallel group should NOT have implicit
968        // ordering dependencies between them (they can run concurrently).
969        let mut graph = TaskGraph::new();
970        let tasks = Tasks::new();
971
972        let task1 = create_task("task1", vec![], vec![]);
973        let task2 = create_task("task2", vec![], vec![]);
974        let task3 = create_task("task3", vec![], vec![]);
975
976        let mut parallel_tasks = HashMap::new();
977        parallel_tasks.insert("task1".to_string(), TaskNode::Task(Box::new(task1)));
978        parallel_tasks.insert("task2".to_string(), TaskNode::Task(Box::new(task2)));
979        parallel_tasks.insert("task3".to_string(), TaskNode::Task(Box::new(task3)));
980
981        let parallel = TaskGroup {
982            type_: "group".to_string(),
983            children: parallel_tasks,
984            depends_on: vec![],
985            description: None,
986            max_concurrency: None,
987        };
988
989        let parallel_node = TaskNode::Group(parallel);
990        graph
991            .build_from_node("parallel", &parallel_node, &tasks)
992            .unwrap();
993        graph.add_dependency_edges().unwrap();
994
995        // All three tasks should be in the first (and only) parallel group
996        let groups = graph.get_parallel_groups().unwrap();
997        assert_eq!(groups.len(), 1, "All tasks should be in one parallel group");
998        assert_eq!(
999            groups[0].len(),
1000            3,
1001            "All three tasks should be executable in parallel"
1002        );
1003    }
1004
1005    #[test]
1006    fn contract_sequential_group_children_have_strict_ordering() {
1007        // Contract: Children in a sequential group MUST execute in order,
1008        // with each task depending on the previous one.
1009        let mut graph = TaskGraph::new();
1010        let tasks = Tasks::new();
1011
1012        let task1 = create_task("first", vec![], vec![]);
1013        let task2 = create_task("second", vec![], vec![]);
1014        let task3 = create_task("third", vec![], vec![]);
1015
1016        let seq_node = TaskNode::Sequence(vec![
1017            TaskNode::Task(Box::new(task1)),
1018            TaskNode::Task(Box::new(task2)),
1019            TaskNode::Task(Box::new(task3)),
1020        ]);
1021        graph.build_from_node("seq", &seq_node, &tasks).unwrap();
1022
1023        // Topological sort should maintain strict order
1024        let sorted = graph.topological_sort().unwrap();
1025        let names: Vec<&str> = sorted.iter().map(|n| n.name.as_str()).collect();
1026
1027        // Strict ordering: seq[0] < seq[1] < seq[2]
1028        let first_pos = names.iter().position(|&n| n == "seq[0]").unwrap();
1029        let second_pos = names.iter().position(|&n| n == "seq[1]").unwrap();
1030        let third_pos = names.iter().position(|&n| n == "seq[2]").unwrap();
1031
1032        assert!(first_pos < second_pos, "seq[0] must execute before seq[1]");
1033        assert!(second_pos < third_pos, "seq[1] must execute before seq[2]");
1034    }
1035
1036    #[test]
1037    fn contract_task_with_label_is_discoverable() {
1038        // Contract: Tasks with labels can be found by label
1039        let task = create_task("build", vec![], vec!["ci", "fast"]);
1040        assert!(task.labels.contains(&"ci".to_string()));
1041        assert!(task.labels.contains(&"fast".to_string()));
1042    }
1043
1044    #[test]
1045    fn contract_topological_sort_is_deterministic() {
1046        // Contract: Multiple calls to topological_sort on the same graph
1047        // should produce the same order (deterministic for reproducibility).
1048        let mut graph = TaskGraph::new();
1049
1050        let task_a = create_task("a", vec![], vec![]);
1051        let task_b = create_task("b", vec!["a"], vec![]);
1052        let task_c = create_task("c", vec!["a"], vec![]);
1053        let task_d = create_task("d", vec!["b", "c"], vec![]);
1054
1055        graph.add_task("a", task_a).unwrap();
1056        graph.add_task("b", task_b).unwrap();
1057        graph.add_task("c", task_c).unwrap();
1058        graph.add_task("d", task_d).unwrap();
1059        graph.add_dependency_edges().unwrap();
1060
1061        let sort1 = graph.topological_sort().unwrap();
1062        let sort2 = graph.topological_sort().unwrap();
1063        let sort3 = graph.topological_sort().unwrap();
1064
1065        let names1: Vec<&str> = sort1.iter().map(|n| n.name.as_str()).collect();
1066        let names2: Vec<&str> = sort2.iter().map(|n| n.name.as_str()).collect();
1067        let names3: Vec<&str> = sort3.iter().map(|n| n.name.as_str()).collect();
1068
1069        assert_eq!(names1, names2, "Topological sort should be deterministic");
1070        assert_eq!(names2, names3, "Topological sort should be deterministic");
1071    }
1072
1073    #[test]
1074    fn contract_cycle_detection_catches_all_cycle_types() {
1075        // Contract: Cycle detection must work for:
1076        // 1. Self-cycles (A -> A)
1077        // 2. Two-node cycles (A -> B -> A)
1078        // 3. Multi-node cycles (A -> B -> C -> A)
1079
1080        // Self-cycle
1081        let mut graph1 = TaskGraph::new();
1082        let self_loop = create_task("self", vec!["self"], vec![]);
1083        graph1.add_task("self", self_loop).unwrap();
1084        graph1.add_dependency_edges().unwrap();
1085        assert!(graph1.has_cycles(), "Self-cycle must be detected");
1086
1087        // Two-node cycle
1088        let mut graph2 = TaskGraph::new();
1089        let a = create_task("a", vec!["b"], vec![]);
1090        let b = create_task("b", vec!["a"], vec![]);
1091        graph2.add_task("a", a).unwrap();
1092        graph2.add_task("b", b).unwrap();
1093        graph2.add_dependency_edges().unwrap();
1094        assert!(graph2.has_cycles(), "Two-node cycle must be detected");
1095
1096        // Three-node cycle
1097        let mut graph3 = TaskGraph::new();
1098        let x = create_task("x", vec!["y"], vec![]);
1099        let y = create_task("y", vec!["z"], vec![]);
1100        let z = create_task("z", vec!["x"], vec![]);
1101        graph3.add_task("x", x).unwrap();
1102        graph3.add_task("y", y).unwrap();
1103        graph3.add_task("z", z).unwrap();
1104        graph3.add_dependency_edges().unwrap();
1105        assert!(graph3.has_cycles(), "Three-node cycle must be detected");
1106    }
1107
1108    #[test]
1109    fn contract_missing_dependency_is_reported() {
1110        // Contract: If a task depends on a non-existent task, an error
1111        // should be returned with the missing task name.
1112        let mut graph = TaskGraph::new();
1113        let task = create_task("build", vec!["nonexistent"], vec![]);
1114        graph.add_task("build", task).unwrap();
1115
1116        let result = graph.add_dependency_edges();
1117        assert!(result.is_err(), "Missing dependency should be an error");
1118
1119        let err = result.unwrap_err().to_string();
1120        assert!(
1121            err.contains("nonexistent") || err.contains("not found"),
1122            "Error should mention the missing task name: {err}"
1123        );
1124    }
1125
1126    // =========================================================================
1127    // TaskResolver Tests
1128    // =========================================================================
1129
1130    #[test]
1131    fn test_parse_path_segments_simple_name() {
1132        let segments = super::parse_path_segments("build");
1133        assert_eq!(segments, vec![super::PathSegment::Name("build".into())]);
1134    }
1135
1136    #[test]
1137    fn test_parse_path_segments_dotted() {
1138        let segments = super::parse_path_segments("build.frontend");
1139        assert_eq!(
1140            segments,
1141            vec![
1142                super::PathSegment::Name("build".into()),
1143                super::PathSegment::Name("frontend".into()),
1144            ]
1145        );
1146    }
1147
1148    #[test]
1149    fn test_parse_path_segments_indexed() {
1150        let segments = super::parse_path_segments("build[0]");
1151        assert_eq!(
1152            segments,
1153            vec![
1154                super::PathSegment::Name("build".into()),
1155                super::PathSegment::Index(0),
1156            ]
1157        );
1158    }
1159
1160    #[test]
1161    fn test_parse_path_segments_nested() {
1162        let segments = super::parse_path_segments("build.frontend[0]");
1163        assert_eq!(
1164            segments,
1165            vec![
1166                super::PathSegment::Name("build".into()),
1167                super::PathSegment::Name("frontend".into()),
1168                super::PathSegment::Index(0),
1169            ]
1170        );
1171    }
1172
1173    #[test]
1174    fn test_task_resolver_single_task() {
1175        use cuenv_task_graph::TaskResolver;
1176
1177        let task = create_task("build", vec![], vec![]);
1178        let mut tasks = Tasks::new();
1179        tasks
1180            .tasks
1181            .insert("build".into(), TaskNode::Task(Box::new(task)));
1182
1183        let resolution = tasks.resolve("build");
1184        assert!(resolution.is_some());
1185        match resolution.unwrap() {
1186            TaskResolution::Single(t) => assert_eq!(t.command, "echo build"),
1187            _ => panic!("Expected Single resolution"),
1188        }
1189    }
1190
1191    #[test]
1192    fn test_task_resolver_parallel_group() {
1193        use cuenv_task_graph::TaskResolver;
1194
1195        let frontend = create_task("frontend", vec![], vec![]);
1196        let backend = create_task("backend", vec![], vec![]);
1197
1198        let mut parallel_tasks = HashMap::new();
1199        parallel_tasks.insert("frontend".into(), TaskNode::Task(Box::new(frontend)));
1200        parallel_tasks.insert("backend".into(), TaskNode::Task(Box::new(backend)));
1201
1202        let group = TaskGroup {
1203            type_: "group".to_string(),
1204            children: parallel_tasks,
1205            depends_on: vec![TaskDependency::from_name("setup")],
1206            description: None,
1207            max_concurrency: None,
1208        };
1209
1210        let mut tasks = Tasks::new();
1211        tasks.tasks.insert("build".into(), TaskNode::Group(group));
1212
1213        let resolution = tasks.resolve("build");
1214        assert!(resolution.is_some());
1215        match resolution.unwrap() {
1216            TaskResolution::Parallel {
1217                children,
1218                depends_on,
1219            } => {
1220                assert_eq!(children.len(), 2);
1221                assert!(children.contains(&"build.frontend".to_string()));
1222                assert!(children.contains(&"build.backend".to_string()));
1223                assert_eq!(depends_on, vec!["setup"]);
1224            }
1225            _ => panic!("Expected Parallel resolution"),
1226        }
1227    }
1228
1229    #[test]
1230    fn test_task_resolver_sequential_group() {
1231        use cuenv_task_graph::TaskResolver;
1232
1233        let task1 = create_task("t1", vec![], vec![]);
1234        let task2 = create_task("t2", vec![], vec![]);
1235
1236        let seq = TaskNode::Sequence(vec![
1237            TaskNode::Task(Box::new(task1)),
1238            TaskNode::Task(Box::new(task2)),
1239        ]);
1240
1241        let mut tasks = Tasks::new();
1242        tasks.tasks.insert("build".into(), seq);
1243
1244        let resolution = tasks.resolve("build");
1245        assert!(resolution.is_some());
1246        match resolution.unwrap() {
1247            TaskResolution::Sequential { children } => {
1248                assert_eq!(children, vec!["build[0]", "build[1]"]);
1249            }
1250            _ => panic!("Expected Sequential resolution"),
1251        }
1252    }
1253
1254    #[test]
1255    fn test_task_resolver_nested_path() {
1256        use cuenv_task_graph::TaskResolver;
1257
1258        let task = create_task("fe", vec![], vec![]);
1259
1260        let mut parallel_tasks = HashMap::new();
1261        parallel_tasks.insert("frontend".into(), TaskNode::Task(Box::new(task)));
1262
1263        let group = TaskGroup {
1264            type_: "group".to_string(),
1265            children: parallel_tasks,
1266            depends_on: vec![],
1267            description: None,
1268            max_concurrency: None,
1269        };
1270
1271        let mut tasks = Tasks::new();
1272        tasks.tasks.insert("build".into(), TaskNode::Group(group));
1273
1274        // Resolve nested path
1275        let resolution = tasks.resolve("build.frontend");
1276        assert!(resolution.is_some());
1277        match resolution.unwrap() {
1278            TaskResolution::Single(t) => assert_eq!(t.command, "echo fe"),
1279            _ => panic!("Expected Single resolution"),
1280        }
1281    }
1282
1283    #[test]
1284    fn test_task_resolver_nonexistent() {
1285        use cuenv_task_graph::TaskResolver;
1286
1287        let tasks = Tasks::new();
1288        assert!(tasks.resolve("nonexistent").is_none());
1289    }
1290}