1use cuenv_core::manifest::Project;
2use cuenv_core::tasks::TaskIndex;
3use cuenv_core::{AffectedBy, matches_pattern};
4use std::collections::{HashMap, HashSet};
5use std::path::{Path, PathBuf};
6
7#[must_use]
8#[allow(clippy::implicit_hasher)]
9pub fn compute_affected_tasks(
10 changed_files: &[PathBuf],
11 pipeline_tasks: &[String],
12 project_root: &Path,
13 config: &Project,
14 all_projects: &HashMap<String, (PathBuf, Project)>,
15) -> Vec<String> {
16 let mut affected = HashSet::new();
17 let mut visited_external_cache: HashMap<String, bool> = HashMap::new();
18
19 for task_name in pipeline_tasks {
21 if is_task_directly_affected(task_name, config, changed_files, project_root) {
22 affected.insert(task_name.clone());
23 }
24 }
25
26 let Ok(index) = TaskIndex::build(&config.tasks) else {
30 return pipeline_tasks
31 .iter()
32 .filter(|t| affected.contains(*t))
33 .cloned()
34 .collect();
35 };
36
37 let mut changed = true;
38 while changed {
39 changed = false;
40 for task_name in pipeline_tasks {
41 if affected.contains(task_name) {
42 continue;
43 }
44
45 if let Ok(entry) = index.resolve(task_name)
46 && let Some(task) = entry.node.as_task()
47 && !task.depends_on.is_empty()
48 {
49 for dep in &task.depends_on {
50 let dep_name = dep.task_name();
51 if !dep_name.starts_with('#') {
53 if affected.contains(dep_name) {
54 affected.insert(task_name.clone());
55 changed = true;
56 break;
57 }
58 continue;
59 }
60
61 if check_external_dependency(
64 dep_name,
65 all_projects,
66 changed_files,
67 &mut visited_external_cache,
68 ) {
69 affected.insert(task_name.clone());
70 changed = true;
71 break;
72 }
73 }
74 }
75 }
76 }
77
78 pipeline_tasks
80 .iter()
81 .filter(|t| affected.contains(*t))
82 .cloned()
83 .collect()
84}
85
86#[must_use]
87pub fn matched_inputs_for_task(
88 task_name: &str,
89 config: &Project,
90 changed_files: &[PathBuf],
91 project_root: &Path,
92) -> Vec<String> {
93 let Ok(index) = TaskIndex::build(&config.tasks) else {
95 return Vec::new();
96 };
97
98 let Ok(entry) = index.resolve(task_name) else {
99 return Vec::new();
100 };
101
102 let Some(task) = entry.node.as_task() else {
103 return Vec::new();
104 };
105
106 task.iter_path_inputs()
107 .filter(|input_glob| matches_pattern(changed_files, project_root, input_glob))
108 .cloned()
109 .collect()
110}
111
112fn is_task_directly_affected(
122 task_name: &str,
123 config: &Project,
124 changed_files: &[PathBuf],
125 project_root: &Path,
126) -> bool {
127 let Ok(index) = TaskIndex::build(&config.tasks) else {
129 return false;
130 };
131
132 index
133 .resolve(task_name)
134 .ok()
135 .is_some_and(|entry| entry.node.is_affected_by(changed_files, project_root))
136}
137
138#[allow(clippy::implicit_hasher)]
150fn check_external_dependency(
151 dep: &str,
152 all_projects: &HashMap<String, (PathBuf, Project)>,
153 changed_files: &[PathBuf],
154 cache: &mut HashMap<String, bool>,
155) -> bool {
156 if let Some(result) = cache.get(dep) {
158 return *result;
159 }
160
161 cache.insert(dep.to_string(), false);
164
165 let parts: Vec<&str> = dep[1..].split(':').collect();
166 if parts.len() < 2 {
167 return false;
168 }
169 let project_name = parts[0];
170 let task_name = parts[1];
171
172 let Some((project_path, project_config)) = all_projects.get(project_name) else {
173 return false;
174 };
175
176 if is_task_directly_affected(task_name, project_config, changed_files, project_path) {
178 cache.insert(dep.to_string(), true);
179 return true;
180 }
181
182 let Ok(index) = TaskIndex::build(&project_config.tasks) else {
185 return false;
186 };
187 if let Ok(entry) = index.resolve(task_name)
188 && let Some(task) = entry.node.as_task()
189 {
190 for sub_dep in &task.depends_on {
191 let sub_dep_name = sub_dep.task_name();
192 if sub_dep_name.starts_with('#') {
193 if check_external_dependency(sub_dep_name, all_projects, changed_files, cache) {
195 cache.insert(dep.to_string(), true);
196 return true;
197 }
198 } else {
199 let implicit_ref = format!("#{project_name}:{sub_dep_name}");
203 if check_external_dependency(&implicit_ref, all_projects, changed_files, cache) {
204 cache.insert(dep.to_string(), true);
205 return true;
206 }
207 }
208 }
209 }
210
211 false
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use cuenv_core::manifest::Project;
218 use cuenv_core::tasks::{Input, Task, TaskDependency, TaskGroup, TaskNode};
219
220 fn make_project(tasks: Vec<(&str, Task)>) -> Project {
222 let mut project = Project::default();
223 for (name, task) in tasks {
224 project
225 .tasks
226 .insert(name.to_string(), TaskNode::Task(Box::new(task)));
227 }
228 project
229 }
230
231 fn make_task(inputs: Vec<&str>, depends_on: Vec<&str>) -> Task {
233 Task {
234 inputs: inputs
235 .into_iter()
236 .map(|s| Input::Path(s.to_string()))
237 .collect(),
238 depends_on: depends_on
239 .into_iter()
240 .map(TaskDependency::from_name)
241 .collect(),
242 command: "echo test".to_string(),
243 ..Default::default()
244 }
245 }
246
247 #[test]
255 fn test_matched_inputs_for_task_returns_matching_patterns() {
256 let task = make_task(vec!["src/**", "Cargo.toml"], vec![]);
257 let project = make_project(vec![("build", task)]);
258 let changed_files = vec![PathBuf::from("src/lib.rs")];
259 let root = Path::new(".");
260
261 let matched = matched_inputs_for_task("build", &project, &changed_files, root);
262
263 assert_eq!(matched, vec!["src/**".to_string()]);
264 }
265
266 #[test]
267 fn test_matched_inputs_for_task_no_match() {
268 let task = make_task(vec!["src/**"], vec![]);
269 let project = make_project(vec![("build", task)]);
270 let changed_files = vec![PathBuf::from("tests/test.rs")];
271 let root = Path::new(".");
272
273 let matched = matched_inputs_for_task("build", &project, &changed_files, root);
274
275 assert!(matched.is_empty());
276 }
277
278 #[test]
279 fn test_matched_inputs_for_task_nonexistent_task() {
280 let project = Project::default();
281 let changed_files = vec![PathBuf::from("src/lib.rs")];
282 let root = Path::new(".");
283
284 let matched = matched_inputs_for_task("nonexistent", &project, &changed_files, root);
285
286 assert!(matched.is_empty());
287 }
288
289 #[test]
290 fn test_matched_inputs_for_task_multiple_matches() {
291 let task = make_task(vec!["src/**", "lib/**", "Cargo.toml"], vec![]);
292 let project = make_project(vec![("build", task)]);
293 let changed_files = vec![PathBuf::from("src/lib.rs"), PathBuf::from("lib/util.rs")];
294 let root = Path::new(".");
295
296 let matched = matched_inputs_for_task("build", &project, &changed_files, root);
297
298 assert!(matched.contains(&"src/**".to_string()));
299 assert!(matched.contains(&"lib/**".to_string()));
300 assert!(!matched.contains(&"Cargo.toml".to_string()));
301 }
302
303 #[test]
308 fn test_compute_affected_tasks_direct_match() {
309 let task = make_task(vec!["src/**"], vec![]);
310 let project = make_project(vec![("build", task)]);
311 let changed_files = vec![PathBuf::from("src/lib.rs")];
312 let root = Path::new(".");
313 let pipeline_tasks = vec!["build".to_string()];
314 let all_projects: HashMap<String, (PathBuf, Project)> = HashMap::new();
315
316 let affected = compute_affected_tasks(
317 &changed_files,
318 &pipeline_tasks,
319 root,
320 &project,
321 &all_projects,
322 );
323
324 assert_eq!(affected, vec!["build".to_string()]);
325 }
326
327 #[test]
328 fn test_compute_affected_tasks_no_match() {
329 let task = make_task(vec!["src/**"], vec![]);
330 let project = make_project(vec![("build", task)]);
331 let changed_files = vec![PathBuf::from("docs/readme.md")];
332 let root = Path::new(".");
333 let pipeline_tasks = vec!["build".to_string()];
334 let all_projects: HashMap<String, (PathBuf, Project)> = HashMap::new();
335
336 let affected = compute_affected_tasks(
337 &changed_files,
338 &pipeline_tasks,
339 root,
340 &project,
341 &all_projects,
342 );
343
344 assert!(affected.is_empty());
345 }
346
347 #[test]
348 fn test_compute_affected_tasks_transitive_internal_deps() {
349 let build_task = make_task(vec!["src/**"], vec![]);
351 let test_task = make_task(vec![], vec!["build"]);
352 let project = make_project(vec![("build", build_task), ("test", test_task)]);
353 let changed_files = vec![PathBuf::from("src/lib.rs")];
354 let root = Path::new(".");
355 let pipeline_tasks = vec!["build".to_string(), "test".to_string()];
356 let all_projects: HashMap<String, (PathBuf, Project)> = HashMap::new();
357
358 let affected = compute_affected_tasks(
359 &changed_files,
360 &pipeline_tasks,
361 root,
362 &project,
363 &all_projects,
364 );
365
366 assert!(affected.contains(&"build".to_string()));
367 assert!(affected.contains(&"test".to_string()));
368 }
369
370 #[test]
371 fn test_compute_affected_tasks_preserves_pipeline_order() {
372 let build_task = make_task(vec!["src/**"], vec![]);
373 let test_task = make_task(vec![], vec!["build"]);
374 let deploy_task = make_task(vec![], vec!["test"]);
375 let project = make_project(vec![
376 ("build", build_task),
377 ("test", test_task),
378 ("deploy", deploy_task),
379 ]);
380 let changed_files = vec![PathBuf::from("src/lib.rs")];
381 let root = Path::new(".");
382 let pipeline_tasks = vec![
384 "build".to_string(),
385 "test".to_string(),
386 "deploy".to_string(),
387 ];
388 let all_projects: HashMap<String, (PathBuf, Project)> = HashMap::new();
389
390 let affected = compute_affected_tasks(
391 &changed_files,
392 &pipeline_tasks,
393 root,
394 &project,
395 &all_projects,
396 );
397
398 assert_eq!(affected, vec!["build", "test", "deploy"]);
400 }
401
402 #[test]
403 fn test_compute_affected_tasks_only_affected_in_pipeline() {
404 let build_task = make_task(vec!["src/**"], vec![]);
406 let test_task = make_task(vec![], vec!["build"]);
407 let project = make_project(vec![("build", build_task), ("test", test_task)]);
408 let changed_files = vec![PathBuf::from("src/lib.rs")];
409 let root = Path::new(".");
410 let pipeline_tasks = vec!["build".to_string()];
412 let all_projects: HashMap<String, (PathBuf, Project)> = HashMap::new();
413
414 let affected = compute_affected_tasks(
415 &changed_files,
416 &pipeline_tasks,
417 root,
418 &project,
419 &all_projects,
420 );
421
422 assert_eq!(affected, vec!["build"]);
424 }
425
426 #[test]
427 fn test_compute_affected_tasks_empty_pipeline() {
428 let task = make_task(vec!["src/**"], vec![]);
429 let project = make_project(vec![("build", task)]);
430 let changed_files = vec![PathBuf::from("src/lib.rs")];
431 let root = Path::new(".");
432 let pipeline_tasks: Vec<String> = vec![];
433 let all_projects: HashMap<String, (PathBuf, Project)> = HashMap::new();
434
435 let affected = compute_affected_tasks(
436 &changed_files,
437 &pipeline_tasks,
438 root,
439 &project,
440 &all_projects,
441 );
442
443 assert!(affected.is_empty());
444 }
445
446 #[test]
447 fn test_compute_affected_tasks_external_dep_not_found() {
448 let task = make_task(vec!["deploy/**"], vec!["#nonexistent:build"]);
451 let project = make_project(vec![("deploy", task)]);
452 let changed_files = vec![PathBuf::from("src/lib.rs")]; let root = Path::new(".");
454 let pipeline_tasks = vec!["deploy".to_string()];
455 let all_projects: HashMap<String, (PathBuf, Project)> = HashMap::new();
456
457 let affected = compute_affected_tasks(
458 &changed_files,
459 &pipeline_tasks,
460 root,
461 &project,
462 &all_projects,
463 );
464
465 assert!(affected.is_empty());
469 }
470
471 #[test]
472 fn test_compute_affected_tasks_external_dep_affected() {
473 let external_build = make_task(vec!["src/**"], vec![]);
475 let mut external_project = Project::default();
476 external_project.tasks.insert(
477 "build".to_string(),
478 TaskNode::Task(Box::new(external_build)),
479 );
480
481 let deploy_task = make_task(vec![], vec!["#external:build"]);
482 let project = make_project(vec![("deploy", deploy_task)]);
483
484 let changed_files = vec![PathBuf::from("src/lib.rs")];
485 let root = Path::new(".");
486 let pipeline_tasks = vec!["deploy".to_string()];
487
488 let mut all_projects = HashMap::new();
489 all_projects.insert(
490 "external".to_string(),
491 (PathBuf::from("/repo/external"), external_project),
492 );
493
494 let affected = compute_affected_tasks(
495 &changed_files,
496 &pipeline_tasks,
497 root,
498 &project,
499 &all_projects,
500 );
501
502 assert!(affected.contains(&"deploy".to_string()));
503 }
504
505 #[test]
506 fn test_compute_affected_tasks_malformed_external_dep() {
507 let task = make_task(vec!["deploy/**"], vec!["#badformat"]);
510 let project = make_project(vec![("deploy", task)]);
511 let changed_files = vec![PathBuf::from("src/lib.rs")]; let root = Path::new(".");
513 let pipeline_tasks = vec!["deploy".to_string()];
514 let all_projects: HashMap<String, (PathBuf, Project)> = HashMap::new();
515
516 let affected = compute_affected_tasks(
517 &changed_files,
518 &pipeline_tasks,
519 root,
520 &project,
521 &all_projects,
522 );
523
524 assert!(affected.is_empty());
528 }
529
530 #[test]
535 fn test_is_task_directly_affected_match() {
536 let task = make_task(vec!["src/**"], vec![]);
537 let project = make_project(vec![("build", task)]);
538 let changed_files = vec![PathBuf::from("src/lib.rs")];
539 let root = Path::new(".");
540
541 assert!(is_task_directly_affected(
542 "build",
543 &project,
544 &changed_files,
545 root
546 ));
547 }
548
549 #[test]
550 fn test_is_task_directly_affected_no_match() {
551 let task = make_task(vec!["src/**"], vec![]);
552 let project = make_project(vec![("build", task)]);
553 let changed_files = vec![PathBuf::from("docs/readme.md")];
554 let root = Path::new(".");
555
556 assert!(!is_task_directly_affected(
557 "build",
558 &project,
559 &changed_files,
560 root
561 ));
562 }
563
564 #[test]
565 fn test_is_task_directly_affected_nonexistent_task() {
566 let project = Project::default();
567 let changed_files = vec![PathBuf::from("src/lib.rs")];
568 let root = Path::new(".");
569
570 assert!(!is_task_directly_affected(
571 "nonexistent",
572 &project,
573 &changed_files,
574 root
575 ));
576 }
577
578 #[test]
579 fn test_is_task_directly_affected_task_no_inputs_always_affected() {
580 let task = make_task(vec![], vec![]);
583 let project = make_project(vec![("build", task)]);
584 let changed_files = vec![PathBuf::from("src/lib.rs")];
585 let root = Path::new(".");
586
587 assert!(is_task_directly_affected(
588 "build",
589 &project,
590 &changed_files,
591 root
592 ));
593 }
594
595 #[test]
596 fn test_task_with_no_inputs_always_affected_even_with_no_changes() {
597 let task = make_task(vec![], vec![]);
599 let project = make_project(vec![("deploy", task)]);
600 let changed_files: Vec<PathBuf> = vec![]; let root = Path::new(".");
602 let pipeline_tasks = vec!["deploy".to_string()];
603 let all_projects: HashMap<String, (PathBuf, Project)> = HashMap::new();
604
605 let affected = compute_affected_tasks(
606 &changed_files,
607 &pipeline_tasks,
608 root,
609 &project,
610 &all_projects,
611 );
612
613 assert_eq!(
614 affected,
615 vec!["deploy"],
616 "Task with no inputs should always be affected"
617 );
618 }
619
620 #[test]
625 fn test_parallel_group_one_subtask_affected() {
626 let lint_task = make_task(vec!["src/**"], vec![]);
629 let test_task = make_task(vec!["tests/**"], vec![]);
630
631 let mut parallel_tasks = std::collections::HashMap::new();
632 parallel_tasks.insert("lint".to_string(), TaskNode::Task(Box::new(lint_task)));
633 parallel_tasks.insert("test".to_string(), TaskNode::Task(Box::new(test_task)));
634
635 let group = TaskGroup {
636 type_: "group".to_string(),
637 children: parallel_tasks,
638 depends_on: vec![],
639 description: None,
640 max_concurrency: None,
641 };
642
643 let mut project = Project::default();
644 project
645 .tasks
646 .insert("check".to_string(), TaskNode::Group(group));
647
648 let changed_files = vec![PathBuf::from("src/lib.rs")];
649 let root = Path::new(".");
650
651 assert!(is_task_directly_affected(
652 "check",
653 &project,
654 &changed_files,
655 root
656 ));
657 }
658
659 #[test]
660 fn test_parallel_group_no_subtask_affected() {
661 let lint_task = make_task(vec!["src/**"], vec![]);
664 let test_task = make_task(vec!["tests/**"], vec![]);
665
666 let mut parallel_tasks = std::collections::HashMap::new();
667 parallel_tasks.insert("lint".to_string(), TaskNode::Task(Box::new(lint_task)));
668 parallel_tasks.insert("test".to_string(), TaskNode::Task(Box::new(test_task)));
669
670 let group = TaskGroup {
671 type_: "group".to_string(),
672 children: parallel_tasks,
673 depends_on: vec![],
674 description: None,
675 max_concurrency: None,
676 };
677
678 let mut project = Project::default();
679 project
680 .tasks
681 .insert("check".to_string(), TaskNode::Group(group));
682
683 let changed_files = vec![PathBuf::from("docs/readme.md")];
684 let root = Path::new(".");
685
686 assert!(!is_task_directly_affected(
687 "check",
688 &project,
689 &changed_files,
690 root
691 ));
692 }
693
694 #[test]
695 fn test_sequential_group_affected() {
696 let lint_task = make_task(vec!["src/**"], vec![]);
698 let test_task = make_task(vec!["tests/**"], vec![]);
699
700 let seq = TaskNode::Sequence(vec![
701 TaskNode::Task(Box::new(lint_task)),
702 TaskNode::Task(Box::new(test_task)),
703 ]);
704
705 let mut project = Project::default();
706 project.tasks.insert("check".to_string(), seq);
707
708 let changed_files = vec![PathBuf::from("src/lib.rs")];
709 let root = Path::new(".");
710
711 assert!(is_task_directly_affected(
712 "check",
713 &project,
714 &changed_files,
715 root
716 ));
717 }
718
719 #[test]
720 fn test_compute_affected_tasks_with_group() {
721 let lint_task = make_task(vec!["src/**"], vec![]);
723 let test_task = make_task(vec!["tests/**"], vec![]);
724
725 let mut parallel_tasks = std::collections::HashMap::new();
726 parallel_tasks.insert("lint".to_string(), TaskNode::Task(Box::new(lint_task)));
727 parallel_tasks.insert("test".to_string(), TaskNode::Task(Box::new(test_task)));
728
729 let group = TaskGroup {
730 type_: "group".to_string(),
731 children: parallel_tasks,
732 depends_on: vec![],
733 description: None,
734 max_concurrency: None,
735 };
736
737 let mut project = Project::default();
738 project
739 .tasks
740 .insert("check".to_string(), TaskNode::Group(group));
741
742 let changed_files = vec![PathBuf::from("src/lib.rs")];
743 let root = Path::new(".");
744 let pipeline_tasks = vec!["check".to_string()];
745 let all_projects: HashMap<String, (PathBuf, Project)> = HashMap::new();
746
747 let affected = compute_affected_tasks(
748 &changed_files,
749 &pipeline_tasks,
750 root,
751 &project,
752 &all_projects,
753 );
754
755 assert_eq!(affected, vec!["check".to_string()]);
757 }
758
759 #[test]
764 fn test_check_external_dependency_cache_hit() {
765 let mut cache = HashMap::new();
766 cache.insert("#project:task".to_string(), true);
767 let all_projects: HashMap<String, (PathBuf, Project)> = HashMap::new();
768 let changed_files: Vec<PathBuf> = vec![];
769
770 let result =
771 check_external_dependency("#project:task", &all_projects, &changed_files, &mut cache);
772
773 assert!(result);
774 }
775
776 #[test]
777 fn test_check_external_dependency_cache_miss_false() {
778 let mut cache = HashMap::new();
779 cache.insert("#project:task".to_string(), false);
780 let all_projects: HashMap<String, (PathBuf, Project)> = HashMap::new();
781 let changed_files: Vec<PathBuf> = vec![];
782
783 let result =
784 check_external_dependency("#project:task", &all_projects, &changed_files, &mut cache);
785
786 assert!(!result);
787 }
788
789 #[test]
790 fn test_check_external_dependency_project_not_found() {
791 let mut cache = HashMap::new();
792 let all_projects: HashMap<String, (PathBuf, Project)> = HashMap::new();
793 let changed_files = vec![PathBuf::from("src/lib.rs")];
794
795 let result =
796 check_external_dependency("#missing:task", &all_projects, &changed_files, &mut cache);
797
798 assert!(!result);
799 }
800
801 #[test]
802 fn test_check_external_dependency_directly_affected() {
803 let external_build = make_task(vec!["src/**"], vec![]);
804 let mut external_project = Project::default();
805 external_project.tasks.insert(
806 "build".to_string(),
807 TaskNode::Task(Box::new(external_build)),
808 );
809
810 let mut all_projects = HashMap::new();
811 all_projects.insert(
812 "external".to_string(),
813 (PathBuf::from("/repo/external"), external_project),
814 );
815
816 let changed_files = vec![PathBuf::from("src/lib.rs")];
817 let mut cache = HashMap::new();
818
819 let result =
820 check_external_dependency("#external:build", &all_projects, &changed_files, &mut cache);
821
822 assert!(result);
823 assert_eq!(cache.get("#external:build"), Some(&true));
824 }
825
826 #[test]
827 fn test_check_external_dependency_transitive_internal() {
828 let external_build = make_task(vec!["src/**"], vec![]);
831 let external_test = make_task(vec![], vec!["build"]);
832 let mut external_project = Project::default();
833 external_project.tasks.insert(
834 "build".to_string(),
835 TaskNode::Task(Box::new(external_build)),
836 );
837 external_project
838 .tasks
839 .insert("test".to_string(), TaskNode::Task(Box::new(external_test)));
840
841 let mut all_projects = HashMap::new();
842 all_projects.insert(
843 "external".to_string(),
844 (PathBuf::from("/repo/external"), external_project),
845 );
846
847 let changed_files = vec![PathBuf::from("src/lib.rs")];
848 let mut cache = HashMap::new();
849
850 let result =
851 check_external_dependency("#external:test", &all_projects, &changed_files, &mut cache);
852
853 assert!(result);
854 }
855
856 #[test]
857 fn test_check_external_dependency_circular_prevention() {
858 let circular_task = make_task(vec!["taskA/**"], vec!["#proj:taskA"]);
861 let mut project = Project::default();
862 project
863 .tasks
864 .insert("taskA".to_string(), TaskNode::Task(Box::new(circular_task)));
865
866 let mut all_projects = HashMap::new();
867 all_projects.insert("proj".to_string(), (PathBuf::from("/repo/proj"), project));
868
869 let changed_files: Vec<PathBuf> = vec![]; let mut cache = HashMap::new();
871
872 let result =
875 check_external_dependency("#proj:taskA", &all_projects, &changed_files, &mut cache);
876
877 assert!(!result);
878 }
879}