use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use haz_domain::name::{ProjectName, TaskName};
use haz_domain::project::Project;
use haz_domain::resolution::{ResolutionError, resolve_task_ref};
use haz_domain::task::Task;
use haz_domain::task_id::TaskId;
use haz_domain::task_ref::TaskRef;
use haz_domain::workspace::Workspace;
use snafu::Snafu;
use crate::edge::{Edge, EdgeKind};
use crate::effective::{
DefinitionSource, EffectiveTasksByProject, OverlayMergeError, compute_effective_tasks,
};
use crate::graph::TaskGraph;
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum BuildError {
#[snafu(display(
"task `{project}:{task}`: overlay-merge collision among {n} sources (DAG-006)",
n = sources.len()
))]
OverlayCollision {
project: ProjectName,
task: TaskName,
sources: BTreeSet<DefinitionSource>,
},
#[snafu(display("task `{bearing}`: reference `{reference}` is unresolved: {source}"))]
UnresolvedReference {
bearing: TaskId,
reference: TaskRef,
source: ResolutionError,
},
#[snafu(display(
"task `{bearing}`: reference `{reference}` resolves to the task itself (DAG-012)"
))]
SelfReference {
bearing: TaskId,
reference: TaskRef,
},
#[snafu(display(
"task `{bearing}`: `{overlap}` listed in both `deps` and `weakDeps` (DAG-010)"
))]
DepsWeakDepsOverlap {
bearing: TaskId,
overlap: TaskId,
},
#[snafu(display("cyclic dependency among {n} tasks (DAG-014)", n = nodes.len()))]
Cycle {
nodes: std::collections::BTreeSet<TaskId>,
},
#[snafu(display(
"literal output `{path}` declared by {n} tasks (DAG-015)",
n = tasks.len()
))]
LiteralOutputCollision {
path: String,
tasks: std::collections::BTreeSet<TaskId>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BuildErrors(Vec<BuildError>);
impl BuildErrors {
#[must_use]
pub fn as_slice(&self) -> &[BuildError] {
&self.0
}
#[must_use]
pub fn into_inner(self) -> Vec<BuildError> {
self.0
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl fmt::Display for BuildErrors {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let n = self.0.len();
writeln!(f, "{n} error(s) during DAG construction:")?;
for (i, err) in self.0.iter().enumerate() {
writeln!(f, " {}. {err}", i + 1)?;
}
Ok(())
}
}
impl std::error::Error for BuildErrors {}
pub fn build_task_graph(workspace: &Workspace) -> Result<(TaskGraph, Workspace), BuildErrors> {
let effective = match compute_effective_tasks(workspace) {
Ok(effective) => effective,
Err(overlay_errors) => {
let errors = overlay_errors
.into_inner()
.into_iter()
.map(|e| {
let OverlayMergeError::Collision {
project,
task,
sources,
} = e;
BuildError::OverlayCollision {
project,
task,
sources,
}
})
.collect();
return Err(BuildErrors(errors));
}
};
let workspace = apply_effective_tasks(workspace, &effective);
let mut errors: Vec<BuildError> = Vec::new();
let mut nodes: BTreeSet<TaskId> = BTreeSet::new();
let mut edges: BTreeSet<Edge> = BTreeSet::new();
for (project_name, project) in &workspace.projects {
for task_name in project.tasks.keys() {
let bearing_id = TaskId {
project: project_name.clone(),
task: task_name.clone(),
};
nodes.insert(bearing_id.clone());
let task = &project.tasks[task_name];
let hard_set = resolve_field(&task.deps, project, &workspace, &bearing_id, &mut errors);
let soft_set = resolve_field(
&task.weak_deps,
project,
&workspace,
&bearing_id,
&mut errors,
);
for overlap_id in hard_set.intersection(&soft_set) {
errors.push(BuildError::DepsWeakDepsOverlap {
bearing: bearing_id.clone(),
overlap: overlap_id.clone(),
});
}
for source in &hard_set {
edges.insert(Edge {
from: source.clone(),
to: bearing_id.clone(),
kind: EdgeKind::Hard,
});
}
for source in &soft_set {
edges.insert(Edge {
from: source.clone(),
to: bearing_id.clone(),
kind: EdgeKind::Soft,
});
}
}
}
edges.extend(crate::producer::compute_producer_edges(&workspace));
let provisional = TaskGraph {
nodes: nodes.clone(),
edges: edges.clone(),
};
for scc_nodes in crate::cycles::detect_cycles(&provisional) {
errors.push(BuildError::Cycle { nodes: scc_nodes });
}
for collision in crate::outputs::detect_literal_output_collisions(&workspace) {
errors.push(BuildError::LiteralOutputCollision {
path: collision.path,
tasks: collision.tasks,
});
}
if errors.is_empty() {
Ok((TaskGraph { nodes, edges }, workspace))
} else {
Err(BuildErrors(errors))
}
}
fn apply_effective_tasks(workspace: &Workspace, effective: &EffectiveTasksByProject) -> Workspace {
let projects: BTreeMap<ProjectName, Project> = workspace
.projects
.iter()
.map(|(name, project)| {
let tasks: BTreeMap<TaskName, Task> = effective.get(name).cloned().unwrap_or_default();
(
name.clone(),
Project {
name: project.name.clone(),
root: project.root.clone(),
tags: project.tags.clone(),
tasks,
},
)
})
.collect();
Workspace {
root: workspace.root.clone(),
projects,
overlays: workspace.overlays.clone(),
settings: workspace.settings.clone(),
}
}
fn resolve_field(
field_refs: &[TaskRef],
bearing_project: &Project,
workspace: &Workspace,
bearing_id: &TaskId,
errors: &mut Vec<BuildError>,
) -> BTreeSet<TaskId> {
let mut result: BTreeSet<TaskId> = BTreeSet::new();
for reference in field_refs {
match resolve_task_ref(reference, bearing_project, workspace) {
Ok(resolved) => {
if resolved.contains(bearing_id) {
errors.push(BuildError::SelfReference {
bearing: bearing_id.clone(),
reference: reference.clone(),
});
continue;
}
result.extend(resolved);
}
Err(source) => {
errors.push(BuildError::UnresolvedReference {
bearing: bearing_id.clone(),
reference: reference.clone(),
source,
});
}
}
}
result
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::str::FromStr;
use haz_domain::action::TaskAction;
use haz_domain::env::EnvSettings;
use haz_domain::name::{ProjectName, TagName, TaskName};
use haz_domain::path::{CanonicalPath, HazPath, ProjectRoot, WorkspaceRootPath};
use haz_domain::project::Project;
use haz_domain::resolution::ResolutionError;
use haz_domain::settings::WorkspaceSettings;
use haz_domain::task::Task;
use haz_domain::task_id::TaskId;
use haz_domain::task_ref::TaskRef;
use haz_domain::workspace::Workspace;
use nonempty::NonEmpty;
use crate::construction::{BuildError, build_task_graph};
use crate::edge::{Edge, EdgeKind};
use crate::effective::DefinitionSource;
fn project_name(s: &str) -> ProjectName {
ProjectName::from_str(s).unwrap()
}
fn tag_name(s: &str) -> TagName {
TagName::from_str(s).unwrap()
}
fn task_name(s: &str) -> TaskName {
TaskName::from_str(s).unwrap()
}
fn task_with(name: &str, deps: &[&str], weak_deps: &[&str]) -> Task {
Task {
name: task_name(name),
action: TaskAction::Command(NonEmpty::from_vec(vec!["true".to_owned()]).unwrap()),
inputs: vec![],
outputs: vec![],
deps: deps.iter().map(|r| TaskRef::parse(r).unwrap()).collect(),
weak_deps: weak_deps
.iter()
.map(|r| TaskRef::parse(r).unwrap())
.collect(),
mutex: None,
env: EnvSettings::default(),
}
}
fn task_with_mutex(name: &str, mutex_name: &str) -> Task {
use haz_domain::mutex::{Mutex, MutexMode, MutexScope};
use haz_domain::name::MutexName;
Task {
name: task_name(name),
action: TaskAction::Command(NonEmpty::from_vec(vec!["true".to_owned()]).unwrap()),
inputs: vec![],
outputs: vec![],
deps: vec![],
weak_deps: vec![],
mutex: Some(Mutex {
scope: MutexScope::Workspace,
name: MutexName::from_str(mutex_name).unwrap(),
mode: MutexMode::Exclusive,
}),
env: EnvSettings::default(),
}
}
fn project_with(name: &str, root: &str, tags: &[&str], tasks: Vec<Task>) -> Project {
Project {
name: project_name(name),
root: ProjectRoot::Nested(
CanonicalPath::from_absolute(&HazPath::parse(root).unwrap()).unwrap(),
),
tags: tags.iter().map(|t| tag_name(t)).collect(),
tasks: tasks.into_iter().map(|t| (t.name.clone(), t)).collect(),
}
}
fn workspace_with(projects: Vec<Project>) -> Workspace {
let mut map = BTreeMap::new();
for project in projects {
map.insert(project.name.clone(), project);
}
Workspace {
root: WorkspaceRootPath::try_new(PathBuf::from("/abs/ws")).unwrap(),
projects: map,
overlays: BTreeMap::new(),
settings: WorkspaceSettings::default(),
}
}
fn id(project: &str, task: &str) -> TaskId {
TaskId {
project: project_name(project),
task: task_name(task),
}
}
fn hard(from: TaskId, to: TaskId) -> Edge {
Edge {
from,
to,
kind: EdgeKind::Hard,
}
}
fn soft(from: TaskId, to: TaskId) -> Edge {
Edge {
from,
to,
kind: EdgeKind::Soft,
}
}
#[test]
fn dag_007_nodes_are_every_project_task_pair() {
let p = project_with(
"p",
"/p",
&[],
vec![task_with("a", &[], &[]), task_with("b", &[], &[])],
);
let q = project_with("q", "/q", &[], vec![task_with("a", &[], &[])]);
let workspace = workspace_with(vec![p, q]);
let (graph, _) = build_task_graph(&workspace).unwrap();
assert_eq!(
graph.nodes,
BTreeSet::from([id("p", "a"), id("p", "b"), id("q", "a")])
);
assert!(graph.edges.is_empty());
}
#[test]
fn empty_workspace_is_empty_graph() {
let workspace = workspace_with(vec![]);
let (graph, _) = build_task_graph(&workspace).unwrap();
assert!(graph.nodes.is_empty());
assert!(graph.edges.is_empty());
}
#[test]
fn dag_008_same_project_dep_adds_one_hard_edge() {
let p = project_with(
"p",
"/p",
&[],
vec![
task_with("other", &[], &[]),
task_with("bearing", &["~:other"], &[]),
],
);
let workspace = workspace_with(vec![p]);
let (graph, _) = build_task_graph(&workspace).unwrap();
assert_eq!(
graph.edges,
BTreeSet::from([hard(id("p", "other"), id("p", "bearing"))])
);
}
#[test]
fn dag_008_fan_out_dep_adds_hard_edges_to_every_matching_project() {
let a = project_with("a", "/a", &[], vec![task_with("compile", &[], &[])]);
let b = project_with("b", "/b", &[], vec![task_with("compile", &[], &[])]);
let bearing = project_with(
"z",
"/z",
&[],
vec![task_with("build", &["^:compile"], &[])],
);
let workspace = workspace_with(vec![a, b, bearing]);
let (graph, _) = build_task_graph(&workspace).unwrap();
assert!(
graph
.edges
.contains(&hard(id("a", "compile"), id("z", "build")))
);
assert!(
graph
.edges
.contains(&hard(id("b", "compile"), id("z", "build")))
);
assert_eq!(
graph
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Hard)
.count(),
2
);
}
#[test]
fn dag_009_same_project_weak_dep_adds_one_soft_edge() {
let p = project_with(
"p",
"/p",
&[],
vec![
task_with("gen", &[], &[]),
task_with("build", &[], &["~:gen"]),
],
);
let workspace = workspace_with(vec![p]);
let (graph, _) = build_task_graph(&workspace).unwrap();
assert_eq!(
graph.edges,
BTreeSet::from([soft(id("p", "gen"), id("p", "build"))])
);
}
#[test]
fn deps_and_weak_deps_to_different_nodes_coexist() {
let p = project_with(
"p",
"/p",
&[],
vec![
task_with("compile", &[], &[]),
task_with("gen", &[], &[]),
task_with("build", &["~:compile"], &["~:gen"]),
],
);
let workspace = workspace_with(vec![p]);
let (graph, _) = build_task_graph(&workspace).unwrap();
assert!(
graph
.edges
.contains(&hard(id("p", "compile"), id("p", "build")))
);
assert!(
graph
.edges
.contains(&soft(id("p", "gen"), id("p", "build")))
);
assert_eq!(graph.edges.len(), 2);
}
#[test]
fn dag_010_same_node_in_deps_and_weak_deps_is_rejected() {
let p = project_with(
"p",
"/p",
&[],
vec![
task_with("other", &[], &[]),
task_with("bearing", &["~:other"], &["~:other"]),
],
);
let workspace = workspace_with(vec![p]);
let errors = build_task_graph(&workspace).unwrap_err();
let bearing = id("p", "bearing");
let other = id("p", "other");
assert!(errors.as_slice().iter().any(|e| matches!(
e,
BuildError::DepsWeakDepsOverlap { bearing: b, overlap: o }
if b == &bearing && o == &other
)));
}
#[test]
fn dag_010_overlap_detected_through_different_ref_forms() {
let p = project_with(
"p",
"/p",
&[],
vec![
task_with("compile", &[], &[]),
task_with("bearing", &["^:compile"], &["p:compile"]),
],
);
let workspace = workspace_with(vec![p]);
let errors = build_task_graph(&workspace).unwrap_err();
assert!(errors.as_slice().iter().any(|e| matches!(
e,
BuildError::DepsWeakDepsOverlap { overlap, .. } if overlap == &id("p", "compile")
)));
}
#[test]
fn dag_011_multiple_errors_across_multiple_tasks_are_all_reported() {
let p = project_with(
"p",
"/p",
&[],
vec![
task_with("a", &["~:ghost1"], &[]),
task_with("b", &["~:ghost2"], &[]),
],
);
let q = project_with(
"q",
"/q",
&[],
vec![task_with("a", &["nonexistent:thing"], &[])],
);
let workspace = workspace_with(vec![p, q]);
let errors = build_task_graph(&workspace).unwrap_err();
assert_eq!(errors.len(), 3, "all three errors must be accumulated");
let ghost1_seen = errors.as_slice().iter().any(|e| matches!(
e,
BuildError::UnresolvedReference { source: ResolutionError::BearingTaskNotFound { task }, .. }
if task == &task_name("ghost1")
));
let ghost2_seen = errors.as_slice().iter().any(|e| matches!(
e,
BuildError::UnresolvedReference { source: ResolutionError::BearingTaskNotFound { task }, .. }
if task == &task_name("ghost2")
));
let nonexistent_seen = errors.as_slice().iter().any(|e| matches!(
e,
BuildError::UnresolvedReference { source: ResolutionError::ProjectNotFound { project }, .. }
if project == &project_name("nonexistent")
));
assert!(ghost1_seen && ghost2_seen && nonexistent_seen);
}
#[test]
fn dag_012_same_project_self_reference_rejected() {
let p = project_with("p", "/p", &[], vec![task_with("loop", &["~:loop"], &[])]);
let workspace = workspace_with(vec![p]);
let errors = build_task_graph(&workspace).unwrap_err();
assert_eq!(errors.len(), 1);
assert!(matches!(
errors.as_slice()[0],
BuildError::SelfReference { .. }
));
}
#[test]
fn dag_012_project_ref_self_reference_rejected() {
let p = project_with("p", "/p", &[], vec![task_with("loop", &["p:loop"], &[])]);
let workspace = workspace_with(vec![p]);
let errors = build_task_graph(&workspace).unwrap_err();
assert!(matches!(
errors.as_slice()[0],
BuildError::SelfReference { .. }
));
}
#[test]
fn dag_012_fan_out_self_reference_rejects_whole_reference() {
let a = project_with("a", "/a", &[], vec![task_with("compile", &[], &[])]);
let bearing = project_with(
"z",
"/z",
&[],
vec![task_with("compile", &["^:compile"], &[])],
);
let workspace = workspace_with(vec![a, bearing]);
let errors = build_task_graph(&workspace).unwrap_err();
assert!(
errors
.as_slice()
.iter()
.any(|e| matches!(e, BuildError::SelfReference { .. }))
);
}
#[test]
fn mutex_009_shared_mutex_adds_no_edges_between_declaring_tasks() {
let p = project_with(
"p",
"/p",
&[],
vec![task_with_mutex("a", "db"), task_with_mutex("b", "db")],
);
let workspace = workspace_with(vec![p]);
let (graph, _) = build_task_graph(&workspace).unwrap();
assert_eq!(graph.nodes.len(), 2);
assert!(
graph.edges.is_empty(),
"MUTEX-009: mutex declarations MUST NOT introduce edges; \
got {:?}",
graph.edges,
);
assert!(
crate::cycles::detect_cycles(&graph).is_empty(),
"MUTEX-009: shared mutex MUST NOT register as a DAG-014 cycle",
);
}
#[test]
fn mutex_009_cross_project_shared_mutex_adds_no_edges() {
let a = project_with("a", "/a", &[], vec![task_with_mutex("migrate", "db")]);
let b = project_with("b", "/b", &[], vec![task_with_mutex("migrate", "db")]);
let workspace = workspace_with(vec![a, b]);
let (graph, _) = build_task_graph(&workspace).unwrap();
assert_eq!(graph.nodes.len(), 2);
assert!(
graph.edges.is_empty(),
"MUTEX-009: cross-project shared mutex MUST NOT add edges; \
got {:?}",
graph.edges,
);
assert!(crate::cycles::detect_cycles(&graph).is_empty());
}
#[test]
fn dag_012_sibling_fan_out_does_not_trigger_self_reference() {
let a = project_with("a", "/a", &[], vec![task_with("compile", &[], &[])]);
let bearing = project_with(
"z",
"/z",
&[],
vec![task_with("compile", &["^~:compile"], &[])],
);
let workspace = workspace_with(vec![a, bearing]);
let (graph, _) = build_task_graph(&workspace).unwrap();
assert!(
graph
.edges
.contains(&hard(id("a", "compile"), id("z", "compile")))
);
assert_eq!(graph.edges.len(), 1);
}
#[test]
fn dag_012_tag_self_reference_rejected() {
let a = project_with("a", "/a", &["t"], vec![task_with("compile", &[], &[])]);
let bearing = project_with(
"z",
"/z",
&["t"],
vec![task_with("compile", &["[t]:compile"], &[])],
);
let workspace = workspace_with(vec![a, bearing]);
let errors = build_task_graph(&workspace).unwrap_err();
assert!(
errors
.as_slice()
.iter()
.any(|e| matches!(e, BuildError::SelfReference { .. }))
);
}
#[test]
fn errors_appear_in_project_then_task_order() {
let p = project_with(
"p",
"/p",
&[],
vec![
task_with("a", &["~:ghost"], &[]),
task_with("b", &["~:ghost"], &[]),
],
);
let q = project_with("q", "/q", &[], vec![task_with("a", &["~:ghost"], &[])]);
let workspace = workspace_with(vec![p, q]);
let errors = build_task_graph(&workspace).unwrap_err();
let bearings: Vec<TaskId> = errors
.as_slice()
.iter()
.map(|e| match e {
BuildError::UnresolvedReference { bearing, .. }
| BuildError::SelfReference { bearing, .. }
| BuildError::DepsWeakDepsOverlap { bearing, .. } => bearing.clone(),
BuildError::Cycle { .. }
| BuildError::LiteralOutputCollision { .. }
| BuildError::OverlayCollision { .. } => {
unreachable!("no cycle/collision/overlay errors in this workspace")
}
})
.collect();
assert_eq!(bearings, vec![id("p", "a"), id("p", "b"), id("q", "a")]);
}
#[test]
fn build_errors_display_renders_count_and_list() {
let p = project_with("p", "/p", &[], vec![task_with("a", &["~:loop_self"], &[])]);
let workspace = workspace_with(vec![p]);
let errors = build_task_graph(&workspace).unwrap_err();
let rendered = errors.to_string();
assert!(rendered.starts_with("1 error(s) during DAG construction:"));
assert!(rendered.contains("1. "));
}
#[test]
fn dag_014_mutual_hard_deps_form_a_cycle() {
let p = project_with(
"p",
"/p",
&[],
vec![task_with("a", &["~:b"], &[]), task_with("b", &["~:a"], &[])],
);
let workspace = workspace_with(vec![p]);
let errors = build_task_graph(&workspace).unwrap_err();
let cycle_seen = errors.as_slice().iter().any(|e| {
matches!(
e,
BuildError::Cycle { nodes }
if nodes == &BTreeSet::from([id("p", "a"), id("p", "b")])
)
});
assert!(cycle_seen, "expected a Cycle error covering {{a, b}}");
}
#[test]
fn dag_014_three_task_cycle_is_detected() {
let p = project_with(
"p",
"/p",
&[],
vec![
task_with("a", &["~:b"], &[]),
task_with("b", &["~:c"], &[]),
task_with("c", &["~:a"], &[]),
],
);
let workspace = workspace_with(vec![p]);
let errors = build_task_graph(&workspace).unwrap_err();
let cycle_seen = errors.as_slice().iter().any(|e| {
matches!(
e,
BuildError::Cycle { nodes }
if nodes == &BTreeSet::from([id("p", "a"), id("p", "b"), id("p", "c")])
)
});
assert!(cycle_seen);
}
#[test]
fn dag_014_cycle_through_hard_and_soft_is_detected() {
let p = project_with(
"p",
"/p",
&[],
vec![task_with("a", &["~:b"], &[]), task_with("b", &[], &["~:a"])],
);
let workspace = workspace_with(vec![p]);
let errors = build_task_graph(&workspace).unwrap_err();
assert!(
errors
.as_slice()
.iter()
.any(|e| matches!(e, BuildError::Cycle { .. }))
);
}
#[test]
fn dag_014_acyclic_workspace_succeeds() {
let p = project_with(
"p",
"/p",
&[],
vec![
task_with("a", &[], &[]),
task_with("b", &["~:a"], &[]),
task_with("c", &["~:b"], &[]),
],
);
let workspace = workspace_with(vec![p]);
let (graph, _) = build_task_graph(&workspace).unwrap();
assert_eq!(graph.nodes.len(), 3);
assert!(graph.edges.contains(&hard(id("p", "a"), id("p", "b"))));
assert!(graph.edges.contains(&hard(id("p", "b"), id("p", "c"))));
}
#[test]
fn dag_015_two_tasks_same_literal_output_is_rejected() {
use haz_domain::path::OutputSpec;
let mut p_tasks = std::collections::BTreeMap::new();
let task_a = Task {
name: task_name("a"),
action: TaskAction::Command(NonEmpty::from_vec(vec!["true".to_owned()]).unwrap()),
inputs: vec![],
outputs: vec![OutputSpec::parse("dist/main.js").unwrap()],
deps: vec![],
weak_deps: vec![],
mutex: None,
env: EnvSettings::default(),
};
let task_b = Task {
name: task_name("b"),
action: TaskAction::Command(NonEmpty::from_vec(vec!["true".to_owned()]).unwrap()),
inputs: vec![],
outputs: vec![OutputSpec::parse("dist/main.js").unwrap()],
deps: vec![],
weak_deps: vec![],
mutex: None,
env: EnvSettings::default(),
};
p_tasks.insert(task_a.name.clone(), task_a);
p_tasks.insert(task_b.name.clone(), task_b);
let p = Project {
name: project_name("p"),
root: haz_domain::path::ProjectRoot::Nested(
haz_domain::path::CanonicalPath::from_absolute(
&haz_domain::path::HazPath::parse("/p").unwrap(),
)
.unwrap(),
),
tags: BTreeSet::new(),
tasks: p_tasks,
};
let workspace = workspace_with(vec![p]);
let errors = build_task_graph(&workspace).unwrap_err();
let collision_seen = errors.as_slice().iter().any(|e| {
matches!(
e,
BuildError::LiteralOutputCollision { path, tasks }
if path == "/p/dist/main.js"
&& tasks == &BTreeSet::from([id("p", "a"), id("p", "b")])
)
});
assert!(collision_seen);
}
fn overlay_name(s: &str) -> haz_domain::name::OverlayName {
haz_domain::name::OverlayName::from_str(s).unwrap()
}
fn overlay_with(
name: &str,
matched_projects: &[&str],
tasks: Vec<Task>,
) -> haz_domain::overlay::Overlay {
haz_domain::overlay::Overlay {
name: overlay_name(name),
matched_projects: matched_projects.iter().map(|p| project_name(p)).collect(),
tasks: tasks.into_iter().map(|t| (t.name.clone(), t)).collect(),
}
}
fn workspace_with_overlays(
projects: Vec<Project>,
overlays: Vec<haz_domain::overlay::Overlay>,
) -> Workspace {
Workspace {
root: WorkspaceRootPath::try_new(PathBuf::from("/abs/ws")).unwrap(),
projects: projects.into_iter().map(|p| (p.name.clone(), p)).collect(),
overlays: overlays.into_iter().map(|o| (o.name.clone(), o)).collect(),
settings: WorkspaceSettings::default(),
}
}
#[test]
fn overlay_attached_task_appears_as_a_node() {
let p = project_with("p", "/p", &[], vec![task_with("build", &[], &[])]);
let lint = overlay_with("lint", &["p"], vec![task_with("lint", &[], &[])]);
let workspace = workspace_with_overlays(vec![p], vec![lint]);
let (graph, _) = build_task_graph(&workspace).unwrap();
assert!(graph.nodes.contains(&id("p", "build")));
assert!(graph.nodes.contains(&id("p", "lint")));
assert_eq!(graph.nodes.len(), 2);
}
#[test]
fn overlay_task_with_dep_on_project_task_emits_hard_edge() {
let p = project_with("p", "/p", &[], vec![task_with("build", &[], &[])]);
let lint = overlay_with("lint", &["p"], vec![task_with("lint", &["~:build"], &[])]);
let workspace = workspace_with_overlays(vec![p], vec![lint]);
let (graph, _) = build_task_graph(&workspace).unwrap();
assert!(
graph
.edges
.contains(&hard(id("p", "build"), id("p", "lint")))
);
assert_eq!(graph.edges.len(), 1);
}
#[test]
fn project_task_can_reference_overlay_contributed_sibling() {
let p = project_with("p", "/p", &[], vec![task_with("build", &["~:lint"], &[])]);
let lint = overlay_with("lint", &["p"], vec![task_with("lint", &[], &[])]);
let workspace = workspace_with_overlays(vec![p], vec![lint]);
let (graph, _) = build_task_graph(&workspace).unwrap();
assert!(
graph
.edges
.contains(&hard(id("p", "lint"), id("p", "build")))
);
}
#[test]
fn dag_006_overlay_collision_propagates_as_build_error() {
let p = project_with("p", "/p", &[], vec![]);
let q = project_with("q", "/q", &[], vec![]);
let r = project_with("r", "/r", &[], vec![]);
let pq = overlay_with("pq", &["p", "q"], vec![task_with("lint", &[], &[])]);
let pr = overlay_with("pr", &["p", "r"], vec![task_with("lint", &[], &[])]);
let workspace = workspace_with_overlays(vec![p, q, r], vec![pq, pr]);
let errors = build_task_graph(&workspace).unwrap_err();
let collision_seen = errors.as_slice().iter().any(|e| {
matches!(
e,
BuildError::OverlayCollision { project, task, sources }
if project == &project_name("p")
&& task == &task_name("lint")
&& sources.contains(&DefinitionSource::Overlay { name: overlay_name("pq") })
&& sources.contains(&DefinitionSource::Overlay { name: overlay_name("pr") })
)
});
assert!(
collision_seen,
"expected `OverlayCollision` carrying both overlays as sources"
);
}
#[test]
fn overlay_collision_short_circuits_downstream_phases() {
let p = project_with("p", "/p", &[], vec![task_with("a", &["~:ghost"], &[])]);
let q = project_with("q", "/q", &[], vec![]);
let pq = overlay_with("pq", &["p", "q"], vec![task_with("lint", &[], &[])]);
let qp = overlay_with("qp", &["p", "q"], vec![task_with("lint", &[], &[])]);
let workspace = workspace_with_overlays(vec![p, q], vec![pq, qp]);
let errors = build_task_graph(&workspace).unwrap_err();
assert!(
errors
.as_slice()
.iter()
.all(|e| matches!(e, BuildError::OverlayCollision { .. })),
"downstream errors must NOT surface alongside overlay collisions"
);
}
#[test]
fn overlay_output_collides_with_project_literal_output() {
use haz_domain::path::OutputSpec;
let task_a = Task {
name: task_name("a"),
action: TaskAction::Command(NonEmpty::from_vec(vec!["true".to_owned()]).unwrap()),
inputs: vec![],
outputs: vec![OutputSpec::parse("dist/main.js").unwrap()],
deps: vec![],
weak_deps: vec![],
mutex: None,
env: EnvSettings::default(),
};
let p = Project {
name: project_name("p"),
root: haz_domain::path::ProjectRoot::Nested(
haz_domain::path::CanonicalPath::from_absolute(
&haz_domain::path::HazPath::parse("/p").unwrap(),
)
.unwrap(),
),
tags: BTreeSet::new(),
tasks: BTreeMap::from([(task_a.name.clone(), task_a)]),
};
let task_b = Task {
name: task_name("b"),
action: TaskAction::Command(NonEmpty::from_vec(vec!["true".to_owned()]).unwrap()),
inputs: vec![],
outputs: vec![OutputSpec::parse("dist/main.js").unwrap()],
deps: vec![],
weak_deps: vec![],
mutex: None,
env: EnvSettings::default(),
};
let lint = haz_domain::overlay::Overlay {
name: overlay_name("lint"),
matched_projects: BTreeSet::from([project_name("p")]),
tasks: BTreeMap::from([(task_b.name.clone(), task_b)]),
};
let workspace = workspace_with_overlays(vec![p], vec![lint]);
let errors = build_task_graph(&workspace).unwrap_err();
let collision_seen = errors.as_slice().iter().any(|e| {
matches!(
e,
BuildError::LiteralOutputCollision { path, tasks }
if path == "/p/dist/main.js"
&& tasks == &BTreeSet::from([id("p", "a"), id("p", "b")])
)
});
assert!(
collision_seen,
"DAG-015 must see overlay-contributed outputs"
);
}
}