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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum TaskState {
38 Pending,
40 Ready,
42 Running,
44 Completed,
46}
47
48#[derive(Debug)]
53pub struct TaskGraph {
54 tasks: HashSet<TaskId>,
56 dependencies: HashMap<TaskId, HashSet<TaskId>>,
58 dependents: HashMap<TaskId, HashSet<TaskId>>,
60 states: HashMap<TaskId, TaskState>,
62 pending_deps: HashMap<TaskId, usize>,
64}
65
66impl TaskGraph {
67 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 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 graph.resolve_transitive_deps(project_graph)?;
101 graph.check_cycles()?;
102 graph.initialize_state();
103
104 Ok(graph)
105 }
106
107 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 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 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 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 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 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 pub fn state(&self, task_id: &TaskId) -> Option<TaskState> {
301 self.states.get(task_id).copied()
302 }
303
304 pub fn tasks(&self) -> impl Iterator<Item = &TaskId> {
306 self.tasks.iter()
307 }
308
309 pub fn dependencies_of(&self, task_id: &TaskId) -> Option<&HashSet<TaskId>> {
311 self.dependencies.get(task_id)
312 }
313
314 pub fn len(&self) -> usize {
316 self.tasks.len()
317 }
318
319 pub fn is_empty(&self) -> bool {
321 self.tasks.is_empty()
322 }
323
324 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 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 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 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 assert_eq!(task_graph.state(&lib_build), Some(TaskState::Ready));
476 assert_eq!(task_graph.state(&app_build), Some(TaskState::Pending));
477
478 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 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 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 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 let ready = task_graph.ready_tasks();
589 assert_eq!(ready.len(), 1);
590 assert_eq!(ready[0].project().as_str(), "core");
591
592 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 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}