Skip to main content

haz_dag/
effective.rs

1//! Overlay merging: combine each project's own tasks with the
2//! tasks contributed by every overlay file that attaches to it,
3//! producing an *effective task set* for every project per
4//! `DAG-001`..`DAG-006` of `docs/spec/07-dag-semantics.md`.
5//!
6//! This module is the orchestrator for the overlay-merging layer:
7//!
8//! - `DAG-001` (overlay attachment) is consumed via the
9//!   pre-computed [`haz_domain::overlay::Overlay::matched_projects`]
10//!   set: discovery has already projected each overlay's
11//!   `tagsFilter` against project tags per `CFG-026`, so this layer
12//!   reads the relation directly.
13//! - `DAG-002` (definition sources): for each `(project P, task
14//!   name n)` pair, the set of definition sources is the union of
15//!   `P`'s own task body for `n` (if present) and every overlay
16//!   attaching a task named `n` to `P`.
17//! - `DAG-003` (specialisation order): the standard partial order
18//!   induced by set inclusion on matched-projects sets. A
19//!   project-level source's conceptual matched-projects set is
20//!   `{P}` and is therefore maximally specific.
21//! - `DAG-004` (single source): a singleton definition-source set
22//!   has a trivial winner.
23//! - `DAG-005` (specialisation override): when more than one
24//!   source exists, the unique source whose matched-projects set
25//!   is a strict subset of every other source's set wins. A
26//!   project-level source, when present, always wins per the spec.
27//! - `DAG-006` (specialisation collision): when no unique winner
28//!   exists, the workspace is rejected with a typed error
29//!   identifying every contributing source for that
30//!   `(project, task)` pair. Errors are accumulated across the
31//!   workspace.
32//!
33//! After [`compute_effective_tasks`] succeeds, every project has
34//! exactly one body per attached task name, and the result feeds
35//! the rest of the DAG-construction pipeline
36//! ([`crate::construction::build_task_graph`]).
37
38use std::collections::{BTreeMap, BTreeSet};
39use std::fmt;
40
41use haz_domain::name::{OverlayName, ProjectName, TaskName};
42use haz_domain::overlay::Overlay;
43use haz_domain::task::Task;
44use haz_domain::workspace::Workspace;
45use snafu::Snafu;
46
47/// Effective task set per project, after overlay merging.
48///
49/// Keyed by [`ProjectName`]. Every project in the input workspace
50/// appears as a key; the inner map is the project's effective
51/// tasks keyed by [`TaskName`].
52#[derive(Debug, Clone, PartialEq, Eq, Default)]
53pub struct EffectiveTasksByProject(BTreeMap<ProjectName, BTreeMap<TaskName, Task>>);
54
55impl EffectiveTasksByProject {
56    /// View the effective task map for one project, if known.
57    #[must_use]
58    pub fn get(&self, project: &ProjectName) -> Option<&BTreeMap<TaskName, Task>> {
59        self.0.get(project)
60    }
61
62    /// Iterate over `(project, effective tasks)` pairs in
63    /// [`ProjectName`] order.
64    pub fn iter(&self) -> impl Iterator<Item = (&ProjectName, &BTreeMap<TaskName, Task>)> {
65        self.0.iter()
66    }
67
68    /// Number of projects covered (always equals the number of
69    /// projects in the source workspace).
70    #[must_use]
71    pub fn len(&self) -> usize {
72        self.0.len()
73    }
74
75    /// `true` if no projects are covered (i.e., the source
76    /// workspace had no projects).
77    #[must_use]
78    pub fn is_empty(&self) -> bool {
79        self.0.is_empty()
80    }
81
82    /// Consume and return the underlying map.
83    #[must_use]
84    pub fn into_inner(self) -> BTreeMap<ProjectName, BTreeMap<TaskName, Task>> {
85        self.0
86    }
87}
88
89/// Identity of a single definition source for a `(project, task)`
90/// pair per `DAG-002`.
91///
92/// Variant ordering ([`ProjectLevel`] before [`Overlay`]) reflects
93/// the specialisation order: a project-level source is maximally
94/// specific by construction (`DAG-003`).
95///
96/// [`ProjectLevel`]: DefinitionSource::ProjectLevel
97/// [`Overlay`]: DefinitionSource::Overlay
98#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
99pub enum DefinitionSource {
100    /// The project's own `haz.yml`.
101    ProjectLevel {
102        /// Identity of the project whose `haz.yml` contributes.
103        project: ProjectName,
104    },
105    /// An overlay file `.haz/tasks/<name>.yml`.
106    Overlay {
107        /// Identity of the contributing overlay.
108        name: OverlayName,
109    },
110}
111
112impl fmt::Display for DefinitionSource {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        match self {
115            DefinitionSource::ProjectLevel { project } => {
116                write!(f, "project-level `{project}/haz.yml`")
117            }
118            DefinitionSource::Overlay { name } => write!(f, "overlay `{name}.yml`"),
119        }
120    }
121}
122
123/// A single error encountered during overlay merging.
124///
125/// Multiple instances are accumulated into [`OverlayMergeErrors`]:
126/// every `(project, task)` collision in the workspace surfaces in
127/// one run.
128#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
129pub enum OverlayMergeError {
130    /// More than one definition source exists for the
131    /// `(project, task)` pair AND no unique winner under the
132    /// specialisation order (`DAG-006`). The body cannot be
133    /// resolved; the workspace MUST be rejected.
134    #[snafu(display(
135        "task `{project}:{task}`: overlay-merge collision among {n} sources (DAG-006)",
136        n = sources.len()
137    ))]
138    Collision {
139        /// The project the collision applies to.
140        project: ProjectName,
141        /// The task name the collision applies to.
142        task: TaskName,
143        /// Every contributing definition source for the pair.
144        /// Per `DAG-005` a project-level source, when present,
145        /// always wins; therefore in practice this set contains
146        /// only [`DefinitionSource::Overlay`] variants.
147        sources: BTreeSet<DefinitionSource>,
148    },
149}
150
151/// All overlay-merge errors accumulated by [`compute_effective_tasks`].
152///
153/// Ordered by project then task name; the printed diagnostic is
154/// stable across runs.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct OverlayMergeErrors(Vec<OverlayMergeError>);
157
158impl OverlayMergeErrors {
159    /// View the underlying slice without consuming.
160    #[must_use]
161    pub fn as_slice(&self) -> &[OverlayMergeError] {
162        &self.0
163    }
164
165    /// Consume and return the underlying vector.
166    #[must_use]
167    pub fn into_inner(self) -> Vec<OverlayMergeError> {
168        self.0
169    }
170
171    /// Number of accumulated errors. Always `>= 1` for a value
172    /// that escapes [`compute_effective_tasks`] as `Err(_)`.
173    #[must_use]
174    pub fn len(&self) -> usize {
175        self.0.len()
176    }
177
178    /// `true` if there are no errors (defensive; the constructor
179    /// only emits a value when there is at least one).
180    #[must_use]
181    pub fn is_empty(&self) -> bool {
182        self.0.is_empty()
183    }
184}
185
186impl fmt::Display for OverlayMergeErrors {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        let n = self.0.len();
189        writeln!(f, "{n} error(s) during overlay merging:")?;
190        for (i, err) in self.0.iter().enumerate() {
191            writeln!(f, "  {}. {err}", i + 1)?;
192        }
193        Ok(())
194    }
195}
196
197impl std::error::Error for OverlayMergeErrors {}
198
199/// Compute the effective task set of every project in `workspace`
200/// per `DAG-001`..`DAG-006`.
201///
202/// The function does NOT short-circuit on the first collision:
203/// every `(project, task)` collision in the workspace surfaces in
204/// the returned [`OverlayMergeErrors`].
205///
206/// # Errors
207///
208/// Returns [`OverlayMergeErrors`] when any `(project, task)`
209/// definition-source set has more than one member AND no unique
210/// winner under the specialisation order (`DAG-006`).
211pub fn compute_effective_tasks(
212    workspace: &Workspace,
213) -> Result<EffectiveTasksByProject, OverlayMergeErrors> {
214    let mut result: BTreeMap<ProjectName, BTreeMap<TaskName, Task>> = BTreeMap::new();
215    let mut errors: Vec<OverlayMergeError> = Vec::new();
216
217    for (project_name, project) in &workspace.projects {
218        let attached_overlays: Vec<&Overlay> = workspace
219            .overlays
220            .values()
221            .filter(|o| o.matched_projects.contains(project_name))
222            .collect();
223
224        let mut task_names: BTreeSet<&TaskName> = project.tasks.keys().collect();
225        for overlay in &attached_overlays {
226            for n in overlay.tasks.keys() {
227                task_names.insert(n);
228            }
229        }
230
231        let mut effective: BTreeMap<TaskName, Task> = BTreeMap::new();
232
233        for task_name in task_names {
234            let project_level_body = project.tasks.get(task_name);
235            let overlay_sources: Vec<&Overlay> = attached_overlays
236                .iter()
237                .copied()
238                .filter(|o| o.tasks.contains_key(task_name))
239                .collect();
240
241            if let Some(body) = pick_winner(project_level_body, &overlay_sources, task_name) {
242                effective.insert(task_name.clone(), body.clone());
243            } else {
244                let mut sources: BTreeSet<DefinitionSource> = overlay_sources
245                    .iter()
246                    .map(|o| DefinitionSource::Overlay {
247                        name: o.name.clone(),
248                    })
249                    .collect();
250                if project_level_body.is_some() {
251                    sources.insert(DefinitionSource::ProjectLevel {
252                        project: project_name.clone(),
253                    });
254                }
255                errors.push(OverlayMergeError::Collision {
256                    project: project_name.clone(),
257                    task: task_name.clone(),
258                    sources,
259                });
260            }
261        }
262
263        result.insert(project_name.clone(), effective);
264    }
265
266    if errors.is_empty() {
267        Ok(EffectiveTasksByProject(result))
268    } else {
269        Err(OverlayMergeErrors(errors))
270    }
271}
272
273/// Pick the winning task body for one `(project, task)` pair, or
274/// `None` if the definition-source set is empty or no unique
275/// winner exists.
276///
277/// Per `DAG-005`: when a project-level source is present, it
278/// always wins (it is maximally specific by construction). When
279/// only overlay sources exist, the unique overlay whose
280/// `matched_projects` is a strict subset of every other
281/// candidate's `matched_projects` wins.
282fn pick_winner<'a>(
283    project_level: Option<&'a Task>,
284    overlays: &[&'a Overlay],
285    task_name: &TaskName,
286) -> Option<&'a Task> {
287    // DAG-005: project-level always wins when present.
288    if let Some(body) = project_level {
289        return Some(body);
290    }
291
292    match overlays.len() {
293        0 => None,
294        // DAG-004: single overlay is the trivial winner.
295        1 => overlays[0].tasks.get(task_name),
296        _ => {
297            // DAG-005 (overlay-only path): an overlay X wins iff,
298            // for every OTHER overlay Y in the candidate set,
299            // X.matched_projects is a STRICT subset of
300            // Y.matched_projects. If exactly one such X exists,
301            // it wins; otherwise we report DAG-006.
302            let winners: Vec<&Overlay> = overlays
303                .iter()
304                .copied()
305                .filter(|x| {
306                    overlays.iter().copied().all(|y| {
307                        x.name == y.name
308                            || (x.matched_projects.is_subset(&y.matched_projects)
309                                && x.matched_projects != y.matched_projects)
310                    })
311                })
312                .collect();
313            if winners.len() == 1 {
314                winners[0].tasks.get(task_name)
315            } else {
316                None
317            }
318        }
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use std::collections::BTreeSet;
325    use std::path::PathBuf;
326    use std::str::FromStr;
327
328    use haz_domain::action::TaskAction;
329    use haz_domain::env::EnvSettings;
330    use haz_domain::name::{OverlayName, ProjectName, TaskName};
331    use haz_domain::overlay::Overlay;
332    use haz_domain::path::{CanonicalPath, HazPath, ProjectRoot, WorkspaceRootPath};
333    use haz_domain::project::Project;
334    use haz_domain::settings::WorkspaceSettings;
335    use haz_domain::task::Task;
336    use haz_domain::workspace::Workspace;
337    use nonempty::NonEmpty;
338
339    use super::{DefinitionSource, OverlayMergeError, compute_effective_tasks, pick_winner};
340
341    fn project_name(s: &str) -> ProjectName {
342        ProjectName::from_str(s).unwrap()
343    }
344
345    fn task_name(s: &str) -> TaskName {
346        TaskName::from_str(s).unwrap()
347    }
348
349    fn overlay_name(s: &str) -> OverlayName {
350        OverlayName::from_str(s).unwrap()
351    }
352
353    fn task_with_marker(name: &str, marker: &str) -> Task {
354        Task {
355            name: task_name(name),
356            action: TaskAction::Command(
357                NonEmpty::from_vec(vec!["echo".to_owned(), marker.to_owned()]).unwrap(),
358            ),
359            inputs: vec![],
360            outputs: vec![],
361            deps: vec![],
362            weak_deps: vec![],
363            mutex: None,
364            env: EnvSettings::default(),
365        }
366    }
367
368    fn project_with(name: &str, root: &str, tasks: Vec<Task>) -> Project {
369        Project {
370            name: project_name(name),
371            root: ProjectRoot::Nested(
372                CanonicalPath::from_absolute(&HazPath::parse(root).unwrap()).unwrap(),
373            ),
374            tags: BTreeSet::new(),
375            tasks: tasks.into_iter().map(|t| (t.name.clone(), t)).collect(),
376        }
377    }
378
379    fn overlay_with(name: &str, matched_projects: &[&str], tasks: Vec<Task>) -> Overlay {
380        Overlay {
381            name: overlay_name(name),
382            matched_projects: matched_projects.iter().map(|p| project_name(p)).collect(),
383            tasks: tasks.into_iter().map(|t| (t.name.clone(), t)).collect(),
384        }
385    }
386
387    fn workspace_with(projects: Vec<Project>, overlays: Vec<Overlay>) -> Workspace {
388        Workspace {
389            root: WorkspaceRootPath::try_new(PathBuf::from("/abs/ws")).unwrap(),
390            projects: projects.into_iter().map(|p| (p.name.clone(), p)).collect(),
391            overlays: overlays.into_iter().map(|o| (o.name.clone(), o)).collect(),
392            settings: WorkspaceSettings::default(),
393        }
394    }
395
396    fn marker_of(task: &Task) -> &str {
397        match &task.action {
398            TaskAction::Command(parts) => parts[1].as_str(),
399            TaskAction::Shell { .. } => panic!("expected Command action with two parts"),
400        }
401    }
402
403    // DAG-002 + DAG-004 baseline: workspace with no overlays
404    // returns project tasks verbatim.
405
406    #[test]
407    fn dag_002_no_overlays_returns_project_tasks_unchanged() {
408        let p = project_with("p", "/p", vec![task_with_marker("build", "p-build")]);
409        let q = project_with(
410            "q",
411            "/q",
412            vec![
413                task_with_marker("build", "q-build"),
414                task_with_marker("test", "q-test"),
415            ],
416        );
417        let workspace = workspace_with(vec![p, q], vec![]);
418        let effective = compute_effective_tasks(&workspace).unwrap();
419
420        assert_eq!(effective.len(), 2);
421        let p_tasks = effective.get(&project_name("p")).unwrap();
422        let q_tasks = effective.get(&project_name("q")).unwrap();
423        assert_eq!(p_tasks.len(), 1);
424        assert_eq!(q_tasks.len(), 2);
425        assert_eq!(marker_of(&p_tasks[&task_name("build")]), "p-build");
426        assert_eq!(marker_of(&q_tasks[&task_name("test")]), "q-test");
427    }
428
429    #[test]
430    fn empty_workspace_returns_empty_effective_tasks() {
431        let workspace = workspace_with(vec![], vec![]);
432        let effective = compute_effective_tasks(&workspace).unwrap();
433        assert!(effective.is_empty());
434    }
435
436    // DAG-001 + DAG-002: an overlay attaches its tasks to every
437    // project in its matched-projects set, and only those.
438
439    #[test]
440    fn dag_002_overlay_attaches_task_only_to_matched_projects() {
441        let p = project_with("p", "/p", vec![]);
442        let q = project_with("q", "/q", vec![]);
443        let r = project_with("r", "/r", vec![]);
444        let lint = overlay_with(
445            "lint",
446            &["p", "r"],
447            vec![task_with_marker("lint", "lint-body")],
448        );
449        let workspace = workspace_with(vec![p, q, r], vec![lint]);
450
451        let effective = compute_effective_tasks(&workspace).unwrap();
452
453        assert!(
454            effective
455                .get(&project_name("p"))
456                .unwrap()
457                .contains_key(&task_name("lint"))
458        );
459        assert!(
460            effective
461                .get(&project_name("r"))
462                .unwrap()
463                .contains_key(&task_name("lint"))
464        );
465        assert!(
466            effective.get(&project_name("q")).unwrap().is_empty(),
467            "overlay must NOT attach to unmatched project q"
468        );
469    }
470
471    #[test]
472    fn dag_002_overlay_only_task_appears_in_effective_set_for_matched_project() {
473        let p = project_with("p", "/p", vec![task_with_marker("build", "p-build")]);
474        let lint = overlay_with("lint", &["p"], vec![task_with_marker("lint", "lint-body")]);
475        let workspace = workspace_with(vec![p], vec![lint]);
476
477        let effective = compute_effective_tasks(&workspace).unwrap();
478        let p_tasks = effective.get(&project_name("p")).unwrap();
479        assert_eq!(p_tasks.len(), 2);
480        assert_eq!(marker_of(&p_tasks[&task_name("build")]), "p-build");
481        assert_eq!(marker_of(&p_tasks[&task_name("lint")]), "lint-body");
482    }
483
484    // DAG-004: single overlay source for a task is the trivial
485    // winner.
486
487    #[test]
488    fn dag_004_single_overlay_source_is_trivial_winner() {
489        let p = project_with("p", "/p", vec![]);
490        let lint = overlay_with(
491            "lint",
492            &["p"],
493            vec![task_with_marker("lint", "overlay-body")],
494        );
495        let workspace = workspace_with(vec![p], vec![lint]);
496
497        let effective = compute_effective_tasks(&workspace).unwrap();
498        let body = &effective.get(&project_name("p")).unwrap()[&task_name("lint")];
499        assert_eq!(marker_of(body), "overlay-body");
500    }
501
502    // DAG-005: project-level source always wins over any overlay.
503
504    #[test]
505    fn dag_005_project_level_always_wins_over_overlay() {
506        let p = project_with("p", "/p", vec![task_with_marker("build", "project-body")]);
507        let universal = overlay_with(
508            "universal",
509            &["p"],
510            vec![task_with_marker("build", "overlay-body")],
511        );
512        let workspace = workspace_with(vec![p], vec![universal]);
513
514        let effective = compute_effective_tasks(&workspace).unwrap();
515        let body = &effective.get(&project_name("p")).unwrap()[&task_name("build")];
516        assert_eq!(marker_of(body), "project-body");
517    }
518
519    #[test]
520    fn dag_005_project_level_wins_even_against_singleton_overlay() {
521        // Overlay's matched_projects is {p}, equal to project-level's
522        // conceptual {p}. Per DAG-005 project-level still wins.
523        let p = project_with("p", "/p", vec![task_with_marker("build", "project-body")]);
524        let singleton = overlay_with(
525            "singleton",
526            &["p"],
527            vec![task_with_marker("build", "overlay-body")],
528        );
529        let workspace = workspace_with(vec![p], vec![singleton]);
530
531        let effective = compute_effective_tasks(&workspace).unwrap();
532        let body = &effective.get(&project_name("p")).unwrap()[&task_name("build")];
533        assert_eq!(marker_of(body), "project-body");
534    }
535
536    // DAG-005 (overlay-only path): a strictly more specific
537    // overlay wins over a less specific one.
538
539    #[test]
540    fn dag_005_more_specific_overlay_wins_over_less_specific() {
541        let p = project_with("p", "/p", vec![]);
542        let q = project_with("q", "/q", vec![]);
543        let universal = overlay_with(
544            "universal",
545            &["p", "q"],
546            vec![task_with_marker("lint", "universal-body")],
547        );
548        let specific = overlay_with(
549            "specific",
550            &["p"],
551            vec![task_with_marker("lint", "specific-body")],
552        );
553        let workspace = workspace_with(vec![p, q], vec![universal, specific]);
554
555        let effective = compute_effective_tasks(&workspace).unwrap();
556        let p_body = &effective.get(&project_name("p")).unwrap()[&task_name("lint")];
557        let q_body = &effective.get(&project_name("q")).unwrap()[&task_name("lint")];
558        // For p: specific {p} ⊂ universal {p,q} → specific wins.
559        assert_eq!(marker_of(p_body), "specific-body");
560        // For q: only universal attaches → universal is trivially the source.
561        assert_eq!(marker_of(q_body), "universal-body");
562    }
563
564    #[test]
565    fn dag_005_three_overlay_chain_smallest_wins() {
566        let p = project_with("p", "/p", vec![]);
567        let q = project_with("q", "/q", vec![]);
568        let r = project_with("r", "/r", vec![]);
569        let pqr = overlay_with(
570            "pqr",
571            &["p", "q", "r"],
572            vec![task_with_marker("compile", "pqr-body")],
573        );
574        let pq = overlay_with(
575            "pq",
576            &["p", "q"],
577            vec![task_with_marker("compile", "pq-body")],
578        );
579        let p_only = overlay_with(
580            "p_only",
581            &["p"],
582            vec![task_with_marker("compile", "p-only-body")],
583        );
584        let workspace = workspace_with(vec![p, q, r], vec![pqr, pq, p_only]);
585
586        let effective = compute_effective_tasks(&workspace).unwrap();
587        let body = &effective.get(&project_name("p")).unwrap()[&task_name("compile")];
588        assert_eq!(
589            marker_of(body),
590            "p-only-body",
591            "{{p}} ⊂ {{p,q}} ⊂ {{p,q,r}}: smallest wins"
592        );
593    }
594
595    // DAG-006: incomparable matched-projects sets emit a collision.
596
597    #[test]
598    fn dag_006_incomparable_overlays_emit_collision() {
599        let p = project_with("p", "/p", vec![]);
600        let q = project_with("q", "/q", vec![]);
601        let r = project_with("r", "/r", vec![]);
602        let pq = overlay_with("pq", &["p", "q"], vec![task_with_marker("lint", "pq-body")]);
603        let pr = overlay_with("pr", &["p", "r"], vec![task_with_marker("lint", "pr-body")]);
604        let workspace = workspace_with(vec![p, q, r], vec![pq, pr]);
605
606        let errors = compute_effective_tasks(&workspace).unwrap_err();
607        let collision_for_p = errors.as_slice().iter().any(|e| {
608            matches!(
609                e,
610                OverlayMergeError::Collision { project, task, sources }
611                    if project == &project_name("p")
612                        && task == &task_name("lint")
613                        && sources.contains(&DefinitionSource::Overlay { name: overlay_name("pq") })
614                        && sources.contains(&DefinitionSource::Overlay { name: overlay_name("pr") })
615            )
616        });
617        assert!(collision_for_p, "expected DAG-006 collision for (p, lint)");
618    }
619
620    #[test]
621    fn dag_006_equal_matched_projects_overlays_emit_collision() {
622        // Two distinct overlay files with identical
623        // matched-projects sets are treated as incomparable per
624        // DAG-003 and therefore collide per DAG-006.
625        let p = project_with("p", "/p", vec![]);
626        let a = overlay_with("a", &["p"], vec![task_with_marker("lint", "a-body")]);
627        let b = overlay_with("b", &["p"], vec![task_with_marker("lint", "b-body")]);
628        let workspace = workspace_with(vec![p], vec![a, b]);
629
630        let errors = compute_effective_tasks(&workspace).unwrap_err();
631        assert_eq!(errors.len(), 1);
632        let OverlayMergeError::Collision { sources, .. } = &errors.as_slice()[0];
633        assert_eq!(sources.len(), 2);
634        assert!(sources.contains(&DefinitionSource::Overlay {
635            name: overlay_name("a")
636        }));
637        assert!(sources.contains(&DefinitionSource::Overlay {
638            name: overlay_name("b")
639        }));
640    }
641
642    #[test]
643    fn dag_006_collision_does_not_block_other_tasks_or_projects() {
644        // (p, collide) collides; (p, good) and (q, *) succeed.
645        let p = project_with("p", "/p", vec![]);
646        let q = project_with("q", "/q", vec![task_with_marker("build", "q-build")]);
647        let a = overlay_with(
648            "a",
649            &["p"],
650            vec![
651                task_with_marker("collide", "a-body"),
652                task_with_marker("good", "a-good"),
653            ],
654        );
655        let b = overlay_with("b", &["p"], vec![task_with_marker("collide", "b-body")]);
656        let workspace = workspace_with(vec![p, q], vec![a, b]);
657
658        let errors = compute_effective_tasks(&workspace).unwrap_err();
659        assert!(
660            errors
661                .as_slice()
662                .iter()
663                .all(|e| matches!(e, OverlayMergeError::Collision { task, .. } if task == &task_name("collide"))),
664            "only `collide` should fail"
665        );
666    }
667
668    #[test]
669    fn errors_accumulate_across_projects_and_tasks() {
670        // Two separate collisions, one for each project.
671        let p = project_with("p", "/p", vec![]);
672        let q = project_with("q", "/q", vec![]);
673        let a = overlay_with(
674            "a",
675            &["p", "q"],
676            vec![task_with_marker("collide", "a-body")],
677        );
678        let b = overlay_with(
679            "b",
680            &["p", "q"],
681            vec![task_with_marker("collide", "b-body")],
682        );
683        let workspace = workspace_with(vec![p, q], vec![a, b]);
684
685        let errors = compute_effective_tasks(&workspace).unwrap_err();
686        assert_eq!(
687            errors.len(),
688            2,
689            "expected one collision per affected project"
690        );
691    }
692
693    // DefinitionSource ordering: ProjectLevel comes before Overlay
694    // in the derived Ord, reflecting the specialisation hierarchy.
695
696    #[test]
697    fn dag_003_definition_source_ordering_puts_project_level_first() {
698        let pl = DefinitionSource::ProjectLevel {
699            project: project_name("z"),
700        };
701        let ov = DefinitionSource::Overlay {
702            name: overlay_name("a"),
703        };
704        assert!(pl < ov);
705    }
706
707    // Display rendering: error text mentions count and one line
708    // per error.
709
710    #[test]
711    fn display_overlay_merge_errors_renders_count_and_list() {
712        let p = project_with("p", "/p", vec![]);
713        let a = overlay_with("a", &["p"], vec![task_with_marker("lint", "a")]);
714        let b = overlay_with("b", &["p"], vec![task_with_marker("lint", "b")]);
715        let workspace = workspace_with(vec![p], vec![a, b]);
716
717        let errors = compute_effective_tasks(&workspace).unwrap_err();
718        let rendered = errors.to_string();
719        assert!(rendered.starts_with("1 error(s) during overlay merging:"));
720        assert!(rendered.contains("overlay-merge collision"));
721        assert!(rendered.contains("DAG-006"));
722    }
723
724    // Helper-level: pick_winner edge cases that are awkward to
725    // express via compute_effective_tasks alone.
726
727    #[test]
728    fn pick_winner_returns_none_for_empty_sources() {
729        let result = pick_winner(None, &[], &task_name("anything"));
730        assert!(result.is_none());
731    }
732
733    #[test]
734    fn pick_winner_returns_project_level_over_overlays() {
735        let project_body = task_with_marker("x", "pl");
736        let overlay = overlay_with("a", &["p"], vec![task_with_marker("x", "ov")]);
737        let body = pick_winner(Some(&project_body), &[&overlay], &task_name("x")).unwrap();
738        assert_eq!(marker_of(body), "pl");
739    }
740}