Skip to main content

cuenv_ci/
affected.rs

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    // 1. Identify directly affected tasks (file changes in this project)
20    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    // 2. Transitive dependencies
27    // We need to check dependencies recursively including cross-project ones
28    // Build task index once for resolving nested task names
29    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                    // Internal dependency
52                    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                    // External dependency (#project:task) - no longer supported
62                    // Keeping check for safety but this path shouldn't be hit
63                    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    // Return in pipeline order
79    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    // Build task index to resolve nested names like "deploy.preview"
94    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
112/// Check if a task is directly affected by file changes.
113///
114/// Uses the [`AffectedBy`] trait implementation from cuenv-core, which handles:
115/// - Single tasks: affected if any input pattern matches changed files
116/// - Task groups: affected if ANY subtask is affected
117/// - Tasks with no inputs: always considered affected (safe default)
118///
119/// This function uses `TaskIndex` to resolve nested task names like "deploy.preview"
120/// which are stored in CUE as hierarchical structures (e.g., `deploy: preview: {...}`).
121fn is_task_directly_affected(
122    task_name: &str,
123    config: &Project,
124    changed_files: &[PathBuf],
125    project_root: &Path,
126) -> bool {
127    // Build task index to resolve nested names like "deploy.preview"
128    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/// Check if an external dependency (cross-project task) is affected by file changes.
139///
140/// External dependencies are specified in the format `#project:task`. This function
141/// recursively checks if the referenced task or any of its dependencies are affected.
142///
143/// # Recursion Prevention
144///
145/// To prevent infinite loops with circular dependencies, we insert a `false` sentinel
146/// value into the cache before checking. If we encounter this dependency again during
147/// recursion, we return false (not affected). Once the check completes, the cache is
148/// updated with the actual result.
149#[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    // dep format: "#project:task"
157    if let Some(result) = cache.get(dep) {
158        return *result;
159    }
160
161    // Insert false as a sentinel to prevent infinite recursion on circular dependencies.
162    // This will be updated with the actual result once the check completes.
163    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    // Check if directly affected
177    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    // Check transitive dependencies of the external task
183    // Use TaskIndex to resolve nested task names
184    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                // External ref - no longer supported but keeping check for safety
194                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                // Internal ref within that project
200                // We need to resolve internal deps of the external project recursively.
201                // Construct implicit external ref: #project:sub_dep
202                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    /// Helper to create a minimal Project with tasks
221    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    /// Helper to create a minimal Task with inputs and depends_on
232    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    // NOTE: Pattern matching tests are now in cuenv-core/src/affected.rs
248    // Tests below focus on CI-specific logic.
249
250    // ==========================================================================
251    // matched_inputs_for_task tests
252    // ==========================================================================
253
254    #[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    // ==========================================================================
304    // compute_affected_tasks tests
305    // ==========================================================================
306
307    #[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        // test depends on build, build is affected -> test should also be affected
350        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        // Pipeline order: build, test, deploy
383        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        // Should be in pipeline order
399        assert_eq!(affected, vec!["build", "test", "deploy"]);
400    }
401
402    #[test]
403    fn test_compute_affected_tasks_only_affected_in_pipeline() {
404        // If a task is not in pipeline_tasks, it shouldn't be in the result
405        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        // Only build in the pipeline, not test
411        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        // Only build should be returned (test not in pipeline)
423        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        // External dependency to non-existent project should be skipped
449        // Task has inputs so it won't be auto-affected
450        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")]; // Doesn't match deploy/**
453        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        // deploy shouldn't be affected because:
466        // 1. Its inputs don't match the changed files
467        // 2. Its external dep project doesn't exist
468        assert!(affected.is_empty());
469    }
470
471    #[test]
472    fn test_compute_affected_tasks_external_dep_affected() {
473        // External dependency is affected -> task should be affected
474        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        // Malformed external dependency (missing colon) should be skipped
508        // Task has inputs so it won't be auto-affected
509        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")]; // Doesn't match deploy/**
512        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        // deploy shouldn't be affected because:
525        // 1. Its inputs don't match the changed files
526        // 2. Its malformed external dep is skipped
527        assert!(affected.is_empty());
528    }
529
530    // ==========================================================================
531    // is_task_directly_affected tests
532    // ==========================================================================
533
534    #[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        // Tasks with no inputs should always be considered affected
581        // because we can't determine what affects them
582        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        // Even when no files changed, a task with no inputs should be affected
598        let task = make_task(vec![], vec![]);
599        let project = make_project(vec![("deploy", task)]);
600        let changed_files: Vec<PathBuf> = vec![]; // No changes
601        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    // ==========================================================================
621    // Task group affected detection tests
622    // ==========================================================================
623
624    #[test]
625    fn test_parallel_group_one_subtask_affected() {
626        // check group: { lint: inputs: ["src/**"], test: inputs: ["tests/**"] }
627        // Changed file: src/lib.rs -> lint is affected -> group is affected
628        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        // check group: { lint: inputs: ["src/**"], test: inputs: ["tests/**"] }
662        // Changed file: docs/readme.md -> no subtask affected -> group not affected
663        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        // Sequential group: [lint, test] where lint is affected
697        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        // Pipeline has ["check"] where check is a group containing lint (affected)
722        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        // "check" should be in affected list because its "lint" subtask is affected
756        assert_eq!(affected, vec!["check".to_string()]);
757    }
758
759    // ==========================================================================
760    // check_external_dependency tests
761    // ==========================================================================
762
763    #[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        // External project has: test depends on build, build is affected
829        // -> #external:test should be affected
830        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        // Task A depends on itself (circular) - should not infinite loop
859        // Task has inputs so it won't be auto-affected
860        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![]; // No changes matching taskA/**
870        let mut cache = HashMap::new();
871
872        // Should return false without infinite loop
873        // (inputs don't match and circular dep doesn't cause issues)
874        let result =
875            check_external_dependency("#proj:taskA", &all_projects, &changed_files, &mut cache);
876
877        assert!(!result);
878    }
879}