Skip to main content

cuenv_task_graph/
graph.rs

1//! Task graph builder using petgraph.
2//!
3//! This module builds directed acyclic graphs (DAGs) from task definitions
4//! to handle dependencies and determine execution order.
5
6use crate::{Error, Result, TaskNodeData, TaskResolution, TaskResolver};
7use petgraph::algo::{is_cyclic_directed, toposort};
8use petgraph::graph::{DiGraph, NodeIndex};
9use petgraph::visit::IntoNodeReferences;
10use std::collections::{HashMap, HashSet};
11use tracing::debug;
12
13/// Discriminator for the kind of node in a mixed task/service graph.
14#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)]
15pub enum NodeKind {
16    /// A one-shot task that runs to completion.
17    #[default]
18    Task,
19    /// A long-running service supervised by `cuenv up`.
20    Service,
21    /// A container image build managed by `cuenv build`.
22    Image,
23}
24
25impl std::fmt::Display for NodeKind {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            Self::Task => write!(f, "task"),
29            Self::Service => write!(f, "service"),
30            Self::Image => write!(f, "image"),
31        }
32    }
33}
34
35/// A node in the task graph.
36#[derive(Debug, Clone)]
37pub struct GraphNode<T> {
38    /// Name of the task.
39    pub name: String,
40    /// The task data.
41    pub task: T,
42    /// Whether this node is a task or a service.
43    pub kind: NodeKind,
44}
45
46/// Task graph for dependency resolution and execution ordering.
47///
48/// This is a generic graph that can hold any task type implementing [`TaskNodeData`].
49/// It provides methods for building the graph, resolving dependencies, and
50/// computing execution order.
51pub struct TaskGraph<T: TaskNodeData> {
52    /// The directed graph of tasks.
53    graph: DiGraph<GraphNode<T>, ()>,
54    /// Map from task names to node indices.
55    name_to_node: HashMap<String, NodeIndex>,
56    /// Map from group prefix to child task names (for dependency expansion).
57    group_children: HashMap<String, Vec<String>>,
58}
59
60impl<T: TaskNodeData> TaskGraph<T> {
61    /// Create a new empty task graph.
62    #[must_use]
63    pub fn new() -> Self {
64        Self {
65            graph: DiGraph::new(),
66            name_to_node: HashMap::new(),
67            group_children: HashMap::new(),
68        }
69    }
70
71    /// Add a node to the graph with the given kind.
72    ///
73    /// If a node with the same name and kind already exists, returns the
74    /// existing node index. If a node with the same name but a *different*
75    /// kind exists, returns a [`DuplicateNodeName`](Error::DuplicateNodeName)
76    /// error.
77    fn add_node_with_kind(&mut self, name: &str, task: T, kind: NodeKind) -> Result<NodeIndex> {
78        if let Some(&existing) = self.name_to_node.get(name) {
79            let existing_kind = self.graph[existing].kind;
80            if existing_kind != kind {
81                return Err(Error::DuplicateNodeName {
82                    name: name.to_string(),
83                    existing_kind: existing_kind.to_string(),
84                    new_kind: kind.to_string(),
85                });
86            }
87            return Ok(existing);
88        }
89
90        let node = GraphNode {
91            name: name.to_string(),
92            task,
93            kind,
94        };
95
96        let node_index = self.graph.add_node(node);
97        self.name_to_node.insert(name.to_string(), node_index);
98        debug!("Added {kind} node '{name}'");
99
100        Ok(node_index)
101    }
102
103    /// Add a single task to the graph.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if a node with the same name but different kind exists.
108    pub fn add_task(&mut self, name: &str, task: T) -> Result<NodeIndex> {
109        self.add_node_with_kind(name, task, NodeKind::Task)
110    }
111
112    /// Add a single service node to the graph.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if a node with the same name but different kind exists.
117    pub fn add_service(&mut self, name: &str, task: T) -> Result<NodeIndex> {
118        self.add_node_with_kind(name, task, NodeKind::Service)
119    }
120
121    /// Add a single image node to the graph.
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if a node with the same name but different kind exists.
126    pub fn add_image(&mut self, name: &str, task: T) -> Result<NodeIndex> {
127        self.add_node_with_kind(name, task, NodeKind::Image)
128    }
129
130    /// Get a mutable reference to a task node by index.
131    pub fn get_node_mut(&mut self, index: NodeIndex) -> Option<&mut GraphNode<T>> {
132        self.graph.node_weight_mut(index)
133    }
134
135    /// Get a reference to a task node by name.
136    #[must_use]
137    pub fn get_node_by_name(&self, name: &str) -> Option<&GraphNode<T>> {
138        self.name_to_node
139            .get(name)
140            .and_then(|&idx| self.graph.node_weight(idx))
141    }
142
143    /// Register a group of child task names under a group prefix.
144    ///
145    /// This enables group-level dependency expansion where depending on
146    /// a group name will expand to depend on all child tasks.
147    pub fn register_group(&mut self, prefix: &str, children: Vec<String>) {
148        if !children.is_empty() {
149            self.group_children.insert(prefix.to_string(), children);
150        }
151    }
152
153    /// Expand a dependency name to leaf task names.
154    ///
155    /// If the dependency is a direct task, returns it as-is.
156    /// If it's a group name, recursively expands to all leaf tasks in that group.
157    fn expand_dep_to_leaf_tasks(&self, dep_name: &str) -> Vec<String> {
158        if self.name_to_node.contains_key(dep_name) {
159            // It's a leaf task (exists directly in the graph)
160            vec![dep_name.to_string()]
161        } else if let Some(children) = self.group_children.get(dep_name) {
162            // It's a group - recursively expand children
163            children
164                .iter()
165                .flat_map(|child| self.expand_dep_to_leaf_tasks(child))
166                .collect()
167        } else {
168            // Not found - will be caught as missing dependency later
169            vec![dep_name.to_string()]
170        }
171    }
172
173    /// Add dependency edges after all tasks have been added.
174    ///
175    /// This ensures proper cycle detection and missing dependency validation.
176    ///
177    /// # Errors
178    ///
179    /// Returns an error if any task depends on a non-existent task.
180    pub fn add_dependency_edges(&mut self) -> Result<()> {
181        let mut missing_deps = Vec::new();
182        let mut edges_to_add = Vec::new();
183
184        // Collect all dependency relationships
185        for (node_index, node) in self.graph.node_references() {
186            for dep_name in node.task.dependency_names() {
187                // Expand group references to leaf tasks
188                let expanded_deps = self.expand_dep_to_leaf_tasks(dep_name);
189
190                for expanded_dep in expanded_deps {
191                    if let Some(&dep_node_index) = self.name_to_node.get(&expanded_dep) {
192                        // Record edge to add later
193                        edges_to_add.push((dep_node_index, node_index));
194                    } else {
195                        missing_deps.push((node.name.clone(), expanded_dep));
196                    }
197                }
198            }
199        }
200
201        // Report missing dependencies
202        if !missing_deps.is_empty() {
203            return Err(Error::MissingDependencies {
204                missing: missing_deps,
205            });
206        }
207
208        // Add all edges
209        for (from, to) in edges_to_add {
210            self.graph.add_edge(from, to, ());
211        }
212
213        Ok(())
214    }
215
216    /// Add a direct edge between two tasks.
217    ///
218    /// This is a low-level method for adding edges directly, typically used
219    /// for sequential group ordering.
220    pub fn add_edge(&mut self, from: NodeIndex, to: NodeIndex) {
221        self.graph.add_edge(from, to, ());
222    }
223
224    /// Check if the graph has cycles.
225    #[must_use]
226    pub fn has_cycles(&self) -> bool {
227        is_cyclic_directed(&self.graph)
228    }
229
230    /// Get topologically sorted list of tasks.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if the graph contains cycles.
235    pub fn topological_sort(&self) -> Result<Vec<GraphNode<T>>> {
236        if self.has_cycles() {
237            return Err(Error::CycleDetected {
238                message: "Task dependency graph contains cycles".to_string(),
239            });
240        }
241
242        match toposort(&self.graph, None) {
243            Ok(sorted_indices) => Ok(sorted_indices
244                .into_iter()
245                .map(|idx| self.graph[idx].clone())
246                .collect()),
247            Err(_) => Err(Error::TopologicalSortFailed {
248                reason: "petgraph toposort failed".to_string(),
249            }),
250        }
251    }
252
253    /// Get all tasks that can run in parallel (no dependencies between them).
254    ///
255    /// Returns a vector of parallel groups, where each group contains tasks
256    /// that can execute concurrently. Groups are ordered by dependency level.
257    ///
258    /// # Errors
259    ///
260    /// Returns an error if the graph contains cycles.
261    pub fn get_parallel_groups(&self) -> Result<Vec<Vec<GraphNode<T>>>> {
262        let sorted = self.topological_sort()?;
263
264        if sorted.is_empty() {
265            return Ok(vec![]);
266        }
267
268        // Group tasks by their dependency level
269        let mut groups: Vec<Vec<GraphNode<T>>> = vec![];
270        let mut processed: HashMap<String, usize> = HashMap::new();
271
272        for task in sorted {
273            // Find the maximum level of all dependencies
274            let mut level = 0;
275            for dep in task.task.dependency_names() {
276                if let Some(&dep_level) = processed.get(dep) {
277                    level = level.max(dep_level + 1);
278                }
279            }
280
281            // Add to appropriate group
282            if level >= groups.len() {
283                groups.resize(level + 1, vec![]);
284            }
285            groups[level].push(task.clone());
286            processed.insert(task.name.clone(), level);
287        }
288
289        Ok(groups)
290    }
291
292    /// Get the number of tasks in the graph.
293    #[must_use]
294    pub fn task_count(&self) -> usize {
295        self.graph.node_count()
296    }
297
298    /// Check if a task exists in the graph.
299    #[must_use]
300    pub fn contains_task(&self, name: &str) -> bool {
301        self.name_to_node.contains_key(name)
302    }
303
304    /// Get the node index for a task by name.
305    #[must_use]
306    pub fn get_node_index(&self, name: &str) -> Option<NodeIndex> {
307        self.name_to_node.get(name).copied()
308    }
309
310    /// Get a mutable reference to a task's data by name.
311    pub fn get_task_mut(&mut self, name: &str) -> Option<&mut T> {
312        let idx = self.name_to_node.get(name).copied()?;
313        self.graph.node_weight_mut(idx).map(|node| &mut node.task)
314    }
315
316    /// Iterate over all nodes in the graph.
317    pub fn iter_nodes(&self) -> impl Iterator<Item = (NodeIndex, &GraphNode<T>)> {
318        self.graph.node_references()
319    }
320
321    /// Build graph for a specific task and all its transitive dependencies.
322    ///
323    /// This method takes an iterator of all available tasks and builds
324    /// only the subgraph needed for the requested task.
325    ///
326    /// # Arguments
327    ///
328    /// * `task_name` - The name of the task to build the graph for
329    /// * `get_task` - Function that returns the task data for a given name
330    ///
331    /// # Errors
332    ///
333    /// Returns an error if dependencies cannot be resolved.
334    pub fn build_for_task<F>(&mut self, task_name: &str, mut get_task: F) -> Result<()>
335    where
336        F: FnMut(&str) -> Option<T>,
337    {
338        let mut to_process = vec![task_name.to_string()];
339        let mut processed = HashSet::new();
340
341        debug!("Building graph for '{}'", task_name);
342
343        // First pass: Collect all tasks that need to be included
344        while let Some(current_name) = to_process.pop() {
345            if processed.contains(&current_name) {
346                continue;
347            }
348            processed.insert(current_name.clone());
349
350            if let Some(task) = get_task(&current_name) {
351                // Collect dependencies before adding the task
352                let deps: Vec<String> = task.dependency_names().map(String::from).collect();
353
354                self.add_task(&current_name, task)?;
355
356                // Add dependencies to processing queue
357                for dep in deps {
358                    if !processed.contains(&dep) {
359                        to_process.push(dep);
360                    }
361                }
362            } else {
363                debug!("Task '{}' not found while building graph", current_name);
364            }
365        }
366
367        // Second pass: Add dependency edges
368        self.add_dependency_edges()?;
369
370        Ok(())
371    }
372
373    /// Build graph for a specific task using a resolver that handles group expansion.
374    ///
375    /// This method uses the [`TaskResolver`] trait to resolve task names, which enables
376    /// unified handling of single tasks and groups (sequential/parallel).
377    ///
378    /// # Arguments
379    ///
380    /// * `task_name` - The name of the task to build the graph for
381    /// * `resolver` - Implementation of [`TaskResolver`] that provides task lookup and group expansion
382    ///
383    /// # Errors
384    ///
385    /// Returns an error if dependencies cannot be resolved.
386    pub fn build_for_task_with_resolver<R>(&mut self, task_name: &str, resolver: &R) -> Result<()>
387    where
388        R: TaskResolver<T>,
389    {
390        let mut to_process = vec![task_name.to_string()];
391        let mut processed = HashSet::new();
392        // Track sequential orderings for second pass
393        let mut sequential_orderings: Vec<Vec<String>> = Vec::new();
394        // Track parallel group depends_on to apply to leaf tasks
395        let mut pending_group_deps: HashMap<String, Vec<String>> = HashMap::new();
396
397        debug!("Building graph with resolver for '{}'", task_name);
398
399        // First pass: Collect all tasks and track sequential groups
400        while let Some(current_name) = to_process.pop() {
401            if processed.contains(&current_name) {
402                continue;
403            }
404            processed.insert(current_name.clone());
405
406            match resolver.resolve(&current_name) {
407                Some(TaskResolution::Single(mut task)) => {
408                    // Apply any pending group-level dependencies
409                    // Walk up the path to find parent groups
410                    let path_parts: Vec<&str> = current_name.split('.').collect();
411                    for i in 1..path_parts.len() {
412                        let parent_path = path_parts[..i].join(".");
413                        if let Some(deps) = pending_group_deps.get(&parent_path) {
414                            for dep in deps {
415                                task.add_dependency(dep.clone());
416                            }
417                        }
418                    }
419                    // Also check for bracket notation parents (e.g., "build[0]" -> "build")
420                    if let Some(bracket_idx) = current_name.find('[') {
421                        let parent_path = &current_name[..bracket_idx];
422                        if let Some(deps) = pending_group_deps.get(parent_path) {
423                            for dep in deps {
424                                task.add_dependency(dep.clone());
425                            }
426                        }
427                    }
428
429                    self.add_task(&current_name, task.clone())?;
430
431                    // Add dependencies to processing queue
432                    for dep in task.dependency_names() {
433                        if !processed.contains(dep) {
434                            to_process.push(dep.to_string());
435                        }
436                    }
437                }
438                Some(TaskResolution::Sequential { children }) => {
439                    self.register_group(&current_name, children.clone());
440                    // Track ordering for second pass
441                    sequential_orderings.push(children.clone());
442                    for child in children {
443                        if !processed.contains(&child) {
444                            to_process.push(child);
445                        }
446                    }
447                }
448                Some(TaskResolution::Parallel {
449                    children,
450                    depends_on,
451                }) => {
452                    self.register_group(&current_name, children.clone());
453                    // Store group-level deps to apply to leaf tasks
454                    if !depends_on.is_empty() {
455                        pending_group_deps.insert(current_name.clone(), depends_on.clone());
456                        // Also add the group deps to processing queue
457                        for dep in &depends_on {
458                            if !processed.contains(dep) {
459                                to_process.push(dep.clone());
460                            }
461                        }
462                    }
463                    for child in children {
464                        if !processed.contains(&child) {
465                            to_process.push(child);
466                        }
467                    }
468                }
469                None => {
470                    debug!("Task '{}' not found while building graph", current_name);
471                }
472            }
473        }
474
475        // Second pass: Add sequential ordering edges
476        for ordering in sequential_orderings {
477            for window in ordering.windows(2) {
478                if let [prev, next] = window {
479                    // Add edge from prev to next (prev must complete before next)
480                    if let (Some(prev_idx), Some(next_idx)) =
481                        (self.get_node_index(prev), self.get_node_index(next))
482                    {
483                        self.add_edge(prev_idx, next_idx);
484                    }
485                }
486            }
487        }
488
489        // Third pass: Add dependency edges from task.depends_on
490        self.add_dependency_edges()
491    }
492
493    /// Compute which tasks from a pipeline are affected, using transitive dependency propagation.
494    ///
495    /// This method determines which tasks need to run based on:
496    /// 1. Direct effect: The predicate returns true for the task
497    /// 2. Transitive effect: A task depends on an affected task
498    /// 3. External effect: An external dependency (e.g., `#project:task`) is affected
499    ///
500    /// # Arguments
501    ///
502    /// * `pipeline_tasks` - The names of tasks in the pipeline to check
503    /// * `is_directly_affected` - Predicate that returns true if a task is directly affected
504    /// * `is_external_affected` - Optional predicate for external dependencies (starting with `#`)
505    ///
506    /// # Returns
507    ///
508    /// A vector of task names that are affected, in pipeline order.
509    ///
510    /// # Example
511    ///
512    /// ```ignore
513    /// // Without external dependency checking
514    /// let affected = graph.compute_affected(
515    ///     &["build", "test", "deploy"],
516    ///     |task| task.is_affected_by(&changed_files, &project_root),
517    ///     None::<fn(&str) -> bool>,
518    /// );
519    ///
520    /// // With external dependency checking (for CI cross-project deps)
521    /// let affected = graph.compute_affected(
522    ///     &["build", "test", "deploy"],
523    ///     |task| task.is_affected_by(&changed_files, &project_root),
524    ///     Some(|dep: &str| check_external_dependency(dep, &all_projects, &changed_files)),
525    /// );
526    /// ```
527    #[allow(clippy::needless_pass_by_value)] // Option<E> is intentionally by-value for ergonomic API
528    pub fn compute_affected<F, E>(
529        &self,
530        pipeline_tasks: &[impl AsRef<str>],
531        is_directly_affected: F,
532        is_external_affected: Option<E>,
533    ) -> Vec<String>
534    where
535        F: Fn(&T) -> bool,
536        E: Fn(&str) -> bool,
537    {
538        use std::collections::HashSet;
539
540        let mut affected = HashSet::new();
541
542        // 1. Find directly affected tasks
543        for task_name in pipeline_tasks {
544            let task_name = task_name.as_ref();
545            if let Some(node) = self.get_node_by_name(task_name)
546                && is_directly_affected(&node.task)
547            {
548                affected.insert(task_name.to_string());
549            }
550        }
551
552        // 2. Propagate through dependencies (tasks that depend on affected tasks become affected)
553        let mut changed = true;
554        while changed {
555            changed = false;
556            for task_name in pipeline_tasks {
557                let task_name = task_name.as_ref();
558                if affected.contains(task_name) {
559                    continue;
560                }
561
562                if let Some(node) = self.get_node_by_name(task_name) {
563                    for dep in node.task.dependency_names() {
564                        // Check if this is an external dependency (starts with #)
565                        // Note: Cross-project refs are no longer supported, but keeping check for safety
566                        if dep.starts_with('#') {
567                            if is_external_affected
568                                .as_ref()
569                                .is_some_and(|resolver| resolver(dep))
570                            {
571                                affected.insert(task_name.to_string());
572                                changed = true;
573                                break;
574                            }
575                            continue;
576                        }
577
578                        // Expand group dependencies to their leaf tasks
579                        let leaf_deps = self.expand_dep_to_leaf_tasks(dep);
580                        for leaf_dep in leaf_deps {
581                            if affected.contains(&leaf_dep) {
582                                affected.insert(task_name.to_string());
583                                changed = true;
584                                break;
585                            }
586                        }
587                        if changed {
588                            break;
589                        }
590                    }
591                }
592            }
593        }
594
595        // Return in pipeline order
596        pipeline_tasks
597            .iter()
598            .map(|t| t.as_ref().to_string())
599            .filter(|t| affected.contains(t))
600            .collect()
601    }
602}
603
604impl<T: TaskNodeData> Default for TaskGraph<T> {
605    fn default() -> Self {
606        Self::new()
607    }
608}
609
610/// Compute the transitive closure of dependencies from an initial set.
611///
612/// Given a set of starting nodes and a function to retrieve dependencies,
613/// returns all nodes reachable by following dependency edges.
614///
615/// # Arguments
616///
617/// * `initial` - Starting set of node names
618/// * `get_deps` - Function that returns dependencies for a given node name
619///
620/// # Example
621///
622/// ```ignore
623/// use cuenv_task_graph::compute_transitive_closure;
624/// use std::collections::HashMap;
625///
626/// let deps: HashMap<&str, Vec<String>> = [
627///     ("build", vec![]),
628///     ("test", vec!["build".to_string()]),
629///     ("deploy", vec!["test".to_string()]),
630/// ].into_iter().collect();
631///
632/// let closure = compute_transitive_closure(
633///     ["deploy"],
634///     |name| deps.get(name).map(|v| v.as_slice()),
635/// );
636/// // closure contains: {"deploy", "test", "build"}
637/// ```
638#[must_use]
639pub fn compute_transitive_closure<'a>(
640    initial: impl IntoIterator<Item = &'a str>,
641    get_deps: impl Fn(&str) -> Option<&'a [String]>,
642) -> std::collections::HashSet<String> {
643    use std::collections::HashSet;
644
645    let mut all = HashSet::new();
646    let mut frontier: Vec<&str> = Vec::new();
647
648    // Initialize with starting set
649    for name in initial {
650        if all.insert(name.to_string()) {
651            frontier.push(name);
652        }
653    }
654
655    // BFS through dependencies
656    while let Some(task_id) = frontier.pop() {
657        if let Some(deps) = get_deps(task_id) {
658            for dep in deps {
659                if all.insert(dep.clone()) {
660                    // Use string slice from the set we just inserted
661                    // This is safe because we're doing BFS and won't revisit
662                    frontier.push(dep.as_str());
663                }
664            }
665        }
666    }
667
668    all
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    /// Simple test task implementation
676    #[derive(Clone, Debug, Default)]
677    struct TestTask {
678        depends_on: Vec<String>,
679    }
680
681    impl TestTask {
682        fn new(deps: &[&str]) -> Self {
683            Self {
684                depends_on: deps.iter().map(|s| (*s).to_string()).collect(),
685            }
686        }
687    }
688
689    impl TaskNodeData for TestTask {
690        fn dependency_names(&self) -> impl Iterator<Item = &str> {
691            self.depends_on.iter().map(String::as_str)
692        }
693
694        fn add_dependency(&mut self, dep: String) {
695            if !self.depends_on.contains(&dep) {
696                self.depends_on.push(dep);
697            }
698        }
699    }
700
701    #[test]
702    fn test_task_graph_new() {
703        let graph: TaskGraph<TestTask> = TaskGraph::new();
704        assert_eq!(graph.task_count(), 0);
705    }
706
707    #[test]
708    fn test_add_single_task() {
709        let mut graph = TaskGraph::new();
710        let task = TestTask::new(&[]);
711
712        let node = graph.add_task("test", task).unwrap();
713        assert!(graph.contains_task("test"));
714        assert_eq!(graph.task_count(), 1);
715
716        // Adding same task again should return same node
717        let task2 = TestTask::new(&[]);
718        let node2 = graph.add_task("test", task2).unwrap();
719        assert_eq!(node, node2);
720        assert_eq!(graph.task_count(), 1);
721    }
722
723    #[test]
724    fn test_task_dependencies() {
725        let mut graph = TaskGraph::new();
726
727        // Add tasks with dependencies
728        let task1 = TestTask::new(&[]);
729        let task2 = TestTask::new(&["task1"]);
730        let task3 = TestTask::new(&["task1", "task2"]);
731
732        graph.add_task("task1", task1).unwrap();
733        graph.add_task("task2", task2).unwrap();
734        graph.add_task("task3", task3).unwrap();
735        graph.add_dependency_edges().unwrap();
736
737        assert_eq!(graph.task_count(), 3);
738        assert!(!graph.has_cycles());
739
740        let sorted = graph.topological_sort().unwrap();
741        assert_eq!(sorted.len(), 3);
742
743        // task1 should come before task2 and task3
744        let positions: HashMap<String, usize> = sorted
745            .iter()
746            .enumerate()
747            .map(|(i, node)| (node.name.clone(), i))
748            .collect();
749
750        assert!(positions["task1"] < positions["task2"]);
751        assert!(positions["task1"] < positions["task3"]);
752        assert!(positions["task2"] < positions["task3"]);
753    }
754
755    #[test]
756    fn test_cycle_detection() {
757        let mut graph = TaskGraph::new();
758
759        // Create a cycle: task1 -> task2 -> task3 -> task1
760        let task1 = TestTask::new(&["task3"]);
761        let task2 = TestTask::new(&["task1"]);
762        let task3 = TestTask::new(&["task2"]);
763
764        graph.add_task("task1", task1).unwrap();
765        graph.add_task("task2", task2).unwrap();
766        graph.add_task("task3", task3).unwrap();
767        graph.add_dependency_edges().unwrap();
768
769        assert!(graph.has_cycles());
770        assert!(graph.topological_sort().is_err());
771    }
772
773    #[test]
774    fn test_parallel_groups() {
775        let mut graph = TaskGraph::new();
776
777        // Create tasks that can run in parallel
778        // Level 0: task1, task2 (no dependencies)
779        // Level 1: task3 (depends on task1), task4 (depends on task2)
780        // Level 2: task5 (depends on task3 and task4)
781
782        let task1 = TestTask::new(&[]);
783        let task2 = TestTask::new(&[]);
784        let task3 = TestTask::new(&["task1"]);
785        let task4 = TestTask::new(&["task2"]);
786        let task5 = TestTask::new(&["task3", "task4"]);
787
788        graph.add_task("task1", task1).unwrap();
789        graph.add_task("task2", task2).unwrap();
790        graph.add_task("task3", task3).unwrap();
791        graph.add_task("task4", task4).unwrap();
792        graph.add_task("task5", task5).unwrap();
793        graph.add_dependency_edges().unwrap();
794
795        let groups = graph.get_parallel_groups().unwrap();
796
797        // Should have 3 levels
798        assert_eq!(groups.len(), 3);
799
800        // Level 0 should have 2 tasks
801        assert_eq!(groups[0].len(), 2);
802
803        // Level 1 should have 2 tasks
804        assert_eq!(groups[1].len(), 2);
805
806        // Level 2 should have 1 task
807        assert_eq!(groups[2].len(), 1);
808        assert_eq!(groups[2][0].name, "task5");
809    }
810
811    #[test]
812    fn test_group_dependency_expansion() {
813        let mut graph = TaskGraph::new();
814
815        // Register a group "build" with two children
816        graph.register_group(
817            "build",
818            vec!["build.deps".to_string(), "build.compile".to_string()],
819        );
820
821        // Add the child tasks
822        let deps_task = TestTask::new(&[]);
823        let compile_task = TestTask::new(&[]);
824        graph.add_task("build.deps", deps_task).unwrap();
825        graph.add_task("build.compile", compile_task).unwrap();
826
827        // Add a task that depends on the group name "build"
828        let test_task = TestTask::new(&["build"]);
829        graph.add_task("test", test_task).unwrap();
830
831        // This should succeed - "build" expands to both children
832        graph.add_dependency_edges().unwrap();
833
834        assert!(!graph.has_cycles());
835        assert_eq!(graph.task_count(), 3);
836
837        // test should come after both build.deps and build.compile
838        let sorted = graph.topological_sort().unwrap();
839        let positions: HashMap<String, usize> = sorted
840            .iter()
841            .enumerate()
842            .map(|(i, node)| (node.name.clone(), i))
843            .collect();
844
845        assert!(positions["build.deps"] < positions["test"]);
846        assert!(positions["build.compile"] < positions["test"]);
847    }
848
849    #[test]
850    fn test_missing_dependency() {
851        let mut graph = TaskGraph::new();
852
853        // Create task with dependency that doesn't exist
854        let task = TestTask::new(&["missing"]);
855        graph.add_task("dependent", task).unwrap();
856
857        // Should fail to add edges due to missing dependency
858        assert!(graph.add_dependency_edges().is_err());
859    }
860
861    #[test]
862    fn test_empty_graph() {
863        let graph: TaskGraph<TestTask> = TaskGraph::new();
864
865        assert_eq!(graph.task_count(), 0);
866        assert!(!graph.has_cycles());
867
868        let groups = graph.get_parallel_groups().unwrap();
869        assert!(groups.is_empty());
870    }
871
872    #[test]
873    fn test_diamond_dependency() {
874        let mut graph = TaskGraph::new();
875
876        // Create a diamond dependency pattern:
877        //     A
878        //    / \
879        //   B   C
880        //    \ /
881        //     D
882        let task_a = TestTask::new(&[]);
883        let task_b = TestTask::new(&["a"]);
884        let task_c = TestTask::new(&["a"]);
885        let task_d = TestTask::new(&["b", "c"]);
886
887        graph.add_task("a", task_a).unwrap();
888        graph.add_task("b", task_b).unwrap();
889        graph.add_task("c", task_c).unwrap();
890        graph.add_task("d", task_d).unwrap();
891        graph.add_dependency_edges().unwrap();
892
893        assert!(!graph.has_cycles());
894        assert_eq!(graph.task_count(), 4);
895
896        let groups = graph.get_parallel_groups().unwrap();
897
898        // Should have 3 levels: [A], [B,C], [D]
899        assert_eq!(groups.len(), 3);
900        assert_eq!(groups[0].len(), 1); // A
901        assert_eq!(groups[1].len(), 2); // B and C can run in parallel
902        assert_eq!(groups[2].len(), 1); // D
903    }
904
905    #[test]
906    fn test_self_dependency_cycle() {
907        let mut graph = TaskGraph::new();
908
909        // Create self-referencing task
910        let task = TestTask::new(&["self_ref"]);
911        graph.add_task("self_ref", task).unwrap();
912        graph.add_dependency_edges().unwrap();
913
914        assert!(graph.has_cycles());
915        assert!(graph.get_parallel_groups().is_err());
916    }
917
918    /// Test that shared dependencies appear only once in the DAG.
919    ///
920    /// When task A and task B both depend on task C, task C should only
921    /// appear once in the task graph (deduplication).
922    #[test]
923    fn test_shared_dependency_deduplication() {
924        let mut graph = TaskGraph::new();
925
926        // Create pattern where both A and B depend on C:
927        //     C
928        //    / \
929        //   A   B
930        let task_c = TestTask::new(&[]);
931        let task_a = TestTask::new(&["c"]);
932        let task_b = TestTask::new(&["c"]);
933
934        graph.add_task("c", task_c).unwrap();
935        graph.add_task("a", task_a).unwrap();
936        graph.add_task("b", task_b).unwrap();
937        graph.add_dependency_edges().unwrap();
938
939        // Verify task C appears exactly once in the graph
940        assert_eq!(graph.task_count(), 3, "Should have exactly 3 tasks");
941
942        // Count occurrences of task C in the topological sort
943        let sorted = graph.topological_sort().unwrap();
944        let c_count = sorted.iter().filter(|node| node.name == "c").count();
945        assert_eq!(c_count, 1, "Task C should appear exactly once in the DAG");
946
947        // Verify execution order: C comes before both A and B
948        let positions: std::collections::HashMap<String, usize> = sorted
949            .iter()
950            .enumerate()
951            .map(|(i, node)| (node.name.clone(), i))
952            .collect();
953        assert!(positions["c"] < positions["a"], "C should execute before A");
954        assert!(positions["c"] < positions["b"], "C should execute before B");
955
956        // Verify parallel groups: C in level 0, A and B in level 1
957        let groups = graph.get_parallel_groups().unwrap();
958        assert_eq!(groups.len(), 2, "Should have 2 execution levels");
959        assert_eq!(groups[0].len(), 1, "Level 0 should have 1 task (C)");
960        assert_eq!(groups[0][0].name, "c");
961        assert_eq!(groups[1].len(), 2, "Level 1 should have 2 tasks (A and B)");
962    }
963
964    #[test]
965    fn test_build_for_task() {
966        let mut graph = TaskGraph::new();
967
968        // Create a map of available tasks
969        let mut all_tasks = HashMap::new();
970        all_tasks.insert("a".to_string(), TestTask::new(&[]));
971        all_tasks.insert("b".to_string(), TestTask::new(&["a"]));
972        all_tasks.insert("c".to_string(), TestTask::new(&["b"]));
973        all_tasks.insert("d".to_string(), TestTask::new(&[])); // Not a dependency of c
974
975        // Build graph for "c" - should include a, b, c but not d
976        graph
977            .build_for_task("c", |name| all_tasks.get(name).cloned())
978            .unwrap();
979
980        assert_eq!(graph.task_count(), 3);
981        assert!(graph.contains_task("a"));
982        assert!(graph.contains_task("b"));
983        assert!(graph.contains_task("c"));
984        assert!(!graph.contains_task("d"));
985    }
986
987    // Tests for TaskResolver functionality
988
989    use crate::{TaskResolution, TaskResolver};
990
991    /// Test resolver that supports groups
992    struct TestResolver {
993        tasks: HashMap<String, TestTask>,
994        sequential_groups: HashMap<String, Vec<String>>,
995        parallel_groups: HashMap<String, (Vec<String>, Vec<String>)>, // (children, depends_on)
996    }
997
998    impl TestResolver {
999        fn new() -> Self {
1000            Self {
1001                tasks: HashMap::new(),
1002                sequential_groups: HashMap::new(),
1003                parallel_groups: HashMap::new(),
1004            }
1005        }
1006
1007        fn add_task(&mut self, name: &str, task: TestTask) {
1008            self.tasks.insert(name.to_string(), task);
1009        }
1010
1011        fn add_sequential_group(&mut self, name: &str, children: &[&str]) {
1012            self.sequential_groups.insert(
1013                name.to_string(),
1014                children.iter().map(|s| (*s).to_string()).collect(),
1015            );
1016        }
1017
1018        fn add_parallel_group(&mut self, name: &str, children: &[&str], depends_on: &[&str]) {
1019            self.parallel_groups.insert(
1020                name.to_string(),
1021                (
1022                    children.iter().map(|s| (*s).to_string()).collect(),
1023                    depends_on.iter().map(|s| (*s).to_string()).collect(),
1024                ),
1025            );
1026        }
1027    }
1028
1029    impl TaskResolver<TestTask> for TestResolver {
1030        fn resolve(&self, name: &str) -> Option<TaskResolution<TestTask>> {
1031            // Check if it's a direct task
1032            if let Some(task) = self.tasks.get(name) {
1033                return Some(TaskResolution::Single(task.clone()));
1034            }
1035            // Check if it's a sequential group
1036            if let Some(children) = self.sequential_groups.get(name) {
1037                return Some(TaskResolution::Sequential {
1038                    children: children.clone(),
1039                });
1040            }
1041            // Check if it's a parallel group
1042            if let Some((children, depends_on)) = self.parallel_groups.get(name) {
1043                return Some(TaskResolution::Parallel {
1044                    children: children.clone(),
1045                    depends_on: depends_on.clone(),
1046                });
1047            }
1048            None
1049        }
1050    }
1051
1052    #[test]
1053    fn test_resolver_single_task() {
1054        let mut resolver = TestResolver::new();
1055        resolver.add_task("build", TestTask::new(&[]));
1056        resolver.add_task("test", TestTask::new(&["build"]));
1057
1058        let mut graph = TaskGraph::new();
1059        graph
1060            .build_for_task_with_resolver("test", &resolver)
1061            .unwrap();
1062
1063        assert_eq!(graph.task_count(), 2);
1064        assert!(graph.contains_task("build"));
1065        assert!(graph.contains_task("test"));
1066
1067        let sorted = graph.topological_sort().unwrap();
1068        let positions: HashMap<String, usize> = sorted
1069            .iter()
1070            .enumerate()
1071            .map(|(i, n)| (n.name.clone(), i))
1072            .collect();
1073
1074        assert!(positions["build"] < positions["test"]);
1075    }
1076
1077    #[test]
1078    fn test_resolver_sequential_group() {
1079        let mut resolver = TestResolver::new();
1080        // Sequential group: build[0] -> build[1] -> build[2]
1081        resolver.add_sequential_group("build", &["build[0]", "build[1]", "build[2]"]);
1082        resolver.add_task("build[0]", TestTask::new(&[]));
1083        resolver.add_task("build[1]", TestTask::new(&[]));
1084        resolver.add_task("build[2]", TestTask::new(&[]));
1085
1086        let mut graph = TaskGraph::new();
1087        graph
1088            .build_for_task_with_resolver("build", &resolver)
1089            .unwrap();
1090
1091        assert_eq!(graph.task_count(), 3);
1092
1093        let sorted = graph.topological_sort().unwrap();
1094        let positions: HashMap<String, usize> = sorted
1095            .iter()
1096            .enumerate()
1097            .map(|(i, n)| (n.name.clone(), i))
1098            .collect();
1099
1100        // Sequential ordering must be preserved
1101        assert!(positions["build[0]"] < positions["build[1]"]);
1102        assert!(positions["build[1]"] < positions["build[2]"]);
1103    }
1104
1105    #[test]
1106    fn test_resolver_parallel_group() {
1107        let mut resolver = TestResolver::new();
1108        // Parallel group with children
1109        resolver.add_parallel_group(
1110            "build",
1111            &["build.frontend", "build.backend"],
1112            &[], // no group-level deps
1113        );
1114        resolver.add_task("build.frontend", TestTask::new(&[]));
1115        resolver.add_task("build.backend", TestTask::new(&[]));
1116
1117        let mut graph = TaskGraph::new();
1118        graph
1119            .build_for_task_with_resolver("build", &resolver)
1120            .unwrap();
1121
1122        assert_eq!(graph.task_count(), 2);
1123        assert!(graph.contains_task("build.frontend"));
1124        assert!(graph.contains_task("build.backend"));
1125
1126        // Both should be at same level (can run in parallel)
1127        let groups = graph.get_parallel_groups().unwrap();
1128        assert_eq!(groups.len(), 1); // Single level
1129        assert_eq!(groups[0].len(), 2); // Both tasks
1130    }
1131
1132    #[test]
1133    fn test_resolver_parallel_group_with_depends_on() {
1134        let mut resolver = TestResolver::new();
1135        // Setup task first
1136        resolver.add_task("setup", TestTask::new(&[]));
1137        // Parallel group with group-level depends_on
1138        resolver.add_parallel_group(
1139            "build",
1140            &["build.frontend", "build.backend"],
1141            &["setup"], // group depends on setup
1142        );
1143        resolver.add_task("build.frontend", TestTask::new(&[]));
1144        resolver.add_task("build.backend", TestTask::new(&[]));
1145
1146        let mut graph = TaskGraph::new();
1147        graph
1148            .build_for_task_with_resolver("build", &resolver)
1149            .unwrap();
1150
1151        assert_eq!(graph.task_count(), 3);
1152
1153        let sorted = graph.topological_sort().unwrap();
1154        let positions: HashMap<String, usize> = sorted
1155            .iter()
1156            .enumerate()
1157            .map(|(i, n)| (n.name.clone(), i))
1158            .collect();
1159
1160        // Setup must come before both children
1161        assert!(positions["setup"] < positions["build.frontend"]);
1162        assert!(positions["setup"] < positions["build.backend"]);
1163    }
1164
1165    #[test]
1166    fn test_resolver_nested_groups() {
1167        let mut resolver = TestResolver::new();
1168        // Top level parallel group
1169        resolver.add_parallel_group("build", &["build.frontend", "build.backend"], &[]);
1170        // Nested sequential group
1171        resolver.add_sequential_group(
1172            "build.frontend",
1173            &["build.frontend[0]", "build.frontend[1]"],
1174        );
1175        resolver.add_task("build.frontend[0]", TestTask::new(&[]));
1176        resolver.add_task("build.frontend[1]", TestTask::new(&[]));
1177        resolver.add_task("build.backend", TestTask::new(&[]));
1178
1179        let mut graph = TaskGraph::new();
1180        graph
1181            .build_for_task_with_resolver("build", &resolver)
1182            .unwrap();
1183
1184        assert_eq!(graph.task_count(), 3);
1185
1186        let sorted = graph.topological_sort().unwrap();
1187        let positions: HashMap<String, usize> = sorted
1188            .iter()
1189            .enumerate()
1190            .map(|(i, n)| (n.name.clone(), i))
1191            .collect();
1192
1193        // Sequential ordering within frontend must be preserved
1194        assert!(positions["build.frontend[0]"] < positions["build.frontend[1]"]);
1195    }
1196
1197    // ==========================================================================
1198    // compute_affected tests
1199    // ==========================================================================
1200
1201    #[test]
1202    fn test_compute_affected_direct() {
1203        let mut graph = TaskGraph::new();
1204        graph.add_task("build", TestTask::new(&[])).unwrap();
1205        graph.add_task("test", TestTask::new(&["build"])).unwrap();
1206        graph.add_task("deploy", TestTask::new(&["test"])).unwrap();
1207        graph.add_dependency_edges().unwrap();
1208
1209        // Only build is directly affected
1210        let affected = graph.compute_affected(
1211            &["build", "test", "deploy"],
1212            |task| {
1213                // Simulate: build has no deps (directly affected), others don't
1214                task.depends_on.is_empty()
1215            },
1216            None::<fn(&str) -> bool>,
1217        );
1218
1219        // build is directly affected, test and deploy are transitively affected
1220        assert_eq!(affected, vec!["build", "test", "deploy"]);
1221    }
1222
1223    #[test]
1224    fn test_compute_affected_none() {
1225        let mut graph = TaskGraph::new();
1226        graph.add_task("build", TestTask::new(&[])).unwrap();
1227        graph.add_task("test", TestTask::new(&["build"])).unwrap();
1228        graph.add_dependency_edges().unwrap();
1229
1230        // Nothing is directly affected
1231        let affected =
1232            graph.compute_affected(&["build", "test"], |_task| false, None::<fn(&str) -> bool>);
1233
1234        assert!(affected.is_empty());
1235    }
1236
1237    #[test]
1238    fn test_compute_affected_preserves_pipeline_order() {
1239        let mut graph = TaskGraph::new();
1240        graph.add_task("deploy", TestTask::new(&["test"])).unwrap();
1241        graph.add_task("test", TestTask::new(&["build"])).unwrap();
1242        graph.add_task("build", TestTask::new(&[])).unwrap();
1243        graph.add_dependency_edges().unwrap();
1244
1245        // All directly affected
1246        let affected = graph.compute_affected(
1247            &["build", "test", "deploy"],
1248            |_| true,
1249            None::<fn(&str) -> bool>,
1250        );
1251
1252        // Should preserve pipeline order, not graph order
1253        assert_eq!(affected, vec!["build", "test", "deploy"]);
1254    }
1255
1256    #[test]
1257    fn test_compute_affected_transitive_only() {
1258        let mut graph = TaskGraph::new();
1259        graph.add_task("build", TestTask::new(&[])).unwrap();
1260        graph.add_task("test", TestTask::new(&["build"])).unwrap();
1261        graph.add_task("deploy", TestTask::new(&["test"])).unwrap();
1262        graph.add_dependency_edges().unwrap();
1263
1264        // Only test is directly affected, but deploy depends on it
1265        let affected = graph.compute_affected(
1266            &["build", "test", "deploy"],
1267            |task| {
1268                // Only "test" has exactly one dependency
1269                task.depends_on.len() == 1 && task.depends_on[0] == "build"
1270            },
1271            None::<fn(&str) -> bool>,
1272        );
1273
1274        // test is directly affected, deploy is transitively affected
1275        // build is not affected because nothing depends on what build does
1276        assert_eq!(affected, vec!["test", "deploy"]);
1277    }
1278
1279    #[test]
1280    fn test_compute_affected_with_external_resolver() {
1281        let mut graph = TaskGraph::new();
1282        // build depends on an external project task, test depends on build
1283        graph
1284            .add_task("build", TestTask::new(&["#external:lib"]))
1285            .unwrap();
1286        graph.add_task("test", TestTask::new(&["build"])).unwrap();
1287        // Don't call add_dependency_edges() - external deps would fail validation
1288        // We manually add the internal edge
1289        let build_idx = *graph.name_to_node.get("build").unwrap();
1290        let test_idx = *graph.name_to_node.get("test").unwrap();
1291        graph.add_edge(build_idx, test_idx);
1292
1293        // External resolver: #external:lib is affected
1294        let affected = graph.compute_affected(
1295            &["build", "test"],
1296            |_task| false, // Nothing directly affected
1297            Some(|dep: &str| dep == "#external:lib"),
1298        );
1299
1300        // build is affected via external dep, test is transitively affected
1301        assert_eq!(affected, vec!["build", "test"]);
1302    }
1303
1304    #[test]
1305    fn test_compute_affected_external_not_affected() {
1306        let mut graph = TaskGraph::new();
1307        graph
1308            .add_task("build", TestTask::new(&["#external:lib"]))
1309            .unwrap();
1310        graph.add_task("test", TestTask::new(&["build"])).unwrap();
1311        // Don't call add_dependency_edges() - external deps would fail validation
1312        let build_idx = *graph.name_to_node.get("build").unwrap();
1313        let test_idx = *graph.name_to_node.get("test").unwrap();
1314        graph.add_edge(build_idx, test_idx);
1315
1316        // External resolver: nothing is affected
1317        let affected =
1318            graph.compute_affected(&["build", "test"], |_task| false, Some(|_dep: &str| false));
1319
1320        assert!(affected.is_empty());
1321    }
1322
1323    // ==========================================================================
1324    // compute_transitive_closure tests
1325    // ==========================================================================
1326
1327    #[test]
1328    fn test_transitive_closure_empty() {
1329        let deps: std::collections::HashMap<&str, Vec<String>> = std::collections::HashMap::new();
1330        let closure = compute_transitive_closure(std::iter::empty::<&str>(), |name| {
1331            deps.get(name).map(|v| v.as_slice())
1332        });
1333        assert!(closure.is_empty());
1334    }
1335
1336    #[test]
1337    fn test_transitive_closure_single_node_no_deps() {
1338        let deps: std::collections::HashMap<&str, Vec<String>> =
1339            [("build", vec![])].into_iter().collect();
1340        let closure =
1341            compute_transitive_closure(["build"], |name| deps.get(name).map(|v| v.as_slice()));
1342        assert_eq!(closure.len(), 1);
1343        assert!(closure.contains("build"));
1344    }
1345
1346    #[test]
1347    fn test_transitive_closure_chain() {
1348        // deploy -> test -> build
1349        let deps: std::collections::HashMap<&str, Vec<String>> = [
1350            ("build", vec![]),
1351            ("test", vec!["build".to_string()]),
1352            ("deploy", vec!["test".to_string()]),
1353        ]
1354        .into_iter()
1355        .collect();
1356
1357        let closure =
1358            compute_transitive_closure(["deploy"], |name| deps.get(name).map(|v| v.as_slice()));
1359
1360        assert_eq!(closure.len(), 3);
1361        assert!(closure.contains("deploy"));
1362        assert!(closure.contains("test"));
1363        assert!(closure.contains("build"));
1364    }
1365
1366    #[test]
1367    fn test_transitive_closure_diamond() {
1368        //      A
1369        //     / \
1370        //    B   C
1371        //     \ /
1372        //      D
1373        let deps: std::collections::HashMap<&str, Vec<String>> = [
1374            ("D", vec![]),
1375            ("B", vec!["D".to_string()]),
1376            ("C", vec!["D".to_string()]),
1377            ("A", vec!["B".to_string(), "C".to_string()]),
1378        ]
1379        .into_iter()
1380        .collect();
1381
1382        let closure =
1383            compute_transitive_closure(["A"], |name| deps.get(name).map(|v| v.as_slice()));
1384
1385        assert_eq!(closure.len(), 4);
1386        assert!(closure.contains("A"));
1387        assert!(closure.contains("B"));
1388        assert!(closure.contains("C"));
1389        assert!(closure.contains("D"));
1390    }
1391
1392    #[test]
1393    fn test_transitive_closure_multiple_initial() {
1394        // Two separate chains: A -> B, C -> D
1395        let deps: std::collections::HashMap<&str, Vec<String>> = [
1396            ("B", vec![]),
1397            ("A", vec!["B".to_string()]),
1398            ("D", vec![]),
1399            ("C", vec!["D".to_string()]),
1400        ]
1401        .into_iter()
1402        .collect();
1403
1404        let closure =
1405            compute_transitive_closure(["A", "C"], |name| deps.get(name).map(|v| v.as_slice()));
1406
1407        assert_eq!(closure.len(), 4);
1408        assert!(closure.contains("A"));
1409        assert!(closure.contains("B"));
1410        assert!(closure.contains("C"));
1411        assert!(closure.contains("D"));
1412    }
1413
1414    #[test]
1415    fn test_transitive_closure_missing_dep() {
1416        // A depends on nonexistent B - should just include A
1417        let deps: std::collections::HashMap<&str, Vec<String>> =
1418            [("A", vec!["B".to_string()])].into_iter().collect();
1419
1420        let closure =
1421            compute_transitive_closure(["A"], |name| deps.get(name).map(|v| v.as_slice()));
1422
1423        // A is included, B is added to closure even though it has no entry (it's a valid node name)
1424        assert_eq!(closure.len(), 2);
1425        assert!(closure.contains("A"));
1426        assert!(closure.contains("B"));
1427    }
1428
1429    // ========================================================================
1430    // NodeKind tests
1431    // ========================================================================
1432
1433    #[test]
1434    fn test_node_kind_default() {
1435        let kind = NodeKind::default();
1436        assert_eq!(kind, NodeKind::Task);
1437    }
1438
1439    #[test]
1440    fn test_node_kind_display() {
1441        assert_eq!(NodeKind::Task.to_string(), "task");
1442        assert_eq!(NodeKind::Service.to_string(), "service");
1443    }
1444
1445    #[test]
1446    fn test_node_kind_equality() {
1447        assert_eq!(NodeKind::Task, NodeKind::Task);
1448        assert_eq!(NodeKind::Service, NodeKind::Service);
1449        assert_ne!(NodeKind::Task, NodeKind::Service);
1450    }
1451
1452    #[test]
1453    fn test_add_task_sets_kind() {
1454        let mut graph = TaskGraph::new();
1455        graph.add_task("build", TestTask::new(&[])).unwrap();
1456
1457        let node = graph.get_node_by_name("build").unwrap();
1458        assert_eq!(node.kind, NodeKind::Task);
1459    }
1460
1461    #[test]
1462    fn test_add_service_sets_kind() {
1463        let mut graph = TaskGraph::new();
1464        graph.add_service("db", TestTask::new(&[])).unwrap();
1465
1466        let node = graph.get_node_by_name("db").unwrap();
1467        assert_eq!(node.kind, NodeKind::Service);
1468    }
1469
1470    #[test]
1471    fn test_mixed_graph_tasks_and_services() {
1472        let mut graph = TaskGraph::new();
1473
1474        // Add a task and a service
1475        graph.add_task("build", TestTask::new(&[])).unwrap();
1476        graph.add_service("db", TestTask::new(&["build"])).unwrap();
1477        graph.add_dependency_edges().unwrap();
1478
1479        assert_eq!(graph.task_count(), 2);
1480        assert!(!graph.has_cycles());
1481
1482        // Verify kinds
1483        let build_node = graph.get_node_by_name("build").unwrap();
1484        assert_eq!(build_node.kind, NodeKind::Task);
1485
1486        let db_node = graph.get_node_by_name("db").unwrap();
1487        assert_eq!(db_node.kind, NodeKind::Service);
1488
1489        // Verify topological order
1490        let sorted = graph.topological_sort().unwrap();
1491        let positions: HashMap<String, usize> = sorted
1492            .iter()
1493            .enumerate()
1494            .map(|(i, node)| (node.name.clone(), i))
1495            .collect();
1496        assert!(positions["build"] < positions["db"]);
1497    }
1498
1499    #[test]
1500    fn test_add_service_deduplication() {
1501        let mut graph = TaskGraph::new();
1502        let idx1 = graph.add_service("db", TestTask::new(&[])).unwrap();
1503        let idx2 = graph.add_service("db", TestTask::new(&[])).unwrap();
1504        assert_eq!(idx1, idx2);
1505        assert_eq!(graph.task_count(), 1);
1506    }
1507
1508    #[test]
1509    fn test_duplicate_node_name_across_kinds() {
1510        let mut graph = TaskGraph::new();
1511        graph.add_task("api", TestTask::new(&[])).unwrap();
1512
1513        let err = graph
1514            .add_image("api", TestTask::new(&[]))
1515            .expect_err("should reject image with same name as task");
1516        assert!(
1517            matches!(err, Error::DuplicateNodeName { ref name, .. } if name == "api"),
1518            "expected DuplicateNodeName error, got: {err}"
1519        );
1520
1521        // Reverse direction: image first, then task
1522        let mut graph2 = TaskGraph::new();
1523        graph2.add_image("worker", TestTask::new(&[])).unwrap();
1524
1525        let err2 = graph2
1526            .add_service("worker", TestTask::new(&[]))
1527            .expect_err("should reject service with same name as image");
1528        assert!(
1529            matches!(err2, Error::DuplicateNodeName { ref name, .. } if name == "worker"),
1530            "expected DuplicateNodeName error, got: {err2}"
1531        );
1532    }
1533
1534    #[test]
1535    fn test_add_image_deduplication() {
1536        let mut graph = TaskGraph::new();
1537        let idx1 = graph.add_image("api", TestTask::new(&[])).unwrap();
1538        let idx2 = graph.add_image("api", TestTask::new(&[])).unwrap();
1539        assert_eq!(idx1, idx2);
1540        assert_eq!(graph.task_count(), 1);
1541    }
1542}