Skip to main content

guild_cli/graph/
task.rs

1use std::collections::{HashMap, HashSet};
2use std::fmt;
3
4use crate::config::{DependsOn, ProjectName, TargetName};
5use crate::error::TaskGraphError;
6use crate::graph::ProjectGraph;
7
8/// A unique identifier for a task: (project, target) pair.
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
10pub struct TaskId {
11    project: ProjectName,
12    target: TargetName,
13}
14
15impl TaskId {
16    pub fn new(project: ProjectName, target: TargetName) -> Self {
17        Self { project, target }
18    }
19
20    pub fn project(&self) -> &ProjectName {
21        &self.project
22    }
23
24    pub fn target(&self) -> &TargetName {
25        &self.target
26    }
27}
28
29impl fmt::Display for TaskId {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        write!(f, "{}:{}", self.project, self.target)
32    }
33}
34
35/// The state of a task in the execution graph.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum TaskState {
38    /// Task is waiting for dependencies to complete.
39    Pending,
40    /// Task is ready to execute (all deps complete).
41    Ready,
42    /// Task is currently executing.
43    Running,
44    /// Task completed successfully.
45    Completed,
46}
47
48/// A fine-grained task dependency graph built from the project graph.
49///
50/// Expands project-level dependencies into target-level task dependencies,
51/// resolving local and upstream (`^`) dependency references.
52#[derive(Debug)]
53pub struct TaskGraph {
54    /// All tasks in the graph.
55    tasks: HashSet<TaskId>,
56    /// Dependencies: task -> set of tasks it depends on.
57    dependencies: HashMap<TaskId, HashSet<TaskId>>,
58    /// Reverse dependencies: task -> set of tasks that depend on it.
59    dependents: HashMap<TaskId, HashSet<TaskId>>,
60    /// Current state of each task.
61    states: HashMap<TaskId, TaskState>,
62    /// Count of incomplete dependencies for each task.
63    pending_deps: HashMap<TaskId, usize>,
64}
65
66impl TaskGraph {
67    /// Build a task graph from a project graph for the given target.
68    ///
69    /// This expands the project-level DAG into a fine-grained task-level DAG,
70    /// resolving all `depends_on` references in each target configuration.
71    pub fn build(
72        project_graph: &ProjectGraph,
73        target: &TargetName,
74    ) -> Result<Self, TaskGraphError> {
75        let mut tasks = HashSet::new();
76        let mut dependencies: HashMap<TaskId, HashSet<TaskId>> = HashMap::new();
77        let mut dependents: HashMap<TaskId, HashSet<TaskId>> = HashMap::new();
78
79        // First pass: collect all tasks that have this target
80        for project_name in project_graph.project_names() {
81            if let Some(project) = project_graph.get(project_name)
82                && project.targets().contains_key(target)
83            {
84                let task_id = TaskId::new(project_name.clone(), target.clone());
85                tasks.insert(task_id.clone());
86                dependencies.insert(task_id.clone(), HashSet::new());
87                dependents.insert(task_id, HashSet::new());
88            }
89        }
90
91        let mut graph = Self {
92            tasks,
93            dependencies,
94            dependents,
95            states: HashMap::new(),
96            pending_deps: HashMap::new(),
97        };
98
99        // Resolve all dependencies using worklist approach
100        graph.resolve_transitive_deps(project_graph)?;
101        graph.check_cycles()?;
102        graph.initialize_state();
103
104        Ok(graph)
105    }
106
107    /// Recursively resolve dependencies for all tasks in the graph.
108    fn resolve_transitive_deps(
109        &mut self,
110        project_graph: &ProjectGraph,
111    ) -> Result<(), TaskGraphError> {
112        let mut to_process: Vec<TaskId> = self.tasks.iter().cloned().collect();
113        let mut processed: HashSet<TaskId> = HashSet::new();
114
115        while let Some(task_id) = to_process.pop() {
116            if processed.contains(&task_id) {
117                continue;
118            }
119            processed.insert(task_id.clone());
120
121            let project = match project_graph.get(task_id.project()) {
122                Some(p) => p,
123                None => continue,
124            };
125
126            let target_config = match project.targets().get(task_id.target()) {
127                Some(t) => t,
128                None => continue,
129            };
130
131            for dep in target_config.depends_on() {
132                match dep {
133                    DependsOn::Local(dep_target) => {
134                        let dep_task = TaskId::new(task_id.project().clone(), dep_target.clone());
135
136                        if !project.targets().contains_key(dep_target) {
137                            return Err(TaskGraphError::UnknownTarget {
138                                target: dep_target.to_string(),
139                                project: task_id.project().to_string(),
140                                referencing_target: task_id.target().to_string(),
141                            });
142                        }
143
144                        if !self.tasks.contains(&dep_task) {
145                            self.tasks.insert(dep_task.clone());
146                            self.dependencies.insert(dep_task.clone(), HashSet::new());
147                            self.dependents.insert(dep_task.clone(), HashSet::new());
148                            to_process.push(dep_task.clone());
149                        }
150
151                        self.dependencies
152                            .get_mut(&task_id)
153                            .unwrap()
154                            .insert(dep_task.clone());
155                        self.dependents
156                            .get_mut(&dep_task)
157                            .unwrap()
158                            .insert(task_id.clone());
159                    }
160                    DependsOn::Upstream(dep_target) => {
161                        if let Some(project_deps) = project_graph.dependencies(task_id.project()) {
162                            for dep_project in project_deps {
163                                let dep_task = TaskId::new(dep_project.clone(), dep_target.clone());
164
165                                if let Some(dep_proj_config) = project_graph.get(dep_project)
166                                    && dep_proj_config.targets().contains_key(dep_target)
167                                {
168                                    if !self.tasks.contains(&dep_task) {
169                                        self.tasks.insert(dep_task.clone());
170                                        self.dependencies.insert(dep_task.clone(), HashSet::new());
171                                        self.dependents.insert(dep_task.clone(), HashSet::new());
172                                        to_process.push(dep_task.clone());
173                                    }
174
175                                    self.dependencies
176                                        .get_mut(&task_id)
177                                        .unwrap()
178                                        .insert(dep_task.clone());
179                                    self.dependents
180                                        .get_mut(&dep_task)
181                                        .unwrap()
182                                        .insert(task_id.clone());
183                                }
184                            }
185                        }
186                    }
187                }
188            }
189        }
190
191        Ok(())
192    }
193
194    /// Check for cycles in the task graph.
195    fn check_cycles(&self) -> Result<(), TaskGraphError> {
196        let mut visited = HashSet::new();
197        let mut rec_stack = HashSet::new();
198
199        for task in &self.tasks {
200            if !visited.contains(task) {
201                self.detect_cycle_dfs(task, &mut visited, &mut rec_stack)?;
202            }
203        }
204
205        Ok(())
206    }
207
208    fn detect_cycle_dfs(
209        &self,
210        task: &TaskId,
211        visited: &mut HashSet<TaskId>,
212        rec_stack: &mut HashSet<TaskId>,
213    ) -> Result<(), TaskGraphError> {
214        visited.insert(task.clone());
215        rec_stack.insert(task.clone());
216
217        if let Some(deps) = self.dependencies.get(task) {
218            for dep in deps {
219                if !visited.contains(dep) {
220                    self.detect_cycle_dfs(dep, visited, rec_stack)?;
221                } else if rec_stack.contains(dep) {
222                    return Err(TaskGraphError::CycleDetected {
223                        project: dep.project().to_string(),
224                        target: dep.target().to_string(),
225                    });
226                }
227            }
228        }
229
230        rec_stack.remove(task);
231        Ok(())
232    }
233
234    /// Initialize task states: tasks with no dependencies are Ready, others are Pending.
235    fn initialize_state(&mut self) {
236        for task in &self.tasks {
237            let dep_count = self.dependencies.get(task).map_or(0, |d| d.len());
238            self.pending_deps.insert(task.clone(), dep_count);
239            if dep_count == 0 {
240                self.states.insert(task.clone(), TaskState::Ready);
241            } else {
242                self.states.insert(task.clone(), TaskState::Pending);
243            }
244        }
245    }
246
247    /// Get all tasks that are ready to execute.
248    pub fn ready_tasks(&self) -> Vec<&TaskId> {
249        self.states
250            .iter()
251            .filter(|(_, state)| **state == TaskState::Ready)
252            .map(|(task, _)| task)
253            .collect()
254    }
255
256    /// Mark a task as complete and return the tasks that became ready.
257    pub fn mark_complete(&mut self, task_id: &TaskId) -> Result<Vec<TaskId>, TaskGraphError> {
258        if !self.tasks.contains(task_id) {
259            return Err(TaskGraphError::TaskNotFound {
260                project: task_id.project().to_string(),
261                target: task_id.target().to_string(),
262            });
263        }
264
265        self.states.insert(task_id.clone(), TaskState::Completed);
266
267        let mut newly_ready = Vec::new();
268
269        if let Some(dependents) = self.dependents.get(task_id).cloned() {
270            for dependent in dependents {
271                if let Some(count) = self.pending_deps.get_mut(&dependent) {
272                    *count = count.saturating_sub(1);
273                    if *count == 0
274                        && let Some(state) = self.states.get(&dependent)
275                        && *state == TaskState::Pending
276                    {
277                        self.states.insert(dependent.clone(), TaskState::Ready);
278                        newly_ready.push(dependent);
279                    }
280                }
281            }
282        }
283
284        Ok(newly_ready)
285    }
286
287    /// Mark a task as running.
288    pub fn mark_running(&mut self, task_id: &TaskId) -> Result<(), TaskGraphError> {
289        if !self.tasks.contains(task_id) {
290            return Err(TaskGraphError::TaskNotFound {
291                project: task_id.project().to_string(),
292                target: task_id.target().to_string(),
293            });
294        }
295        self.states.insert(task_id.clone(), TaskState::Running);
296        Ok(())
297    }
298
299    /// Get the state of a task.
300    pub fn state(&self, task_id: &TaskId) -> Option<TaskState> {
301        self.states.get(task_id).copied()
302    }
303
304    /// Get all tasks in the graph.
305    pub fn tasks(&self) -> impl Iterator<Item = &TaskId> {
306        self.tasks.iter()
307    }
308
309    /// Get the direct dependencies of a task.
310    pub fn dependencies_of(&self, task_id: &TaskId) -> Option<&HashSet<TaskId>> {
311        self.dependencies.get(task_id)
312    }
313
314    /// Get the number of tasks in the graph.
315    pub fn len(&self) -> usize {
316        self.tasks.len()
317    }
318
319    /// Returns true if the graph has no tasks.
320    pub fn is_empty(&self) -> bool {
321        self.tasks.is_empty()
322    }
323
324    /// Check if all tasks are completed.
325    pub fn all_completed(&self) -> bool {
326        self.states.values().all(|s| *s == TaskState::Completed)
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::config::ProjectConfig;
334    use std::path::PathBuf;
335
336    fn make_project(name: &str, deps: &[&str], targets: &[(&str, &[&str])]) -> ProjectConfig {
337        let deps_str = if deps.is_empty() {
338            String::new()
339        } else {
340            let dep_list: Vec<String> = deps.iter().map(|d| format!("\"{d}\"")).collect();
341            format!("depends_on = [{}]", dep_list.join(", "))
342        };
343
344        let targets_str: String = targets
345            .iter()
346            .map(|(target_name, target_deps)| {
347                let target_deps_str = if target_deps.is_empty() {
348                    String::new()
349                } else {
350                    let dep_list: Vec<String> =
351                        target_deps.iter().map(|d| format!("\"{d}\"")).collect();
352                    format!("depends_on = [{}]", dep_list.join(", "))
353                };
354                format!(
355                    "[targets.{target_name}]\ncommand = \"echo {target_name}\"\n{target_deps_str}\n"
356                )
357            })
358            .collect();
359
360        let toml = format!("[project]\nname = \"{name}\"\n{deps_str}\n\n{targets_str}");
361        ProjectConfig::from_str(&toml, PathBuf::from(format!("/tmp/{name}"))).unwrap()
362    }
363
364    fn tname(s: &str) -> TargetName {
365        s.parse().unwrap()
366    }
367
368    fn pname(s: &str) -> ProjectName {
369        s.parse().unwrap()
370    }
371
372    #[test]
373    fn test_build_simple_task_graph() {
374        let projects = vec![
375            make_project("app", &[], &[("build", &[])]),
376            make_project("lib", &[], &[("build", &[])]),
377        ];
378        let project_graph = ProjectGraph::build(projects).unwrap();
379        let task_graph = TaskGraph::build(&project_graph, &tname("build")).unwrap();
380
381        assert_eq!(task_graph.len(), 2);
382    }
383
384    #[test]
385    fn test_local_dependency_resolution() {
386        // test depends on build locally
387        let projects = vec![make_project(
388            "app",
389            &[],
390            &[("build", &[]), ("test", &["build"])],
391        )];
392        let project_graph = ProjectGraph::build(projects).unwrap();
393        let task_graph = TaskGraph::build(&project_graph, &tname("test")).unwrap();
394
395        assert_eq!(task_graph.len(), 2);
396
397        let test_task = TaskId::new(pname("app"), tname("test"));
398        let build_task = TaskId::new(pname("app"), tname("build"));
399
400        let deps = task_graph.dependencies_of(&test_task).unwrap();
401        assert!(deps.contains(&build_task));
402    }
403
404    #[test]
405    fn test_upstream_dependency_resolution() {
406        // app depends on lib at project level
407        // app:build depends on ^build (upstream)
408        let projects = vec![
409            make_project("app", &["lib"], &[("build", &["^build"])]),
410            make_project("lib", &[], &[("build", &[])]),
411        ];
412        let project_graph = ProjectGraph::build(projects).unwrap();
413        let task_graph = TaskGraph::build(&project_graph, &tname("build")).unwrap();
414
415        assert_eq!(task_graph.len(), 2);
416
417        let app_build = TaskId::new(pname("app"), tname("build"));
418        let lib_build = TaskId::new(pname("lib"), tname("build"));
419
420        let deps = task_graph.dependencies_of(&app_build).unwrap();
421        assert!(deps.contains(&lib_build));
422    }
423
424    #[test]
425    fn test_upstream_fans_out_to_all_deps() {
426        // app depends on lib-a and lib-b
427        // app:build depends on ^build
428        let projects = vec![
429            make_project("app", &["lib-a", "lib-b"], &[("build", &["^build"])]),
430            make_project("lib-a", &[], &[("build", &[])]),
431            make_project("lib-b", &[], &[("build", &[])]),
432        ];
433        let project_graph = ProjectGraph::build(projects).unwrap();
434        let task_graph = TaskGraph::build(&project_graph, &tname("build")).unwrap();
435
436        assert_eq!(task_graph.len(), 3);
437
438        let app_build = TaskId::new(pname("app"), tname("build"));
439        let lib_a_build = TaskId::new(pname("lib-a"), tname("build"));
440        let lib_b_build = TaskId::new(pname("lib-b"), tname("build"));
441
442        let deps = task_graph.dependencies_of(&app_build).unwrap();
443        assert!(deps.contains(&lib_a_build));
444        assert!(deps.contains(&lib_b_build));
445    }
446
447    #[test]
448    fn test_ready_tasks_initial() {
449        let projects = vec![
450            make_project("app", &["lib"], &[("build", &["^build"])]),
451            make_project("lib", &[], &[("build", &[])]),
452        ];
453        let project_graph = ProjectGraph::build(projects).unwrap();
454        let task_graph = TaskGraph::build(&project_graph, &tname("build")).unwrap();
455
456        let ready = task_graph.ready_tasks();
457        assert_eq!(ready.len(), 1);
458        assert_eq!(ready[0].project().as_str(), "lib");
459        assert_eq!(ready[0].target().as_str(), "build");
460    }
461
462    #[test]
463    fn test_mark_complete_unblocks_dependents() {
464        let projects = vec![
465            make_project("app", &["lib"], &[("build", &["^build"])]),
466            make_project("lib", &[], &[("build", &[])]),
467        ];
468        let project_graph = ProjectGraph::build(projects).unwrap();
469        let mut task_graph = TaskGraph::build(&project_graph, &tname("build")).unwrap();
470
471        let lib_build = TaskId::new(pname("lib"), tname("build"));
472        let app_build = TaskId::new(pname("app"), tname("build"));
473
474        // lib:build should be ready initially
475        assert_eq!(task_graph.state(&lib_build), Some(TaskState::Ready));
476        assert_eq!(task_graph.state(&app_build), Some(TaskState::Pending));
477
478        // Mark lib:build complete
479        let newly_ready = task_graph.mark_complete(&lib_build).unwrap();
480
481        assert_eq!(newly_ready.len(), 1);
482        assert_eq!(newly_ready[0], app_build);
483        assert_eq!(task_graph.state(&app_build), Some(TaskState::Ready));
484    }
485
486    #[test]
487    fn test_cycle_detection_at_target_level() {
488        // Create a cycle at the target level: build -> test -> build
489        let projects = vec![make_project(
490            "app",
491            &[],
492            &[("build", &["test"]), ("test", &["build"])],
493        )];
494        let project_graph = ProjectGraph::build(projects).unwrap();
495        let result = TaskGraph::build(&project_graph, &tname("build"));
496
497        assert!(result.is_err());
498        match result.unwrap_err() {
499            TaskGraphError::CycleDetected { .. } => {}
500            e => panic!("Expected CycleDetected error, got {:?}", e),
501        }
502    }
503
504    #[test]
505    fn test_unknown_local_target_error() {
506        // Reference a target that doesn't exist
507        let projects = vec![make_project("app", &[], &[("build", &["nonexistent"])])];
508        let project_graph = ProjectGraph::build(projects).unwrap();
509        let result = TaskGraph::build(&project_graph, &tname("build"));
510
511        assert!(result.is_err());
512        match result.unwrap_err() {
513            TaskGraphError::UnknownTarget { target, .. } => {
514                assert_eq!(target, "nonexistent");
515            }
516            e => panic!("Expected UnknownTarget error, got {:?}", e),
517        }
518    }
519
520    #[test]
521    fn test_empty_graph_when_no_projects_have_target() {
522        let projects = vec![make_project("app", &[], &[("lint", &[])])];
523        let project_graph = ProjectGraph::build(projects).unwrap();
524        let task_graph = TaskGraph::build(&project_graph, &tname("build")).unwrap();
525
526        assert!(task_graph.is_empty());
527    }
528
529    #[test]
530    fn test_all_completed() {
531        let projects = vec![make_project("app", &[], &[("build", &[])])];
532        let project_graph = ProjectGraph::build(projects).unwrap();
533        let mut task_graph = TaskGraph::build(&project_graph, &tname("build")).unwrap();
534
535        assert!(!task_graph.all_completed());
536
537        let app_build = TaskId::new(pname("app"), tname("build"));
538        task_graph.mark_complete(&app_build).unwrap();
539
540        assert!(task_graph.all_completed());
541    }
542
543    #[test]
544    fn test_mark_running() {
545        let projects = vec![make_project("app", &[], &[("build", &[])])];
546        let project_graph = ProjectGraph::build(projects).unwrap();
547        let mut task_graph = TaskGraph::build(&project_graph, &tname("build")).unwrap();
548
549        let app_build = TaskId::new(pname("app"), tname("build"));
550        task_graph.mark_running(&app_build).unwrap();
551
552        assert_eq!(task_graph.state(&app_build), Some(TaskState::Running));
553    }
554
555    #[test]
556    fn test_task_not_found_error() {
557        let projects = vec![make_project("app", &[], &[("build", &[])])];
558        let project_graph = ProjectGraph::build(projects).unwrap();
559        let mut task_graph = TaskGraph::build(&project_graph, &tname("build")).unwrap();
560
561        let nonexistent = TaskId::new(pname("app"), tname("nonexistent"));
562        let result = task_graph.mark_complete(&nonexistent);
563
564        assert!(result.is_err());
565        match result.unwrap_err() {
566            TaskGraphError::TaskNotFound { .. } => {}
567            e => panic!("Expected TaskNotFound error, got {:?}", e),
568        }
569    }
570
571    #[test]
572    fn test_diamond_dependency() {
573        // app -> lib-a -> core
574        // app -> lib-b -> core
575        // All have build target with ^build deps
576        let projects = vec![
577            make_project("app", &["lib-a", "lib-b"], &[("build", &["^build"])]),
578            make_project("lib-a", &["core"], &[("build", &["^build"])]),
579            make_project("lib-b", &["core"], &[("build", &["^build"])]),
580            make_project("core", &[], &[("build", &[])]),
581        ];
582        let project_graph = ProjectGraph::build(projects).unwrap();
583        let mut task_graph = TaskGraph::build(&project_graph, &tname("build")).unwrap();
584
585        assert_eq!(task_graph.len(), 4);
586
587        // Only core:build should be ready initially
588        let ready = task_graph.ready_tasks();
589        assert_eq!(ready.len(), 1);
590        assert_eq!(ready[0].project().as_str(), "core");
591
592        // Mark core complete, lib-a and lib-b should become ready
593        let core_build = TaskId::new(pname("core"), tname("build"));
594        let newly_ready = task_graph.mark_complete(&core_build).unwrap();
595        assert_eq!(newly_ready.len(), 2);
596
597        // Mark lib-a and lib-b complete, app should become ready
598        let lib_a_build = TaskId::new(pname("lib-a"), tname("build"));
599        let lib_b_build = TaskId::new(pname("lib-b"), tname("build"));
600        task_graph.mark_complete(&lib_a_build).unwrap();
601        let newly_ready = task_graph.mark_complete(&lib_b_build).unwrap();
602
603        assert_eq!(newly_ready.len(), 1);
604        assert_eq!(newly_ready[0].project().as_str(), "app");
605    }
606
607    #[test]
608    fn test_task_id_display() {
609        let task = TaskId::new(pname("my-app"), tname("build"));
610        assert_eq!(task.to_string(), "my-app:build");
611    }
612}