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    /// Add implicit dependency edges inferred from task output references.
372    ///
373    /// Each pair `(from_task, to_task)` means `from_task` references an output
374    /// of `to_task` and therefore depends on it. This creates both:
375    /// - A `dependsOn` entry on the task data (for consistency)
376    /// - An actual petgraph edge (so topological sort / parallel groups work)
377    ///
378    /// When a referenced target task is not yet in the graph, it is added
379    /// (along with its transitive dependencies) from `all_tasks`. This is
380    /// necessary because `build_for_task` only follows explicit `dependsOn`
381    /// edges and won't discover tasks that are only referenced via output refs.
382    pub fn add_output_ref_deps(
383        &mut self,
384        deps: &[(String, String)],
385        all_tasks: &Tasks,
386    ) -> Result<()> {
387        for (from, to) in deps {
388            // Only process pairs where the source task is already in the graph.
389            // This avoids pulling in unrelated tasks (e.g., pipeline[1]→pipeline[0]
390            // when the user only asked to run "work").
391            if self.inner.get_node_index(from).is_none() {
392                continue;
393            }
394
395            // Ensure the target task is in the graph (it may not be if the
396            // only link to it is through an output reference).
397            if self.inner.get_node_index(to).is_none() {
398                self.inner
399                    .build_for_task_with_resolver(to, all_tasks)
400                    .map_err(|e| crate::Error::configuration(e.to_string()))?;
401            }
402
403            let from_idx = self.inner.get_node_index(from);
404            let to_idx = self.inner.get_node_index(to);
405
406            if let (Some(from_idx), Some(to_idx)) = (from_idx, to_idx) {
407                // Skip if this dependency already exists (e.g., user also has explicit dependsOn)
408                let already_exists = self
409                    .inner
410                    .get_task_mut(from)
411                    .is_some_and(|d| d.has_dependency(to));
412
413                if !already_exists {
414                    // Add dependency to task data for consistency
415                    if let Some(from_data) = self.inner.get_task_mut(from) {
416                        from_data.add_dependency(to.clone());
417                    }
418                    // Create actual petgraph edge (to -> from means "to must run before from")
419                    self.inner.add_edge(to_idx, from_idx);
420                }
421            }
422        }
423        Ok(())
424    }
425}
426
427impl Default for TaskGraph {
428    fn default() -> Self {
429        Self::new()
430    }
431}
432
433#[cfg(test)]
434#[path = "graph_advanced_tests.rs"]
435mod graph_advanced_tests;
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::tasks::{TaskDependency, TaskGroup, TaskNode};
441    use crate::test_utils::create_task;
442    use std::collections::HashMap;
443
444    #[test]
445    fn test_task_graph_new() {
446        let graph = TaskGraph::new();
447        assert_eq!(graph.task_count(), 0);
448    }
449
450    #[test]
451    fn test_add_single_task() {
452        let mut graph = TaskGraph::new();
453        let task = create_task("test", vec![], vec![]);
454
455        let node = graph.add_task("test", task).unwrap();
456        assert!(graph.contains_task("test"));
457        assert_eq!(graph.task_count(), 1);
458
459        // Adding same task again should return same node
460        let task2 = create_task("test", vec![], vec![]);
461        let node2 = graph.add_task("test", task2).unwrap();
462        assert_eq!(node, node2);
463        assert_eq!(graph.task_count(), 1);
464    }
465
466    #[test]
467    fn test_task_dependencies() {
468        let mut graph = TaskGraph::new();
469
470        // Add tasks with dependencies
471        let task1 = create_task("task1", vec![], vec![]);
472        let task2 = create_task("task2", vec!["task1"], vec![]);
473        let task3 = create_task("task3", vec!["task1", "task2"], vec![]);
474
475        graph.add_task("task1", task1).unwrap();
476        graph.add_task("task2", task2).unwrap();
477        graph.add_task("task3", task3).unwrap();
478        graph.add_dependency_edges().unwrap();
479
480        assert_eq!(graph.task_count(), 3);
481        assert!(!graph.has_cycles());
482
483        let sorted = graph.topological_sort().unwrap();
484        assert_eq!(sorted.len(), 3);
485
486        // task1 should come before task2 and task3
487        let positions: HashMap<String, usize> = sorted
488            .iter()
489            .enumerate()
490            .map(|(i, node)| (node.name.clone(), i))
491            .collect();
492
493        assert!(positions["task1"] < positions["task2"]);
494        assert!(positions["task1"] < positions["task3"]);
495        assert!(positions["task2"] < positions["task3"]);
496    }
497
498    #[test]
499    fn test_cycle_detection() {
500        let mut graph = TaskGraph::new();
501
502        // Create a cycle: task1 -> task2 -> task3 -> task1
503        let task1 = create_task("task1", vec!["task3"], vec![]);
504        let task2 = create_task("task2", vec!["task1"], vec![]);
505        let task3 = create_task("task3", vec!["task2"], vec![]);
506
507        graph.add_task("task1", task1).unwrap();
508        graph.add_task("task2", task2).unwrap();
509        graph.add_task("task3", task3).unwrap();
510        graph.add_dependency_edges().unwrap();
511
512        assert!(graph.has_cycles());
513        assert!(graph.topological_sort().is_err());
514    }
515
516    #[test]
517    fn test_parallel_groups() {
518        let mut graph = TaskGraph::new();
519
520        // Create tasks that can run in parallel
521        // Level 0: task1, task2 (no dependencies)
522        // Level 1: task3 (depends on task1), task4 (depends on task2)
523        // Level 2: task5 (depends on task3 and task4)
524
525        let task1 = create_task("task1", vec![], vec![]);
526        let task2 = create_task("task2", vec![], vec![]);
527        let task3 = create_task("task3", vec!["task1"], vec![]);
528        let task4 = create_task("task4", vec!["task2"], vec![]);
529        let task5 = create_task("task5", vec!["task3", "task4"], vec![]);
530
531        graph.add_task("task1", task1).unwrap();
532        graph.add_task("task2", task2).unwrap();
533        graph.add_task("task3", task3).unwrap();
534        graph.add_task("task4", task4).unwrap();
535        graph.add_task("task5", task5).unwrap();
536        graph.add_dependency_edges().unwrap();
537
538        let groups = graph.get_parallel_groups().unwrap();
539
540        // Should have 3 levels
541        assert_eq!(groups.len(), 3);
542
543        // Level 0 should have 2 tasks
544        assert_eq!(groups[0].len(), 2);
545
546        // Level 1 should have 2 tasks
547        assert_eq!(groups[1].len(), 2);
548
549        // Level 2 should have 1 task
550        assert_eq!(groups[2].len(), 1);
551        assert_eq!(groups[2][0].name, "task5");
552    }
553
554    #[test]
555    fn test_build_from_sequential_group() {
556        let mut graph = TaskGraph::new();
557        let tasks = Tasks::new();
558
559        let task1 = create_task("t1", vec![], vec![]);
560        let task2 = create_task("t2", vec![], vec![]);
561
562        let node = TaskNode::Sequence(vec![
563            TaskNode::Task(Box::new(task1)),
564            TaskNode::Task(Box::new(task2)),
565        ]);
566        let nodes = graph.build_from_node("seq", &node, &tasks).unwrap();
567        assert_eq!(nodes.len(), 2);
568
569        // Sequential tasks should have dependency chain
570        let sorted = graph.topological_sort().unwrap();
571        assert_eq!(sorted.len(), 2);
572        assert_eq!(sorted[0].name, "seq[0]");
573        assert_eq!(sorted[1].name, "seq[1]");
574    }
575
576    #[test]
577    fn test_build_from_parallel_group() {
578        let mut graph = TaskGraph::new();
579        let tasks = Tasks::new();
580
581        let task1 = create_task("t1", vec![], vec![]);
582        let task2 = create_task("t2", vec![], vec![]);
583
584        let mut parallel_tasks = HashMap::new();
585        parallel_tasks.insert("first".to_string(), TaskNode::Task(Box::new(task1)));
586        parallel_tasks.insert("second".to_string(), TaskNode::Task(Box::new(task2)));
587
588        let group = TaskGroup {
589            type_: "group".to_string(),
590            children: parallel_tasks,
591            depends_on: vec![],
592            description: None,
593            max_concurrency: None,
594        };
595
596        let node = TaskNode::Group(group);
597        let nodes = graph.build_from_node("par", &node, &tasks).unwrap();
598        assert_eq!(nodes.len(), 2);
599
600        // Parallel tasks should not have dependencies between them
601        assert!(!graph.has_cycles());
602
603        let groups = graph.get_parallel_groups().unwrap();
604        assert_eq!(groups.len(), 1); // All in same level
605        assert_eq!(groups[0].len(), 2); // Both can run in parallel
606    }
607
608    #[test]
609    fn test_three_way_cycle_detection() {
610        let mut graph = TaskGraph::new();
611
612        // Create cyclic dependencies: A -> B -> C -> A
613        let task_a = create_task("task_a", vec!["task_c"], vec![]);
614        let task_b = create_task("task_b", vec!["task_a"], vec![]);
615        let task_c = create_task("task_c", vec!["task_b"], vec![]);
616
617        graph.add_task("task_a", task_a).unwrap();
618        graph.add_task("task_b", task_b).unwrap();
619        graph.add_task("task_c", task_c).unwrap();
620        graph.add_dependency_edges().unwrap();
621
622        // This should create a cycle
623        assert!(graph.has_cycles());
624
625        // Should fail when trying to get parallel groups
626        assert!(graph.get_parallel_groups().is_err());
627    }
628
629    // ---------------------------------------------------------------------
630    // add_output_ref_deps()
631    // ---------------------------------------------------------------------
632
633    #[test]
634    fn output_ref_deps_adds_missing_target_and_edge() {
635        // Build a task set where `work` has no explicit dependsOn, but at
636        // runtime references output from `tmpdir`. The inferred pair is
637        // (work -> tmpdir). The graph is first built only for `work`, then
638        // add_output_ref_deps() should pull in `tmpdir` and add the edge.
639        let mut tasks = Tasks::new();
640        let tmpdir = create_task("tmpdir", vec![], vec![]);
641        let work = create_task("work", vec![], vec![]);
642        tasks
643            .tasks
644            .insert("tmpdir".into(), TaskNode::Task(Box::new(tmpdir)));
645        tasks
646            .tasks
647            .insert("work".into(), TaskNode::Task(Box::new(work)));
648
649        let mut graph = TaskGraph::new();
650        graph.build_for_task("work", &tasks).unwrap();
651        assert!(graph.contains_task("work"));
652        assert!(!graph.contains_task("tmpdir"));
653
654        graph
655            .add_output_ref_deps(&[("work".into(), "tmpdir".into())], &tasks)
656            .unwrap();
657
658        // tmpdir should now be present and ordered before work
659        assert!(graph.contains_task("tmpdir"));
660        let sorted = graph.topological_sort().unwrap();
661        let names: Vec<_> = sorted.iter().map(|n| n.name.as_str()).collect();
662        let pos_work = names.iter().position(|&n| n == "work").unwrap();
663        let pos_tmp = names.iter().position(|&n| n == "tmpdir").unwrap();
664        assert!(pos_tmp < pos_work, "tmpdir must precede work");
665
666        // "work" task data should have an implicit dependency recorded
667        let work_data = graph.inner.get_task_mut("work").unwrap().clone();
668        assert!(work_data.has_dependency("tmpdir"));
669    }
670
671    #[test]
672    fn output_ref_deps_skips_when_source_not_in_graph() {
673        // If the source (from) task isn't in the current graph, the pair is
674        // ignored (we shouldn't pull unrelated tasks into the graph).
675        let mut tasks = Tasks::new();
676        let a = create_task("a", vec![], vec![]);
677        let b = create_task("b", vec![], vec![]);
678        tasks.tasks.insert("a".into(), TaskNode::Task(Box::new(a)));
679        tasks.tasks.insert("b".into(), TaskNode::Task(Box::new(b)));
680
681        let mut graph = TaskGraph::new();
682        graph.build_for_task("a", &tasks).unwrap();
683        assert!(graph.contains_task("a"));
684        assert!(!graph.contains_task("b"));
685
686        graph
687            .add_output_ref_deps(&[("x".into(), "b".into())], &tasks)
688            .unwrap();
689
690        // Graph should be unchanged
691        assert!(graph.contains_task("a"));
692        assert!(!graph.contains_task("b"));
693    }
694
695    #[test]
696    fn output_ref_deps_no_duplicate_for_existing_dependency() {
697        // If an explicit dependsOn already exists, add_output_ref_deps should
698        // not duplicate the dependency or add extra edges.
699        let mut tasks = Tasks::new();
700        let tmpdir = create_task("tmpdir", vec![], vec![]);
701        let work = create_task("work", vec!["tmpdir"], vec![]);
702        tasks
703            .tasks
704            .insert("tmpdir".into(), TaskNode::Task(Box::new(tmpdir)));
705        tasks
706            .tasks
707            .insert("work".into(), TaskNode::Task(Box::new(work)));
708
709        let mut graph = TaskGraph::new();
710        graph.build_for_task("work", &tasks).unwrap();
711        // Explicit edges first
712        graph.add_dependency_edges().unwrap();
713        let sorted1 = graph.topological_sort().unwrap();
714        let names1: Vec<_> = sorted1.iter().map(|n| n.name.as_str()).collect();
715        assert!(
716            names1.iter().position(|&n| n == "tmpdir").unwrap()
717                < names1.iter().position(|&n| n == "work").unwrap()
718        );
719
720        // Now apply output-ref deps; should be a no-op logically
721        graph
722            .add_output_ref_deps(&[("work".into(), "tmpdir".into())], &tasks)
723            .unwrap();
724        let sorted2 = graph.topological_sort().unwrap();
725        let names2: Vec<_> = sorted2.iter().map(|n| n.name.as_str()).collect();
726        assert_eq!(names1, names2, "topology should remain unchanged");
727
728        // And "work" should still report a single dependency on tmpdir
729        let work_data = graph.inner.get_task_mut("work").unwrap().clone();
730        assert!(work_data.has_dependency("tmpdir"));
731    }
732
733    #[test]
734    fn test_self_dependency_cycle() {
735        let mut graph = TaskGraph::new();
736
737        // Create self-referencing task
738        let task = create_task("self_ref", vec!["self_ref"], vec![]);
739        graph.add_task("self_ref", task).unwrap();
740        graph.add_dependency_edges().unwrap();
741
742        assert!(graph.has_cycles());
743        assert!(graph.get_parallel_groups().is_err());
744    }
745
746    #[test]
747    fn test_complex_dependency_graph() {
748        let mut graph = TaskGraph::new();
749
750        // Create a diamond dependency pattern:
751        //     A
752        //    / \
753        //   B   C
754        //    \ /
755        //     D
756        let task_a = create_task("a", vec![], vec![]);
757        let task_b = create_task("b", vec!["a"], vec![]);
758        let task_c = create_task("c", vec!["a"], vec![]);
759        let task_d = create_task("d", vec!["b", "c"], vec![]);
760
761        graph.add_task("a", task_a).unwrap();
762        graph.add_task("b", task_b).unwrap();
763        graph.add_task("c", task_c).unwrap();
764        graph.add_task("d", task_d).unwrap();
765        graph.add_dependency_edges().unwrap();
766
767        assert!(!graph.has_cycles());
768        assert_eq!(graph.task_count(), 4);
769
770        let groups = graph.get_parallel_groups().unwrap();
771
772        // Should have 3 levels: [A], [B,C], [D]
773        assert_eq!(groups.len(), 3);
774        assert_eq!(groups[0].len(), 1); // A
775        assert_eq!(groups[1].len(), 2); // B and C can run in parallel
776        assert_eq!(groups[2].len(), 1); // D
777    }
778
779    #[test]
780    fn test_missing_dependency() {
781        let mut graph = TaskGraph::new();
782
783        // Create task with dependency that doesn't exist
784        let task = create_task("dependent", vec!["missing"], vec![]);
785        graph.add_task("dependent", task).unwrap();
786
787        // Should fail to get parallel groups due to missing dependency
788        assert!(graph.add_dependency_edges().is_err());
789    }
790
791    #[test]
792    fn test_empty_graph() {
793        let graph = TaskGraph::new();
794
795        assert_eq!(graph.task_count(), 0);
796        assert!(!graph.has_cycles());
797
798        let groups = graph.get_parallel_groups().unwrap();
799        assert!(groups.is_empty());
800    }
801
802    #[test]
803    fn test_single_task_no_deps() {
804        let mut graph = TaskGraph::new();
805
806        let task = create_task("solo", vec![], vec![]);
807        graph.add_task("solo", task).unwrap();
808
809        assert_eq!(graph.task_count(), 1);
810        assert!(!graph.has_cycles());
811
812        let groups = graph.get_parallel_groups().unwrap();
813        assert_eq!(groups.len(), 1);
814        assert_eq!(groups[0].len(), 1);
815    }
816
817    #[test]
818    fn test_linear_chain() {
819        let mut graph = TaskGraph::new();
820
821        // Create linear chain: A -> B -> C -> D
822        let task_a = create_task("a", vec![], vec![]);
823        let task_b = create_task("b", vec!["a"], vec![]);
824        let task_c = create_task("c", vec!["b"], vec![]);
825        let task_d = create_task("d", vec!["c"], vec![]);
826
827        graph.add_task("a", task_a).unwrap();
828        graph.add_task("b", task_b).unwrap();
829        graph.add_task("c", task_c).unwrap();
830        graph.add_task("d", task_d).unwrap();
831        graph.add_dependency_edges().unwrap();
832
833        assert!(!graph.has_cycles());
834        assert_eq!(graph.task_count(), 4);
835
836        let groups = graph.get_parallel_groups().unwrap();
837
838        // Should be 4 sequential groups
839        assert_eq!(groups.len(), 4);
840        for group in &groups {
841            assert_eq!(group.len(), 1);
842        }
843    }
844
845    #[test]
846    fn test_group_as_dependency_parallel() {
847        let mut graph = TaskGraph::new();
848        let tasks = Tasks::new();
849
850        // Create a parallel group "build" with two children
851        let deps_task = create_task("deps", vec![], vec![]);
852        let compile_task = create_task("compile", vec![], vec![]);
853
854        let mut parallel_tasks = HashMap::new();
855        parallel_tasks.insert("deps".to_string(), TaskNode::Task(Box::new(deps_task)));
856        parallel_tasks.insert(
857            "compile".to_string(),
858            TaskNode::Task(Box::new(compile_task)),
859        );
860
861        let build_group = TaskGroup {
862            type_: "group".to_string(),
863            children: parallel_tasks,
864            depends_on: vec![],
865            description: None,
866            max_concurrency: None,
867        };
868
869        // Build the group first
870        let build_node = TaskNode::Group(build_group);
871        graph.build_from_node("build", &build_node, &tasks).unwrap();
872
873        // Add a task that depends on the group name "build"
874        let test_task = create_task("test", vec!["build"], vec![]);
875        graph.add_task("test", test_task).unwrap();
876
877        // This should succeed - "build" should expand to ["build.deps", "build.compile"]
878        graph.add_dependency_edges().unwrap();
879
880        assert!(!graph.has_cycles());
881        assert_eq!(graph.task_count(), 3);
882
883        // test should come after both build.deps and build.compile
884        let sorted = graph.topological_sort().unwrap();
885        let positions: HashMap<String, usize> = sorted
886            .iter()
887            .enumerate()
888            .map(|(i, node)| (node.name.clone(), i))
889            .collect();
890
891        assert!(positions["build.deps"] < positions["test"]);
892        assert!(positions["build.compile"] < positions["test"]);
893    }
894
895    #[test]
896    fn test_group_as_dependency_sequential() {
897        let mut graph = TaskGraph::new();
898        let tasks = Tasks::new();
899
900        // Create a sequential group "setup" with two children
901        let task1 = create_task("s1", vec![], vec![]);
902        let task2 = create_task("s2", vec![], vec![]);
903
904        // Build the sequence first
905        let setup_node = TaskNode::Sequence(vec![
906            TaskNode::Task(Box::new(task1)),
907            TaskNode::Task(Box::new(task2)),
908        ]);
909        graph.build_from_node("setup", &setup_node, &tasks).unwrap();
910
911        // Add a task that depends on the group name "setup"
912        let run_task = create_task("run", vec!["setup"], vec![]);
913        graph.add_task("run", run_task).unwrap();
914
915        // This should succeed - "setup" should expand to ["setup[0]", "setup[1]"]
916        graph.add_dependency_edges().unwrap();
917
918        assert!(!graph.has_cycles());
919        assert_eq!(graph.task_count(), 3);
920
921        // run should come after both setup[0] and setup[1]
922        let sorted = graph.topological_sort().unwrap();
923        let positions: HashMap<String, usize> = sorted
924            .iter()
925            .enumerate()
926            .map(|(i, node)| (node.name.clone(), i))
927            .collect();
928
929        assert!(positions["setup[0]"] < positions["run"]);
930        assert!(positions["setup[1]"] < positions["run"]);
931    }
932
933    #[test]
934    fn test_nested_group_as_dependency() {
935        let mut graph = TaskGraph::new();
936        let tasks = Tasks::new();
937
938        // Create a nested structure:
939        // build (parallel)
940        //   ├── frontend (sequential)
941        //   │   ├── frontend[0]
942        //   │   └── frontend[1]
943        //   └── backend (single task)
944
945        let frontend_t1 = create_task("fe1", vec![], vec![]);
946        let frontend_t2 = create_task("fe2", vec![], vec![]);
947        let frontend_seq = TaskNode::Sequence(vec![
948            TaskNode::Task(Box::new(frontend_t1)),
949            TaskNode::Task(Box::new(frontend_t2)),
950        ]);
951
952        let backend_task = create_task("be", vec![], vec![]);
953
954        let mut parallel_tasks = HashMap::new();
955        parallel_tasks.insert("frontend".to_string(), frontend_seq);
956        parallel_tasks.insert(
957            "backend".to_string(),
958            TaskNode::Task(Box::new(backend_task)),
959        );
960
961        let build_group = TaskGroup {
962            type_: "group".to_string(),
963            children: parallel_tasks,
964            depends_on: vec![],
965            description: None,
966            max_concurrency: None,
967        };
968
969        // Build the nested group
970        let build_node = TaskNode::Group(build_group);
971        graph.build_from_node("build", &build_node, &tasks).unwrap();
972
973        // Add a task that depends on "build"
974        let deploy_task = create_task("deploy", vec!["build"], vec![]);
975        graph.add_task("deploy", deploy_task).unwrap();
976
977        // This should expand "build" -> ["build.frontend", "build.backend"]
978        // And further expand "build.frontend" -> ["build.frontend[0]", "build.frontend[1]"]
979        graph.add_dependency_edges().unwrap();
980
981        assert!(!graph.has_cycles());
982        assert_eq!(graph.task_count(), 4); // frontend[0], frontend[1], backend, deploy
983
984        // deploy should come after all leaf tasks
985        let sorted = graph.topological_sort().unwrap();
986        let positions: HashMap<String, usize> = sorted
987            .iter()
988            .enumerate()
989            .map(|(i, node)| (node.name.clone(), i))
990            .collect();
991
992        assert!(positions["build.frontend[0]"] < positions["deploy"]);
993        assert!(positions["build.frontend[1]"] < positions["deploy"]);
994        assert!(positions["build.backend"] < positions["deploy"]);
995    }
996
997    #[test]
998    fn test_mixed_exact_and_group_dependencies() {
999        let mut graph = TaskGraph::new();
1000        let tasks = Tasks::new();
1001
1002        // Add a standalone task
1003        let lint_task = create_task("lint", vec![], vec![]);
1004        graph.add_task("lint", lint_task).unwrap();
1005
1006        // Create a parallel group
1007        let deps_task = create_task("deps", vec![], vec![]);
1008        let compile_task = create_task("compile", vec![], vec![]);
1009
1010        let mut parallel_tasks = HashMap::new();
1011        parallel_tasks.insert("deps".to_string(), TaskNode::Task(Box::new(deps_task)));
1012        parallel_tasks.insert(
1013            "compile".to_string(),
1014            TaskNode::Task(Box::new(compile_task)),
1015        );
1016
1017        let build_group = TaskGroup {
1018            type_: "group".to_string(),
1019            children: parallel_tasks,
1020            depends_on: vec![],
1021            description: None,
1022            max_concurrency: None,
1023        };
1024
1025        let build_node = TaskNode::Group(build_group);
1026        graph.build_from_node("build", &build_node, &tasks).unwrap();
1027
1028        // Add a task that depends on both an exact task and a group
1029        let test_task = create_task("test", vec!["lint", "build"], vec![]);
1030        graph.add_task("test", test_task).unwrap();
1031
1032        graph.add_dependency_edges().unwrap();
1033
1034        assert!(!graph.has_cycles());
1035        assert_eq!(graph.task_count(), 4);
1036
1037        // test should come after lint, build.deps, and build.compile
1038        let sorted = graph.topological_sort().unwrap();
1039        let positions: HashMap<String, usize> = sorted
1040            .iter()
1041            .enumerate()
1042            .map(|(i, node)| (node.name.clone(), i))
1043            .collect();
1044
1045        assert!(positions["lint"] < positions["test"]);
1046        assert!(positions["build.deps"] < positions["test"]);
1047        assert!(positions["build.compile"] < positions["test"]);
1048    }
1049
1050    #[test]
1051    fn test_cycle_with_group_expansion() {
1052        let mut graph = TaskGraph::new();
1053        let tasks = Tasks::new();
1054
1055        // Create a group where a child depends on a task that depends on the group
1056        // This creates a cycle: setup[0] -> test, test -> setup (expands to setup[0], setup[1])
1057
1058        // First, add the task that will depend on the group
1059        let test_task = create_task("test", vec!["setup"], vec![]);
1060        graph.add_task("test", test_task).unwrap();
1061
1062        // Create the group where one child depends on test
1063        let task1 = create_task("s1", vec!["test"], vec![]);
1064        let task2 = create_task("s2", vec![], vec![]);
1065
1066        let setup_node = TaskNode::Sequence(vec![
1067            TaskNode::Task(Box::new(task1)),
1068            TaskNode::Task(Box::new(task2)),
1069        ]);
1070        graph.build_from_node("setup", &setup_node, &tasks).unwrap();
1071
1072        graph.add_dependency_edges().unwrap();
1073
1074        // This creates a cycle: test -> setup[0] -> test
1075        assert!(graph.has_cycles());
1076        assert!(graph.topological_sort().is_err());
1077    }
1078
1079    // =========================================================================
1080    // Behavioral Contract Tests
1081    // =========================================================================
1082    // These tests document and verify behavioral contracts that must hold.
1083    // They are written to catch subtle bugs that might not break line coverage.
1084
1085    #[test]
1086    fn contract_diamond_dependency_executes_shared_dep_once() {
1087        // Contract: In a diamond dependency (A -> B, A -> C, B -> D, C -> D),
1088        // task D should appear exactly once in the topological sort.
1089        let mut graph = TaskGraph::new();
1090
1091        let task_d = create_task("d", vec![], vec![]);
1092        let task_b = create_task("b", vec!["d"], vec![]);
1093        let task_c = create_task("c", vec!["d"], vec![]);
1094        let task_a = create_task("a", vec!["b", "c"], vec![]);
1095
1096        graph.add_task("d", task_d).unwrap();
1097        graph.add_task("b", task_b).unwrap();
1098        graph.add_task("c", task_c).unwrap();
1099        graph.add_task("a", task_a).unwrap();
1100        graph.add_dependency_edges().unwrap();
1101
1102        let sorted = graph.topological_sort().unwrap();
1103        let names: Vec<&str> = sorted.iter().map(|n| n.name.as_str()).collect();
1104
1105        // D should appear exactly once
1106        let d_count = names.iter().filter(|&&n| n == "d").count();
1107        assert_eq!(
1108            d_count, 1,
1109            "Diamond dependency: shared task should appear exactly once"
1110        );
1111
1112        // D must come before B and C
1113        let d_pos = names.iter().position(|&n| n == "d").unwrap();
1114        let b_pos = names.iter().position(|&n| n == "b").unwrap();
1115        let c_pos = names.iter().position(|&n| n == "c").unwrap();
1116        let a_pos = names.iter().position(|&n| n == "a").unwrap();
1117
1118        assert!(d_pos < b_pos, "D must execute before B");
1119        assert!(d_pos < c_pos, "D must execute before C");
1120        assert!(b_pos < a_pos, "B must execute before A");
1121        assert!(c_pos < a_pos, "C must execute before A");
1122    }
1123
1124    #[test]
1125    fn contract_parallel_group_children_have_no_implicit_ordering() {
1126        // Contract: Children in a parallel group should NOT have implicit
1127        // ordering dependencies between them (they can run concurrently).
1128        let mut graph = TaskGraph::new();
1129        let tasks = Tasks::new();
1130
1131        let task1 = create_task("task1", vec![], vec![]);
1132        let task2 = create_task("task2", vec![], vec![]);
1133        let task3 = create_task("task3", vec![], vec![]);
1134
1135        let mut parallel_tasks = HashMap::new();
1136        parallel_tasks.insert("task1".to_string(), TaskNode::Task(Box::new(task1)));
1137        parallel_tasks.insert("task2".to_string(), TaskNode::Task(Box::new(task2)));
1138        parallel_tasks.insert("task3".to_string(), TaskNode::Task(Box::new(task3)));
1139
1140        let parallel = TaskGroup {
1141            type_: "group".to_string(),
1142            children: parallel_tasks,
1143            depends_on: vec![],
1144            description: None,
1145            max_concurrency: None,
1146        };
1147
1148        let parallel_node = TaskNode::Group(parallel);
1149        graph
1150            .build_from_node("parallel", &parallel_node, &tasks)
1151            .unwrap();
1152        graph.add_dependency_edges().unwrap();
1153
1154        // All three tasks should be in the first (and only) parallel group
1155        let groups = graph.get_parallel_groups().unwrap();
1156        assert_eq!(groups.len(), 1, "All tasks should be in one parallel group");
1157        assert_eq!(
1158            groups[0].len(),
1159            3,
1160            "All three tasks should be executable in parallel"
1161        );
1162    }
1163
1164    #[test]
1165    fn contract_sequential_group_children_have_strict_ordering() {
1166        // Contract: Children in a sequential group MUST execute in order,
1167        // with each task depending on the previous one.
1168        let mut graph = TaskGraph::new();
1169        let tasks = Tasks::new();
1170
1171        let task1 = create_task("first", vec![], vec![]);
1172        let task2 = create_task("second", vec![], vec![]);
1173        let task3 = create_task("third", vec![], vec![]);
1174
1175        let seq_node = TaskNode::Sequence(vec![
1176            TaskNode::Task(Box::new(task1)),
1177            TaskNode::Task(Box::new(task2)),
1178            TaskNode::Task(Box::new(task3)),
1179        ]);
1180        graph.build_from_node("seq", &seq_node, &tasks).unwrap();
1181
1182        // Topological sort should maintain strict order
1183        let sorted = graph.topological_sort().unwrap();
1184        let names: Vec<&str> = sorted.iter().map(|n| n.name.as_str()).collect();
1185
1186        // Strict ordering: seq[0] < seq[1] < seq[2]
1187        let first_pos = names.iter().position(|&n| n == "seq[0]").unwrap();
1188        let second_pos = names.iter().position(|&n| n == "seq[1]").unwrap();
1189        let third_pos = names.iter().position(|&n| n == "seq[2]").unwrap();
1190
1191        assert!(first_pos < second_pos, "seq[0] must execute before seq[1]");
1192        assert!(second_pos < third_pos, "seq[1] must execute before seq[2]");
1193    }
1194
1195    #[test]
1196    fn contract_task_with_label_is_discoverable() {
1197        // Contract: Tasks with labels can be found by label
1198        let task = create_task("build", vec![], vec!["ci", "fast"]);
1199        assert!(task.labels.contains(&"ci".to_string()));
1200        assert!(task.labels.contains(&"fast".to_string()));
1201    }
1202
1203    #[test]
1204    fn contract_topological_sort_is_deterministic() {
1205        // Contract: Multiple calls to topological_sort on the same graph
1206        // should produce the same order (deterministic for reproducibility).
1207        let mut graph = TaskGraph::new();
1208
1209        let task_a = create_task("a", vec![], vec![]);
1210        let task_b = create_task("b", vec!["a"], vec![]);
1211        let task_c = create_task("c", vec!["a"], vec![]);
1212        let task_d = create_task("d", vec!["b", "c"], vec![]);
1213
1214        graph.add_task("a", task_a).unwrap();
1215        graph.add_task("b", task_b).unwrap();
1216        graph.add_task("c", task_c).unwrap();
1217        graph.add_task("d", task_d).unwrap();
1218        graph.add_dependency_edges().unwrap();
1219
1220        let sort1 = graph.topological_sort().unwrap();
1221        let sort2 = graph.topological_sort().unwrap();
1222        let sort3 = graph.topological_sort().unwrap();
1223
1224        let names1: Vec<&str> = sort1.iter().map(|n| n.name.as_str()).collect();
1225        let names2: Vec<&str> = sort2.iter().map(|n| n.name.as_str()).collect();
1226        let names3: Vec<&str> = sort3.iter().map(|n| n.name.as_str()).collect();
1227
1228        assert_eq!(names1, names2, "Topological sort should be deterministic");
1229        assert_eq!(names2, names3, "Topological sort should be deterministic");
1230    }
1231
1232    #[test]
1233    fn contract_cycle_detection_catches_all_cycle_types() {
1234        // Contract: Cycle detection must work for:
1235        // 1. Self-cycles (A -> A)
1236        // 2. Two-node cycles (A -> B -> A)
1237        // 3. Multi-node cycles (A -> B -> C -> A)
1238
1239        // Self-cycle
1240        let mut graph1 = TaskGraph::new();
1241        let self_loop = create_task("self", vec!["self"], vec![]);
1242        graph1.add_task("self", self_loop).unwrap();
1243        graph1.add_dependency_edges().unwrap();
1244        assert!(graph1.has_cycles(), "Self-cycle must be detected");
1245
1246        // Two-node cycle
1247        let mut graph2 = TaskGraph::new();
1248        let a = create_task("a", vec!["b"], vec![]);
1249        let b = create_task("b", vec!["a"], vec![]);
1250        graph2.add_task("a", a).unwrap();
1251        graph2.add_task("b", b).unwrap();
1252        graph2.add_dependency_edges().unwrap();
1253        assert!(graph2.has_cycles(), "Two-node cycle must be detected");
1254
1255        // Three-node cycle
1256        let mut graph3 = TaskGraph::new();
1257        let x = create_task("x", vec!["y"], vec![]);
1258        let y = create_task("y", vec!["z"], vec![]);
1259        let z = create_task("z", vec!["x"], vec![]);
1260        graph3.add_task("x", x).unwrap();
1261        graph3.add_task("y", y).unwrap();
1262        graph3.add_task("z", z).unwrap();
1263        graph3.add_dependency_edges().unwrap();
1264        assert!(graph3.has_cycles(), "Three-node cycle must be detected");
1265    }
1266
1267    #[test]
1268    fn contract_missing_dependency_is_reported() {
1269        // Contract: If a task depends on a non-existent task, an error
1270        // should be returned with the missing task name.
1271        let mut graph = TaskGraph::new();
1272        let task = create_task("build", vec!["nonexistent"], vec![]);
1273        graph.add_task("build", task).unwrap();
1274
1275        let result = graph.add_dependency_edges();
1276        assert!(result.is_err(), "Missing dependency should be an error");
1277
1278        let err = result.unwrap_err().to_string();
1279        assert!(
1280            err.contains("nonexistent") || err.contains("not found"),
1281            "Error should mention the missing task name: {err}"
1282        );
1283    }
1284
1285    // =========================================================================
1286    // TaskResolver Tests
1287    // =========================================================================
1288
1289    #[test]
1290    fn test_parse_path_segments_simple_name() {
1291        let segments = super::parse_path_segments("build");
1292        assert_eq!(segments, vec![super::PathSegment::Name("build".into())]);
1293    }
1294
1295    #[test]
1296    fn test_parse_path_segments_dotted() {
1297        let segments = super::parse_path_segments("build.frontend");
1298        assert_eq!(
1299            segments,
1300            vec![
1301                super::PathSegment::Name("build".into()),
1302                super::PathSegment::Name("frontend".into()),
1303            ]
1304        );
1305    }
1306
1307    #[test]
1308    fn test_parse_path_segments_indexed() {
1309        let segments = super::parse_path_segments("build[0]");
1310        assert_eq!(
1311            segments,
1312            vec![
1313                super::PathSegment::Name("build".into()),
1314                super::PathSegment::Index(0),
1315            ]
1316        );
1317    }
1318
1319    #[test]
1320    fn test_parse_path_segments_nested() {
1321        let segments = super::parse_path_segments("build.frontend[0]");
1322        assert_eq!(
1323            segments,
1324            vec![
1325                super::PathSegment::Name("build".into()),
1326                super::PathSegment::Name("frontend".into()),
1327                super::PathSegment::Index(0),
1328            ]
1329        );
1330    }
1331
1332    #[test]
1333    fn test_task_resolver_single_task() {
1334        use cuenv_task_graph::TaskResolver;
1335
1336        let task = create_task("build", vec![], vec![]);
1337        let mut tasks = Tasks::new();
1338        tasks
1339            .tasks
1340            .insert("build".into(), TaskNode::Task(Box::new(task)));
1341
1342        let resolution = tasks.resolve("build");
1343        assert!(resolution.is_some());
1344        match resolution.unwrap() {
1345            TaskResolution::Single(t) => assert_eq!(t.command, "echo build"),
1346            _ => panic!("Expected Single resolution"),
1347        }
1348    }
1349
1350    #[test]
1351    fn test_task_resolver_parallel_group() {
1352        use cuenv_task_graph::TaskResolver;
1353
1354        let frontend = create_task("frontend", vec![], vec![]);
1355        let backend = create_task("backend", vec![], vec![]);
1356
1357        let mut parallel_tasks = HashMap::new();
1358        parallel_tasks.insert("frontend".into(), TaskNode::Task(Box::new(frontend)));
1359        parallel_tasks.insert("backend".into(), TaskNode::Task(Box::new(backend)));
1360
1361        let group = TaskGroup {
1362            type_: "group".to_string(),
1363            children: parallel_tasks,
1364            depends_on: vec![TaskDependency::from_name("setup")],
1365            description: None,
1366            max_concurrency: None,
1367        };
1368
1369        let mut tasks = Tasks::new();
1370        tasks.tasks.insert("build".into(), TaskNode::Group(group));
1371
1372        let resolution = tasks.resolve("build");
1373        assert!(resolution.is_some());
1374        match resolution.unwrap() {
1375            TaskResolution::Parallel {
1376                children,
1377                depends_on,
1378            } => {
1379                assert_eq!(children.len(), 2);
1380                assert!(children.contains(&"build.frontend".to_string()));
1381                assert!(children.contains(&"build.backend".to_string()));
1382                assert_eq!(depends_on, vec!["setup"]);
1383            }
1384            _ => panic!("Expected Parallel resolution"),
1385        }
1386    }
1387
1388    #[test]
1389    fn test_task_resolver_sequential_group() {
1390        use cuenv_task_graph::TaskResolver;
1391
1392        let task1 = create_task("t1", vec![], vec![]);
1393        let task2 = create_task("t2", vec![], vec![]);
1394
1395        let seq = TaskNode::Sequence(vec![
1396            TaskNode::Task(Box::new(task1)),
1397            TaskNode::Task(Box::new(task2)),
1398        ]);
1399
1400        let mut tasks = Tasks::new();
1401        tasks.tasks.insert("build".into(), seq);
1402
1403        let resolution = tasks.resolve("build");
1404        assert!(resolution.is_some());
1405        match resolution.unwrap() {
1406            TaskResolution::Sequential { children } => {
1407                assert_eq!(children, vec!["build[0]", "build[1]"]);
1408            }
1409            _ => panic!("Expected Sequential resolution"),
1410        }
1411    }
1412
1413    #[test]
1414    fn test_task_resolver_nested_path() {
1415        use cuenv_task_graph::TaskResolver;
1416
1417        let task = create_task("fe", vec![], vec![]);
1418
1419        let mut parallel_tasks = HashMap::new();
1420        parallel_tasks.insert("frontend".into(), TaskNode::Task(Box::new(task)));
1421
1422        let group = TaskGroup {
1423            type_: "group".to_string(),
1424            children: parallel_tasks,
1425            depends_on: vec![],
1426            description: None,
1427            max_concurrency: None,
1428        };
1429
1430        let mut tasks = Tasks::new();
1431        tasks.tasks.insert("build".into(), TaskNode::Group(group));
1432
1433        // Resolve nested path
1434        let resolution = tasks.resolve("build.frontend");
1435        assert!(resolution.is_some());
1436        match resolution.unwrap() {
1437            TaskResolution::Single(t) => assert_eq!(t.command, "echo fe"),
1438            _ => panic!("Expected Single resolution"),
1439        }
1440    }
1441
1442    #[test]
1443    fn test_task_resolver_nonexistent() {
1444        use cuenv_task_graph::TaskResolver;
1445
1446        let tasks = Tasks::new();
1447        assert!(tasks.resolve("nonexistent").is_none());
1448    }
1449}