Skip to main content

haz_dag/
construction.rs

1//! [`build_task_graph`]: turn a loaded [`Workspace`] into a
2//! validated [`TaskGraph`].
3//!
4//! This module is the orchestrator for `DAG-001`..`DAG-015` of
5//! `docs/spec/07-dag-semantics.md`:
6//!
7//! - `DAG-001`..`DAG-006` (overlay merging): the function first
8//!   delegates to [`crate::effective::compute_effective_tasks`]
9//!   to determine each project's effective task set. Collisions
10//!   surface as [`BuildError::OverlayCollision`] and short-circuit
11//!   the rest of the pipeline (without an unambiguous effective
12//!   task set, downstream analysis cannot run soundly).
13//! - `DAG-007` (node identity), `DAG-008` (hard edges from
14//!   `deps`), `DAG-009` (soft edges from `weakDeps`).
15//! - `DAG-010`: a node that resolves into BOTH `deps` and
16//!   `weakDeps` of the same bearing task is rejected.
17//! - `DAG-011`: every error from `DAG-007`..`DAG-015` is
18//!   accumulated into a single [`BuildErrors`]; the function does
19//!   not short-circuit within those phases.
20//! - `DAG-012`: a reference whose resolved set contains the
21//!   bearing node is rejected as a trivial self-cycle; the whole
22//!   reference is discarded and no edges from it are added.
23//! - `DAG-013`: producer-matching edges are merged in via
24//!   [`crate::producer::compute_producer_edges`].
25//! - `DAG-014`: cycles of length two or more in the union edge
26//!   set are rejected via [`crate::cycles::detect_cycles`].
27//! - `DAG-015`: literal outputs MUST be uniquely produced;
28//!   collisions are emitted via
29//!   [`crate::outputs::detect_literal_output_collisions`].
30//!   `DAG-016` (glob-output overlap) is correctly deferred to
31//!   runtime per the spec.
32
33use std::collections::{BTreeMap, BTreeSet};
34use std::fmt;
35
36use haz_domain::name::{ProjectName, TaskName};
37use haz_domain::project::Project;
38use haz_domain::resolution::{ResolutionError, resolve_task_ref};
39use haz_domain::task::Task;
40use haz_domain::task_id::TaskId;
41use haz_domain::task_ref::TaskRef;
42use haz_domain::workspace::Workspace;
43use snafu::Snafu;
44
45use crate::edge::{Edge, EdgeKind};
46use crate::effective::{
47    DefinitionSource, EffectiveTasksByProject, OverlayMergeError, compute_effective_tasks,
48};
49use crate::graph::TaskGraph;
50
51/// A single error encountered during [`build_task_graph`].
52///
53/// Multiple instances are accumulated into [`BuildErrors`] per
54/// `DAG-011`: a workspace with N broken references and M overlap
55/// violations produces a [`BuildErrors`] with `N + M + ...`
56/// entries, not the first one.
57#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
58pub enum BuildError {
59    /// More than one definition source supplies a body for the
60    /// `(project, task)` pair AND no unique winner exists under
61    /// the specialisation order (`DAG-006`). Surfaced from
62    /// [`crate::effective::compute_effective_tasks`].
63    #[snafu(display(
64        "task `{project}:{task}`: overlay-merge collision among {n} sources (DAG-006)",
65        n = sources.len()
66    ))]
67    OverlayCollision {
68        /// The project the collision applies to.
69        project: ProjectName,
70        /// The task name the collision applies to.
71        task: TaskName,
72        /// Every contributing definition source for the pair.
73        sources: BTreeSet<DefinitionSource>,
74    },
75
76    /// A task reference could not be resolved against the
77    /// workspace. The bearing task identifies WHERE in the
78    /// workspace the unresolved reference lives.
79    #[snafu(display("task `{bearing}`: reference `{reference}` is unresolved: {source}"))]
80    UnresolvedReference {
81        /// The bearing task (the task that owns the offending
82        /// reference).
83        bearing: TaskId,
84        /// The reference, as parsed.
85        reference: TaskRef,
86        /// The resolution failure mode.
87        source: ResolutionError,
88    },
89
90    /// A reference in `deps` or `weakDeps` resolved to the
91    /// bearing task itself (`DAG-012`). The whole reference is
92    /// rejected: no edges from it are added to the graph.
93    ///
94    /// The legitimate way to fan out across siblings without
95    /// reaching the bearing is the sibling-modifier form
96    /// (`^~:<task>` or `[<tag>]~:<task>`, per `REF-008`).
97    #[snafu(display(
98        "task `{bearing}`: reference `{reference}` resolves to the task itself (DAG-012)"
99    ))]
100    SelfReference {
101        /// The bearing task.
102        bearing: TaskId,
103        /// The reference, as parsed.
104        reference: TaskRef,
105    },
106
107    /// A node resolved into BOTH `deps` and `weakDeps` of the
108    /// same bearing task (`DAG-010`). The two fields are
109    /// mutually exclusive by intent; the workspace MUST be
110    /// rejected.
111    #[snafu(display(
112        "task `{bearing}`: `{overlap}` listed in both `deps` and `weakDeps` (DAG-010)"
113    ))]
114    DepsWeakDepsOverlap {
115        /// The bearing task.
116        bearing: TaskId,
117        /// The node that appears in both fields.
118        overlap: TaskId,
119    },
120
121    /// A strongly-connected component of size two or more in the
122    /// union of hard, soft, and producer-matching edges
123    /// (`DAG-014`). Length-one cycles (a single task whose
124    /// outputs intersect its own inputs per `DAG-013`) are
125    /// permitted; this variant covers only length-two-or-more
126    /// cycles.
127    #[snafu(display("cyclic dependency among {n} tasks (DAG-014)", n = nodes.len()))]
128    Cycle {
129        /// The tasks participating in the strongly-connected
130        /// component. Always `|nodes| >= 2`.
131        nodes: std::collections::BTreeSet<TaskId>,
132    },
133
134    /// A literal output path is declared as an output by more
135    /// than one task (`DAG-015`). Glob-output overlaps are
136    /// deferred to runtime per `DAG-016` and do NOT produce this
137    /// variant.
138    #[snafu(display(
139        "literal output `{path}` declared by {n} tasks (DAG-015)",
140        n = tasks.len()
141    ))]
142    LiteralOutputCollision {
143        /// Workspace-absolute string form of the colliding
144        /// literal output path.
145        path: String,
146        /// Tasks that declared this path. Always `|tasks| >= 2`.
147        tasks: std::collections::BTreeSet<TaskId>,
148    },
149}
150
151/// All construction errors accumulated by [`build_task_graph`]
152/// in a single pass over the workspace (`DAG-011`).
153///
154/// The collection is ordered: workspace-projects iteration is
155/// sorted by [`haz_domain::name::ProjectName`], each project's
156/// tasks iteration is sorted by
157/// [`haz_domain::name::TaskName`], and per-task errors appear in
158/// `deps`-then-`weakDeps` declaration order. This makes the
159/// printed diagnostic stable across runs.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct BuildErrors(Vec<BuildError>);
162
163impl BuildErrors {
164    /// View the underlying slice without consuming.
165    #[must_use]
166    pub fn as_slice(&self) -> &[BuildError] {
167        &self.0
168    }
169
170    /// Consume and return the underlying vector.
171    #[must_use]
172    pub fn into_inner(self) -> Vec<BuildError> {
173        self.0
174    }
175
176    /// Number of accumulated errors. Always `>= 1` for a value
177    /// that escapes [`build_task_graph`] as `Err(_)`.
178    #[must_use]
179    pub fn len(&self) -> usize {
180        self.0.len()
181    }
182
183    /// `true` if there are no errors (defensive; the constructor
184    /// rejects empty input).
185    #[must_use]
186    pub fn is_empty(&self) -> bool {
187        self.0.is_empty()
188    }
189}
190
191impl fmt::Display for BuildErrors {
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        let n = self.0.len();
194        writeln!(f, "{n} error(s) during DAG construction:")?;
195        for (i, err) in self.0.iter().enumerate() {
196            writeln!(f, "  {}. {err}", i + 1)?;
197        }
198        Ok(())
199    }
200}
201
202impl std::error::Error for BuildErrors {}
203
204/// Build the [`TaskGraph`] of a fully-loaded [`Workspace`].
205///
206/// Returns the `(graph, effective_workspace)` pair on success. The
207/// `effective_workspace` is the input `workspace` with each
208/// project's `tasks` replaced by its post-overlay-merge effective
209/// task set per `DAG-001`..`DAG-006`. Downstream callers (target
210/// resolution, cache-key derivation, `haz why`, `haz query`) MUST
211/// read from the returned `effective_workspace`; the un-merged
212/// input is missing overlay-attached tasks and is unsafe to
213/// consult for those phases.
214///
215/// Nodes are every `(project, task)` pair in the effective
216/// workspace (`DAG-007`). Hard edges come from `deps` (`DAG-008`);
217/// soft edges come from `weakDeps` (`DAG-009`). Producer-matching
218/// edges come from the `outputs`/`inputs` intersection
219/// (`DAG-013`). The function then rejects length-two-or-more
220/// cycles in the union of all edge kinds (`DAG-014`) and
221/// duplicate literal-output declarations (`DAG-015`).
222///
223/// # Errors
224///
225/// Returns [`BuildErrors`] containing every construction error
226/// found across every task in the workspace per `DAG-011`. The
227/// function does NOT short-circuit on the first error; a user
228/// fixing a workspace expects every broken reference to surface
229/// in one run.
230pub fn build_task_graph(workspace: &Workspace) -> Result<(TaskGraph, Workspace), BuildErrors> {
231    // DAG-001..DAG-006: overlay merging. The effective task set is
232    // required by every downstream phase; a collision means
233    // (P, n)'s body is undefined, so we cannot continue.
234    let effective = match compute_effective_tasks(workspace) {
235        Ok(effective) => effective,
236        Err(overlay_errors) => {
237            let errors = overlay_errors
238                .into_inner()
239                .into_iter()
240                .map(|e| {
241                    let OverlayMergeError::Collision {
242                        project,
243                        task,
244                        sources,
245                    } = e;
246                    BuildError::OverlayCollision {
247                        project,
248                        task,
249                        sources,
250                    }
251                })
252                .collect();
253            return Err(BuildErrors(errors));
254        }
255    };
256
257    // Build an "effective workspace" where each project's `tasks`
258    // is its effective task set (project tasks plus the bodies
259    // contributed by attached overlays). All subsequent phases
260    // (resolution, producer matching, output uniqueness) operate
261    // on this view per `DAG-008`/`DAG-009`'s "post-overlay-merging"
262    // qualifier.
263    let workspace = apply_effective_tasks(workspace, &effective);
264
265    let mut errors: Vec<BuildError> = Vec::new();
266    let mut nodes: BTreeSet<TaskId> = BTreeSet::new();
267    let mut edges: BTreeSet<Edge> = BTreeSet::new();
268
269    for (project_name, project) in &workspace.projects {
270        for task_name in project.tasks.keys() {
271            let bearing_id = TaskId {
272                project: project_name.clone(),
273                task: task_name.clone(),
274            };
275            nodes.insert(bearing_id.clone());
276
277            let task = &project.tasks[task_name];
278
279            let hard_set = resolve_field(&task.deps, project, &workspace, &bearing_id, &mut errors);
280            let soft_set = resolve_field(
281                &task.weak_deps,
282                project,
283                &workspace,
284                &bearing_id,
285                &mut errors,
286            );
287
288            // DAG-010: a node listed (post-resolution) in BOTH
289            // fields is rejected. Iterate the intersection in
290            // canonical order (the sets are BTreeSets).
291            for overlap_id in hard_set.intersection(&soft_set) {
292                errors.push(BuildError::DepsWeakDepsOverlap {
293                    bearing: bearing_id.clone(),
294                    overlap: overlap_id.clone(),
295                });
296            }
297
298            for source in &hard_set {
299                edges.insert(Edge {
300                    from: source.clone(),
301                    to: bearing_id.clone(),
302                    kind: EdgeKind::Hard,
303                });
304            }
305            for source in &soft_set {
306                edges.insert(Edge {
307                    from: source.clone(),
308                    to: bearing_id.clone(),
309                    kind: EdgeKind::Soft,
310                });
311            }
312        }
313    }
314
315    // DAG-013: merge in the producer-matching edges. They do
316    // not produce errors; they are pure metadata derived from
317    // declared `outputs`/`inputs` patterns.
318    edges.extend(crate::producer::compute_producer_edges(&workspace));
319
320    // DAG-014: reject simple cycles of length two or more in
321    // the union edge set. Length-one cycles (formatter
322    // producer-matching self-edges per DAG-013) are permitted.
323    let provisional = TaskGraph {
324        nodes: nodes.clone(),
325        edges: edges.clone(),
326    };
327    for scc_nodes in crate::cycles::detect_cycles(&provisional) {
328        errors.push(BuildError::Cycle { nodes: scc_nodes });
329    }
330
331    // DAG-015: literal outputs MUST be uniquely produced. Glob
332    // outputs are deferred to runtime per DAG-016.
333    for collision in crate::outputs::detect_literal_output_collisions(&workspace) {
334        errors.push(BuildError::LiteralOutputCollision {
335            path: collision.path,
336            tasks: collision.tasks,
337        });
338    }
339
340    if errors.is_empty() {
341        Ok((TaskGraph { nodes, edges }, workspace))
342    } else {
343        Err(BuildErrors(errors))
344    }
345}
346
347/// Construct a "shadow" workspace whose every project carries its
348/// effective task set in place of its declared `tasks`.
349///
350/// Tag and root metadata are preserved verbatim. The original
351/// workspace is left untouched. Subsequent phases of
352/// [`build_task_graph`] are written against this view so the
353/// "post-overlay-merging" qualifier in `DAG-008`/`DAG-009` becomes
354/// a structural property of the input rather than a per-call-site
355/// concern.
356fn apply_effective_tasks(workspace: &Workspace, effective: &EffectiveTasksByProject) -> Workspace {
357    let projects: BTreeMap<ProjectName, Project> = workspace
358        .projects
359        .iter()
360        .map(|(name, project)| {
361            let tasks: BTreeMap<TaskName, Task> = effective.get(name).cloned().unwrap_or_default();
362            (
363                name.clone(),
364                Project {
365                    name: project.name.clone(),
366                    root: project.root.clone(),
367                    tags: project.tags.clone(),
368                    tasks,
369                },
370            )
371        })
372        .collect();
373    Workspace {
374        root: workspace.root.clone(),
375        projects,
376        overlays: workspace.overlays.clone(),
377        settings: workspace.settings.clone(),
378    }
379}
380
381/// Resolve every reference in `field_refs` against
382/// `(bearing_project, workspace)`, accumulating successes into a
383/// set and pushing failures into `errors`.
384///
385/// A successful resolution whose set contains the bearing itself
386/// triggers a `DAG-012` self-reference error and contributes
387/// NOTHING to the returned set (the whole reference is rejected).
388/// Other successes are merged.
389fn resolve_field(
390    field_refs: &[TaskRef],
391    bearing_project: &Project,
392    workspace: &Workspace,
393    bearing_id: &TaskId,
394    errors: &mut Vec<BuildError>,
395) -> BTreeSet<TaskId> {
396    let mut result: BTreeSet<TaskId> = BTreeSet::new();
397    for reference in field_refs {
398        match resolve_task_ref(reference, bearing_project, workspace) {
399            Ok(resolved) => {
400                if resolved.contains(bearing_id) {
401                    errors.push(BuildError::SelfReference {
402                        bearing: bearing_id.clone(),
403                        reference: reference.clone(),
404                    });
405                    continue;
406                }
407                result.extend(resolved);
408            }
409            Err(source) => {
410                errors.push(BuildError::UnresolvedReference {
411                    bearing: bearing_id.clone(),
412                    reference: reference.clone(),
413                    source,
414                });
415            }
416        }
417    }
418    result
419}
420
421#[cfg(test)]
422mod tests {
423    use std::collections::{BTreeMap, BTreeSet};
424    use std::path::PathBuf;
425    use std::str::FromStr;
426
427    use haz_domain::action::TaskAction;
428    use haz_domain::env::EnvSettings;
429    use haz_domain::name::{ProjectName, TagName, TaskName};
430    use haz_domain::path::{CanonicalPath, HazPath, ProjectRoot, WorkspaceRootPath};
431    use haz_domain::project::Project;
432    use haz_domain::resolution::ResolutionError;
433    use haz_domain::settings::WorkspaceSettings;
434    use haz_domain::task::Task;
435    use haz_domain::task_id::TaskId;
436    use haz_domain::task_ref::TaskRef;
437    use haz_domain::workspace::Workspace;
438    use nonempty::NonEmpty;
439
440    use crate::construction::{BuildError, build_task_graph};
441    use crate::edge::{Edge, EdgeKind};
442    use crate::effective::DefinitionSource;
443
444    fn project_name(s: &str) -> ProjectName {
445        ProjectName::from_str(s).unwrap()
446    }
447    fn tag_name(s: &str) -> TagName {
448        TagName::from_str(s).unwrap()
449    }
450    fn task_name(s: &str) -> TaskName {
451        TaskName::from_str(s).unwrap()
452    }
453
454    fn task_with(name: &str, deps: &[&str], weak_deps: &[&str]) -> Task {
455        Task {
456            name: task_name(name),
457            action: TaskAction::Command(NonEmpty::from_vec(vec!["true".to_owned()]).unwrap()),
458            inputs: vec![],
459            outputs: vec![],
460            deps: deps.iter().map(|r| TaskRef::parse(r).unwrap()).collect(),
461            weak_deps: weak_deps
462                .iter()
463                .map(|r| TaskRef::parse(r).unwrap())
464                .collect(),
465            mutex: None,
466            env: EnvSettings::default(),
467        }
468    }
469
470    fn task_with_mutex(name: &str, mutex_name: &str) -> Task {
471        use haz_domain::mutex::{Mutex, MutexMode, MutexScope};
472        use haz_domain::name::MutexName;
473        Task {
474            name: task_name(name),
475            action: TaskAction::Command(NonEmpty::from_vec(vec!["true".to_owned()]).unwrap()),
476            inputs: vec![],
477            outputs: vec![],
478            deps: vec![],
479            weak_deps: vec![],
480            mutex: Some(Mutex {
481                scope: MutexScope::Workspace,
482                name: MutexName::from_str(mutex_name).unwrap(),
483                mode: MutexMode::Exclusive,
484            }),
485            env: EnvSettings::default(),
486        }
487    }
488
489    fn project_with(name: &str, root: &str, tags: &[&str], tasks: Vec<Task>) -> Project {
490        Project {
491            name: project_name(name),
492            root: ProjectRoot::Nested(
493                CanonicalPath::from_absolute(&HazPath::parse(root).unwrap()).unwrap(),
494            ),
495            tags: tags.iter().map(|t| tag_name(t)).collect(),
496            tasks: tasks.into_iter().map(|t| (t.name.clone(), t)).collect(),
497        }
498    }
499
500    fn workspace_with(projects: Vec<Project>) -> Workspace {
501        let mut map = BTreeMap::new();
502        for project in projects {
503            map.insert(project.name.clone(), project);
504        }
505        Workspace {
506            root: WorkspaceRootPath::try_new(PathBuf::from("/abs/ws")).unwrap(),
507            projects: map,
508            overlays: BTreeMap::new(),
509            settings: WorkspaceSettings::default(),
510        }
511    }
512
513    fn id(project: &str, task: &str) -> TaskId {
514        TaskId {
515            project: project_name(project),
516            task: task_name(task),
517        }
518    }
519
520    fn hard(from: TaskId, to: TaskId) -> Edge {
521        Edge {
522            from,
523            to,
524            kind: EdgeKind::Hard,
525        }
526    }
527
528    fn soft(from: TaskId, to: TaskId) -> Edge {
529        Edge {
530            from,
531            to,
532            kind: EdgeKind::Soft,
533        }
534    }
535
536    // DAG-007: node identity.
537
538    #[test]
539    fn dag_007_nodes_are_every_project_task_pair() {
540        let p = project_with(
541            "p",
542            "/p",
543            &[],
544            vec![task_with("a", &[], &[]), task_with("b", &[], &[])],
545        );
546        let q = project_with("q", "/q", &[], vec![task_with("a", &[], &[])]);
547        let workspace = workspace_with(vec![p, q]);
548
549        let (graph, _) = build_task_graph(&workspace).unwrap();
550        assert_eq!(
551            graph.nodes,
552            BTreeSet::from([id("p", "a"), id("p", "b"), id("q", "a")])
553        );
554        assert!(graph.edges.is_empty());
555    }
556
557    #[test]
558    fn empty_workspace_is_empty_graph() {
559        let workspace = workspace_with(vec![]);
560        let (graph, _) = build_task_graph(&workspace).unwrap();
561        assert!(graph.nodes.is_empty());
562        assert!(graph.edges.is_empty());
563    }
564
565    // DAG-008: hard edges from `deps`.
566
567    #[test]
568    fn dag_008_same_project_dep_adds_one_hard_edge() {
569        let p = project_with(
570            "p",
571            "/p",
572            &[],
573            vec![
574                task_with("other", &[], &[]),
575                task_with("bearing", &["~:other"], &[]),
576            ],
577        );
578        let workspace = workspace_with(vec![p]);
579
580        let (graph, _) = build_task_graph(&workspace).unwrap();
581        assert_eq!(
582            graph.edges,
583            BTreeSet::from([hard(id("p", "other"), id("p", "bearing"))])
584        );
585    }
586
587    #[test]
588    fn dag_008_fan_out_dep_adds_hard_edges_to_every_matching_project() {
589        let a = project_with("a", "/a", &[], vec![task_with("compile", &[], &[])]);
590        let b = project_with("b", "/b", &[], vec![task_with("compile", &[], &[])]);
591        let bearing = project_with(
592            "z",
593            "/z",
594            &[],
595            vec![task_with("build", &["^:compile"], &[])],
596        );
597        let workspace = workspace_with(vec![a, b, bearing]);
598
599        let (graph, _) = build_task_graph(&workspace).unwrap();
600        assert!(
601            graph
602                .edges
603                .contains(&hard(id("a", "compile"), id("z", "build")))
604        );
605        assert!(
606            graph
607                .edges
608                .contains(&hard(id("b", "compile"), id("z", "build")))
609        );
610        assert_eq!(
611            graph
612                .edges
613                .iter()
614                .filter(|e| e.kind == EdgeKind::Hard)
615                .count(),
616            2
617        );
618    }
619
620    // DAG-009: soft edges from `weakDeps`.
621
622    #[test]
623    fn dag_009_same_project_weak_dep_adds_one_soft_edge() {
624        let p = project_with(
625            "p",
626            "/p",
627            &[],
628            vec![
629                task_with("gen", &[], &[]),
630                task_with("build", &[], &["~:gen"]),
631            ],
632        );
633        let workspace = workspace_with(vec![p]);
634
635        let (graph, _) = build_task_graph(&workspace).unwrap();
636        assert_eq!(
637            graph.edges,
638            BTreeSet::from([soft(id("p", "gen"), id("p", "build"))])
639        );
640    }
641
642    #[test]
643    fn deps_and_weak_deps_to_different_nodes_coexist() {
644        let p = project_with(
645            "p",
646            "/p",
647            &[],
648            vec![
649                task_with("compile", &[], &[]),
650                task_with("gen", &[], &[]),
651                task_with("build", &["~:compile"], &["~:gen"]),
652            ],
653        );
654        let workspace = workspace_with(vec![p]);
655
656        let (graph, _) = build_task_graph(&workspace).unwrap();
657        assert!(
658            graph
659                .edges
660                .contains(&hard(id("p", "compile"), id("p", "build")))
661        );
662        assert!(
663            graph
664                .edges
665                .contains(&soft(id("p", "gen"), id("p", "build")))
666        );
667        assert_eq!(graph.edges.len(), 2);
668    }
669
670    // DAG-010: deps/weakDeps mutual exclusion.
671
672    #[test]
673    fn dag_010_same_node_in_deps_and_weak_deps_is_rejected() {
674        let p = project_with(
675            "p",
676            "/p",
677            &[],
678            vec![
679                task_with("other", &[], &[]),
680                task_with("bearing", &["~:other"], &["~:other"]),
681            ],
682        );
683        let workspace = workspace_with(vec![p]);
684
685        let errors = build_task_graph(&workspace).unwrap_err();
686        let bearing = id("p", "bearing");
687        let other = id("p", "other");
688        assert!(errors.as_slice().iter().any(|e| matches!(
689            e,
690            BuildError::DepsWeakDepsOverlap { bearing: b, overlap: o }
691                if b == &bearing && o == &other
692        )));
693    }
694
695    #[test]
696    fn dag_010_overlap_detected_through_different_ref_forms() {
697        // ^:compile in deps and p:compile in weakDeps both reach
698        // (p, compile) in this workspace.
699        let p = project_with(
700            "p",
701            "/p",
702            &[],
703            vec![
704                task_with("compile", &[], &[]),
705                task_with("bearing", &["^:compile"], &["p:compile"]),
706            ],
707        );
708        let workspace = workspace_with(vec![p]);
709
710        let errors = build_task_graph(&workspace).unwrap_err();
711        assert!(errors.as_slice().iter().any(|e| matches!(
712            e,
713            BuildError::DepsWeakDepsOverlap { overlap, .. } if overlap == &id("p", "compile")
714        )));
715    }
716
717    // DAG-011: error accumulation.
718
719    #[test]
720    fn dag_011_multiple_errors_across_multiple_tasks_are_all_reported() {
721        let p = project_with(
722            "p",
723            "/p",
724            &[],
725            vec![
726                task_with("a", &["~:ghost1"], &[]),
727                task_with("b", &["~:ghost2"], &[]),
728            ],
729        );
730        let q = project_with(
731            "q",
732            "/q",
733            &[],
734            vec![task_with("a", &["nonexistent:thing"], &[])],
735        );
736        let workspace = workspace_with(vec![p, q]);
737
738        let errors = build_task_graph(&workspace).unwrap_err();
739        assert_eq!(errors.len(), 3, "all three errors must be accumulated");
740
741        let ghost1_seen = errors.as_slice().iter().any(|e| matches!(
742            e,
743            BuildError::UnresolvedReference { source: ResolutionError::BearingTaskNotFound { task }, .. }
744                if task == &task_name("ghost1")
745        ));
746        let ghost2_seen = errors.as_slice().iter().any(|e| matches!(
747            e,
748            BuildError::UnresolvedReference { source: ResolutionError::BearingTaskNotFound { task }, .. }
749                if task == &task_name("ghost2")
750        ));
751        let nonexistent_seen = errors.as_slice().iter().any(|e| matches!(
752            e,
753            BuildError::UnresolvedReference { source: ResolutionError::ProjectNotFound { project }, .. }
754                if project == &project_name("nonexistent")
755        ));
756        assert!(ghost1_seen && ghost2_seen && nonexistent_seen);
757    }
758
759    // DAG-012: self-reference rejection.
760
761    #[test]
762    fn dag_012_same_project_self_reference_rejected() {
763        let p = project_with("p", "/p", &[], vec![task_with("loop", &["~:loop"], &[])]);
764        let workspace = workspace_with(vec![p]);
765
766        let errors = build_task_graph(&workspace).unwrap_err();
767        assert_eq!(errors.len(), 1);
768        assert!(matches!(
769            errors.as_slice()[0],
770            BuildError::SelfReference { .. }
771        ));
772    }
773
774    #[test]
775    fn dag_012_project_ref_self_reference_rejected() {
776        let p = project_with("p", "/p", &[], vec![task_with("loop", &["p:loop"], &[])]);
777        let workspace = workspace_with(vec![p]);
778
779        let errors = build_task_graph(&workspace).unwrap_err();
780        assert!(matches!(
781            errors.as_slice()[0],
782            BuildError::SelfReference { .. }
783        ));
784    }
785
786    #[test]
787    fn dag_012_fan_out_self_reference_rejects_whole_reference() {
788        // ^:compile in a task named `compile` resolves to a set
789        // that includes the bearing. The WHOLE reference is
790        // rejected: no Hard edge from the OTHER projects is
791        // added either.
792        let a = project_with("a", "/a", &[], vec![task_with("compile", &[], &[])]);
793        let bearing = project_with(
794            "z",
795            "/z",
796            &[],
797            vec![task_with("compile", &["^:compile"], &[])],
798        );
799        let workspace = workspace_with(vec![a, bearing]);
800
801        let errors = build_task_graph(&workspace).unwrap_err();
802        assert!(
803            errors
804                .as_slice()
805                .iter()
806                .any(|e| matches!(e, BuildError::SelfReference { .. }))
807        );
808    }
809
810    #[test]
811    fn mutex_009_shared_mutex_adds_no_edges_between_declaring_tasks() {
812        // MUTEX-009: two tasks declaring the same exclusive mutex
813        // create only a runtime scheduling constraint, NOT an edge
814        // in the task graph. Building the graph for two such tasks
815        // (no deps, no shared inputs/outputs, no producer matching)
816        // MUST yield zero edges; the cycle detector's union graph
817        // therefore cannot mistake the shared mutex for a cycle.
818        let p = project_with(
819            "p",
820            "/p",
821            &[],
822            vec![task_with_mutex("a", "db"), task_with_mutex("b", "db")],
823        );
824        let workspace = workspace_with(vec![p]);
825
826        let (graph, _) = build_task_graph(&workspace).unwrap();
827        assert_eq!(graph.nodes.len(), 2);
828        assert!(
829            graph.edges.is_empty(),
830            "MUTEX-009: mutex declarations MUST NOT introduce edges; \
831             got {:?}",
832            graph.edges,
833        );
834        assert!(
835            crate::cycles::detect_cycles(&graph).is_empty(),
836            "MUTEX-009: shared mutex MUST NOT register as a DAG-014 cycle",
837        );
838    }
839
840    #[test]
841    fn mutex_009_cross_project_shared_mutex_adds_no_edges() {
842        // MUTEX-009: same property at workspace scope across two
843        // projects. The mutex is workspace-global so the runtime
844        // constraint applies cross-project, but the graph still
845        // contains zero edges between the two declaring nodes.
846        let a = project_with("a", "/a", &[], vec![task_with_mutex("migrate", "db")]);
847        let b = project_with("b", "/b", &[], vec![task_with_mutex("migrate", "db")]);
848        let workspace = workspace_with(vec![a, b]);
849
850        let (graph, _) = build_task_graph(&workspace).unwrap();
851        assert_eq!(graph.nodes.len(), 2);
852        assert!(
853            graph.edges.is_empty(),
854            "MUTEX-009: cross-project shared mutex MUST NOT add edges; \
855             got {:?}",
856            graph.edges,
857        );
858        assert!(crate::cycles::detect_cycles(&graph).is_empty());
859    }
860
861    #[test]
862    fn dag_012_sibling_fan_out_does_not_trigger_self_reference() {
863        // ^~:compile in a `compile` task excludes the bearing by
864        // construction and is therefore legitimate.
865        let a = project_with("a", "/a", &[], vec![task_with("compile", &[], &[])]);
866        let bearing = project_with(
867            "z",
868            "/z",
869            &[],
870            vec![task_with("compile", &["^~:compile"], &[])],
871        );
872        let workspace = workspace_with(vec![a, bearing]);
873
874        let (graph, _) = build_task_graph(&workspace).unwrap();
875        assert!(
876            graph
877                .edges
878                .contains(&hard(id("a", "compile"), id("z", "compile")))
879        );
880        assert_eq!(graph.edges.len(), 1);
881    }
882
883    #[test]
884    fn dag_012_tag_self_reference_rejected() {
885        let a = project_with("a", "/a", &["t"], vec![task_with("compile", &[], &[])]);
886        let bearing = project_with(
887            "z",
888            "/z",
889            &["t"],
890            vec![task_with("compile", &["[t]:compile"], &[])],
891        );
892        let workspace = workspace_with(vec![a, bearing]);
893
894        let errors = build_task_graph(&workspace).unwrap_err();
895        assert!(
896            errors
897                .as_slice()
898                .iter()
899                .any(|e| matches!(e, BuildError::SelfReference { .. }))
900        );
901    }
902
903    // Stability: error order is deterministic (BTreeMap-driven).
904
905    #[test]
906    fn errors_appear_in_project_then_task_order() {
907        let p = project_with(
908            "p",
909            "/p",
910            &[],
911            vec![
912                task_with("a", &["~:ghost"], &[]),
913                task_with("b", &["~:ghost"], &[]),
914            ],
915        );
916        let q = project_with("q", "/q", &[], vec![task_with("a", &["~:ghost"], &[])]);
917        let workspace = workspace_with(vec![p, q]);
918
919        let errors = build_task_graph(&workspace).unwrap_err();
920        let bearings: Vec<TaskId> = errors
921            .as_slice()
922            .iter()
923            .map(|e| match e {
924                BuildError::UnresolvedReference { bearing, .. }
925                | BuildError::SelfReference { bearing, .. }
926                | BuildError::DepsWeakDepsOverlap { bearing, .. } => bearing.clone(),
927                BuildError::Cycle { .. }
928                | BuildError::LiteralOutputCollision { .. }
929                | BuildError::OverlayCollision { .. } => {
930                    unreachable!("no cycle/collision/overlay errors in this workspace")
931                }
932            })
933            .collect();
934        assert_eq!(bearings, vec![id("p", "a"), id("p", "b"), id("q", "a")]);
935    }
936
937    // BuildErrors display rendering.
938
939    #[test]
940    fn build_errors_display_renders_count_and_list() {
941        let p = project_with("p", "/p", &[], vec![task_with("a", &["~:loop_self"], &[])]);
942        let workspace = workspace_with(vec![p]);
943        let errors = build_task_graph(&workspace).unwrap_err();
944        let rendered = errors.to_string();
945        assert!(rendered.starts_with("1 error(s) during DAG construction:"));
946        assert!(rendered.contains("1. "));
947    }
948
949    // DAG-014: cycle rejection in the union edge set.
950
951    #[test]
952    fn dag_014_mutual_hard_deps_form_a_cycle() {
953        let p = project_with(
954            "p",
955            "/p",
956            &[],
957            vec![task_with("a", &["~:b"], &[]), task_with("b", &["~:a"], &[])],
958        );
959        let workspace = workspace_with(vec![p]);
960        let errors = build_task_graph(&workspace).unwrap_err();
961
962        let cycle_seen = errors.as_slice().iter().any(|e| {
963            matches!(
964                e,
965                BuildError::Cycle { nodes }
966                    if nodes == &BTreeSet::from([id("p", "a"), id("p", "b")])
967            )
968        });
969        assert!(cycle_seen, "expected a Cycle error covering {{a, b}}");
970    }
971
972    #[test]
973    fn dag_014_three_task_cycle_is_detected() {
974        let p = project_with(
975            "p",
976            "/p",
977            &[],
978            vec![
979                task_with("a", &["~:b"], &[]),
980                task_with("b", &["~:c"], &[]),
981                task_with("c", &["~:a"], &[]),
982            ],
983        );
984        let workspace = workspace_with(vec![p]);
985        let errors = build_task_graph(&workspace).unwrap_err();
986        let cycle_seen = errors.as_slice().iter().any(|e| {
987            matches!(
988                e,
989                BuildError::Cycle { nodes }
990                    if nodes == &BTreeSet::from([id("p", "a"), id("p", "b"), id("p", "c")])
991            )
992        });
993        assert!(cycle_seen);
994    }
995
996    #[test]
997    fn dag_014_cycle_through_hard_and_soft_is_detected() {
998        // A's deps depends on B, B's weakDeps depends on A.
999        let p = project_with(
1000            "p",
1001            "/p",
1002            &[],
1003            vec![task_with("a", &["~:b"], &[]), task_with("b", &[], &["~:a"])],
1004        );
1005        let workspace = workspace_with(vec![p]);
1006        let errors = build_task_graph(&workspace).unwrap_err();
1007        assert!(
1008            errors
1009                .as_slice()
1010                .iter()
1011                .any(|e| matches!(e, BuildError::Cycle { .. }))
1012        );
1013    }
1014
1015    #[test]
1016    fn dag_014_acyclic_workspace_succeeds() {
1017        let p = project_with(
1018            "p",
1019            "/p",
1020            &[],
1021            vec![
1022                task_with("a", &[], &[]),
1023                task_with("b", &["~:a"], &[]),
1024                task_with("c", &["~:b"], &[]),
1025            ],
1026        );
1027        let workspace = workspace_with(vec![p]);
1028        let (graph, _) = build_task_graph(&workspace).unwrap();
1029        assert_eq!(graph.nodes.len(), 3);
1030        // a -> b, b -> c
1031        assert!(graph.edges.contains(&hard(id("p", "a"), id("p", "b"))));
1032        assert!(graph.edges.contains(&hard(id("p", "b"), id("p", "c"))));
1033    }
1034
1035    // DAG-015: literal-output uniqueness.
1036
1037    #[test]
1038    fn dag_015_two_tasks_same_literal_output_is_rejected() {
1039        use haz_domain::path::OutputSpec;
1040
1041        // Hand-build tasks with conflicting outputs (the
1042        // task_with helper above doesn't take outputs; build
1043        // by struct literal here).
1044        let mut p_tasks = std::collections::BTreeMap::new();
1045        let task_a = Task {
1046            name: task_name("a"),
1047            action: TaskAction::Command(NonEmpty::from_vec(vec!["true".to_owned()]).unwrap()),
1048            inputs: vec![],
1049            outputs: vec![OutputSpec::parse("dist/main.js").unwrap()],
1050            deps: vec![],
1051            weak_deps: vec![],
1052            mutex: None,
1053            env: EnvSettings::default(),
1054        };
1055        let task_b = Task {
1056            name: task_name("b"),
1057            action: TaskAction::Command(NonEmpty::from_vec(vec!["true".to_owned()]).unwrap()),
1058            inputs: vec![],
1059            outputs: vec![OutputSpec::parse("dist/main.js").unwrap()],
1060            deps: vec![],
1061            weak_deps: vec![],
1062            mutex: None,
1063            env: EnvSettings::default(),
1064        };
1065        p_tasks.insert(task_a.name.clone(), task_a);
1066        p_tasks.insert(task_b.name.clone(), task_b);
1067
1068        let p = Project {
1069            name: project_name("p"),
1070            root: haz_domain::path::ProjectRoot::Nested(
1071                haz_domain::path::CanonicalPath::from_absolute(
1072                    &haz_domain::path::HazPath::parse("/p").unwrap(),
1073                )
1074                .unwrap(),
1075            ),
1076            tags: BTreeSet::new(),
1077            tasks: p_tasks,
1078        };
1079        let workspace = workspace_with(vec![p]);
1080
1081        let errors = build_task_graph(&workspace).unwrap_err();
1082        let collision_seen = errors.as_slice().iter().any(|e| {
1083            matches!(
1084                e,
1085                BuildError::LiteralOutputCollision { path, tasks }
1086                    if path == "/p/dist/main.js"
1087                        && tasks == &BTreeSet::from([id("p", "a"), id("p", "b")])
1088            )
1089        });
1090        assert!(collision_seen);
1091    }
1092
1093    // DAG-001..DAG-006: overlay merging end-to-end via
1094    // `build_task_graph`.
1095
1096    fn overlay_name(s: &str) -> haz_domain::name::OverlayName {
1097        haz_domain::name::OverlayName::from_str(s).unwrap()
1098    }
1099
1100    fn overlay_with(
1101        name: &str,
1102        matched_projects: &[&str],
1103        tasks: Vec<Task>,
1104    ) -> haz_domain::overlay::Overlay {
1105        haz_domain::overlay::Overlay {
1106            name: overlay_name(name),
1107            matched_projects: matched_projects.iter().map(|p| project_name(p)).collect(),
1108            tasks: tasks.into_iter().map(|t| (t.name.clone(), t)).collect(),
1109        }
1110    }
1111
1112    fn workspace_with_overlays(
1113        projects: Vec<Project>,
1114        overlays: Vec<haz_domain::overlay::Overlay>,
1115    ) -> Workspace {
1116        Workspace {
1117            root: WorkspaceRootPath::try_new(PathBuf::from("/abs/ws")).unwrap(),
1118            projects: projects.into_iter().map(|p| (p.name.clone(), p)).collect(),
1119            overlays: overlays.into_iter().map(|o| (o.name.clone(), o)).collect(),
1120            settings: WorkspaceSettings::default(),
1121        }
1122    }
1123
1124    // DAG-001 + DAG-007: an overlay-attached task becomes a node
1125    // in the graph.
1126
1127    #[test]
1128    fn overlay_attached_task_appears_as_a_node() {
1129        let p = project_with("p", "/p", &[], vec![task_with("build", &[], &[])]);
1130        let lint = overlay_with("lint", &["p"], vec![task_with("lint", &[], &[])]);
1131        let workspace = workspace_with_overlays(vec![p], vec![lint]);
1132
1133        let (graph, _) = build_task_graph(&workspace).unwrap();
1134        assert!(graph.nodes.contains(&id("p", "build")));
1135        assert!(graph.nodes.contains(&id("p", "lint")));
1136        assert_eq!(graph.nodes.len(), 2);
1137    }
1138
1139    // DAG-008 post-overlay: an overlay's task body declares `deps`
1140    // resolved against the EFFECTIVE task set of the bearing
1141    // project. The reference `~:build` refers to the project's
1142    // own `build`; the resulting hard edge attaches to the
1143    // overlay-contributed node.
1144
1145    #[test]
1146    fn overlay_task_with_dep_on_project_task_emits_hard_edge() {
1147        let p = project_with("p", "/p", &[], vec![task_with("build", &[], &[])]);
1148        let lint = overlay_with("lint", &["p"], vec![task_with("lint", &["~:build"], &[])]);
1149        let workspace = workspace_with_overlays(vec![p], vec![lint]);
1150
1151        let (graph, _) = build_task_graph(&workspace).unwrap();
1152        assert!(
1153            graph
1154                .edges
1155                .contains(&hard(id("p", "build"), id("p", "lint")))
1156        );
1157        assert_eq!(graph.edges.len(), 1);
1158    }
1159
1160    // A project task can reference an overlay-contributed task
1161    // because resolution sees the effective task set.
1162
1163    #[test]
1164    fn project_task_can_reference_overlay_contributed_sibling() {
1165        let p = project_with("p", "/p", &[], vec![task_with("build", &["~:lint"], &[])]);
1166        let lint = overlay_with("lint", &["p"], vec![task_with("lint", &[], &[])]);
1167        let workspace = workspace_with_overlays(vec![p], vec![lint]);
1168
1169        let (graph, _) = build_task_graph(&workspace).unwrap();
1170        assert!(
1171            graph
1172                .edges
1173                .contains(&hard(id("p", "lint"), id("p", "build")))
1174        );
1175    }
1176
1177    // DAG-006: an overlay-merge collision propagates as
1178    // `BuildError::OverlayCollision` carrying every contributing
1179    // overlay.
1180
1181    #[test]
1182    fn dag_006_overlay_collision_propagates_as_build_error() {
1183        let p = project_with("p", "/p", &[], vec![]);
1184        let q = project_with("q", "/q", &[], vec![]);
1185        let r = project_with("r", "/r", &[], vec![]);
1186        let pq = overlay_with("pq", &["p", "q"], vec![task_with("lint", &[], &[])]);
1187        let pr = overlay_with("pr", &["p", "r"], vec![task_with("lint", &[], &[])]);
1188        let workspace = workspace_with_overlays(vec![p, q, r], vec![pq, pr]);
1189
1190        let errors = build_task_graph(&workspace).unwrap_err();
1191        let collision_seen = errors.as_slice().iter().any(|e| {
1192            matches!(
1193                e,
1194                BuildError::OverlayCollision { project, task, sources }
1195                    if project == &project_name("p")
1196                        && task == &task_name("lint")
1197                        && sources.contains(&DefinitionSource::Overlay { name: overlay_name("pq") })
1198                        && sources.contains(&DefinitionSource::Overlay { name: overlay_name("pr") })
1199            )
1200        });
1201        assert!(
1202            collision_seen,
1203            "expected `OverlayCollision` carrying both overlays as sources"
1204        );
1205    }
1206
1207    // Overlay-merge errors short-circuit downstream phases:
1208    // resolution, cycles, output collisions are not surfaced in
1209    // the same run.
1210
1211    #[test]
1212    fn overlay_collision_short_circuits_downstream_phases() {
1213        let p = project_with("p", "/p", &[], vec![task_with("a", &["~:ghost"], &[])]);
1214        let q = project_with("q", "/q", &[], vec![]);
1215        let pq = overlay_with("pq", &["p", "q"], vec![task_with("lint", &[], &[])]);
1216        let qp = overlay_with("qp", &["p", "q"], vec![task_with("lint", &[], &[])]);
1217        let workspace = workspace_with_overlays(vec![p, q], vec![pq, qp]);
1218
1219        let errors = build_task_graph(&workspace).unwrap_err();
1220        assert!(
1221            errors
1222                .as_slice()
1223                .iter()
1224                .all(|e| matches!(e, BuildError::OverlayCollision { .. })),
1225            "downstream errors must NOT surface alongside overlay collisions"
1226        );
1227    }
1228
1229    // DAG-015 post-overlay: a literal output declared by an
1230    // overlay-attached task collides with a project-level
1231    // declaration on the same path.
1232
1233    #[test]
1234    fn overlay_output_collides_with_project_literal_output() {
1235        use haz_domain::path::OutputSpec;
1236
1237        let task_a = Task {
1238            name: task_name("a"),
1239            action: TaskAction::Command(NonEmpty::from_vec(vec!["true".to_owned()]).unwrap()),
1240            inputs: vec![],
1241            outputs: vec![OutputSpec::parse("dist/main.js").unwrap()],
1242            deps: vec![],
1243            weak_deps: vec![],
1244            mutex: None,
1245            env: EnvSettings::default(),
1246        };
1247        let p = Project {
1248            name: project_name("p"),
1249            root: haz_domain::path::ProjectRoot::Nested(
1250                haz_domain::path::CanonicalPath::from_absolute(
1251                    &haz_domain::path::HazPath::parse("/p").unwrap(),
1252                )
1253                .unwrap(),
1254            ),
1255            tags: BTreeSet::new(),
1256            tasks: BTreeMap::from([(task_a.name.clone(), task_a)]),
1257        };
1258
1259        let task_b = Task {
1260            name: task_name("b"),
1261            action: TaskAction::Command(NonEmpty::from_vec(vec!["true".to_owned()]).unwrap()),
1262            inputs: vec![],
1263            outputs: vec![OutputSpec::parse("dist/main.js").unwrap()],
1264            deps: vec![],
1265            weak_deps: vec![],
1266            mutex: None,
1267            env: EnvSettings::default(),
1268        };
1269        let lint = haz_domain::overlay::Overlay {
1270            name: overlay_name("lint"),
1271            matched_projects: BTreeSet::from([project_name("p")]),
1272            tasks: BTreeMap::from([(task_b.name.clone(), task_b)]),
1273        };
1274        let workspace = workspace_with_overlays(vec![p], vec![lint]);
1275
1276        let errors = build_task_graph(&workspace).unwrap_err();
1277        let collision_seen = errors.as_slice().iter().any(|e| {
1278            matches!(
1279                e,
1280                BuildError::LiteralOutputCollision { path, tasks }
1281                    if path == "/p/dist/main.js"
1282                        && tasks == &BTreeSet::from([id("p", "a"), id("p", "b")])
1283            )
1284        });
1285        assert!(
1286            collision_seen,
1287            "DAG-015 must see overlay-contributed outputs"
1288        );
1289    }
1290}