1use 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#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
58pub enum BuildError {
59 #[snafu(display(
64 "task `{project}:{task}`: overlay-merge collision among {n} sources (DAG-006)",
65 n = sources.len()
66 ))]
67 OverlayCollision {
68 project: ProjectName,
70 task: TaskName,
72 sources: BTreeSet<DefinitionSource>,
74 },
75
76 #[snafu(display("task `{bearing}`: reference `{reference}` is unresolved: {source}"))]
80 UnresolvedReference {
81 bearing: TaskId,
84 reference: TaskRef,
86 source: ResolutionError,
88 },
89
90 #[snafu(display(
98 "task `{bearing}`: reference `{reference}` resolves to the task itself (DAG-012)"
99 ))]
100 SelfReference {
101 bearing: TaskId,
103 reference: TaskRef,
105 },
106
107 #[snafu(display(
112 "task `{bearing}`: `{overlap}` listed in both `deps` and `weakDeps` (DAG-010)"
113 ))]
114 DepsWeakDepsOverlap {
115 bearing: TaskId,
117 overlap: TaskId,
119 },
120
121 #[snafu(display("cyclic dependency among {n} tasks (DAG-014)", n = nodes.len()))]
128 Cycle {
129 nodes: std::collections::BTreeSet<TaskId>,
132 },
133
134 #[snafu(display(
139 "literal output `{path}` declared by {n} tasks (DAG-015)",
140 n = tasks.len()
141 ))]
142 LiteralOutputCollision {
143 path: String,
146 tasks: std::collections::BTreeSet<TaskId>,
148 },
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct BuildErrors(Vec<BuildError>);
162
163impl BuildErrors {
164 #[must_use]
166 pub fn as_slice(&self) -> &[BuildError] {
167 &self.0
168 }
169
170 #[must_use]
172 pub fn into_inner(self) -> Vec<BuildError> {
173 self.0
174 }
175
176 #[must_use]
179 pub fn len(&self) -> usize {
180 self.0.len()
181 }
182
183 #[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
204pub fn build_task_graph(workspace: &Workspace) -> Result<(TaskGraph, Workspace), BuildErrors> {
231 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 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 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 edges.extend(crate::producer::compute_producer_edges(&workspace));
319
320 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 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
347fn 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
381fn 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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 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 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 #[test]
1038 fn dag_015_two_tasks_same_literal_output_is_rejected() {
1039 use haz_domain::path::OutputSpec;
1040
1041 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 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 #[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 #[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 #[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 #[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 #[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 #[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}