use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use haz_domain::name::{OverlayName, ProjectName, TaskName};
use haz_domain::overlay::Overlay;
use haz_domain::task::Task;
use haz_domain::workspace::Workspace;
use snafu::Snafu;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct EffectiveTasksByProject(BTreeMap<ProjectName, BTreeMap<TaskName, Task>>);
impl EffectiveTasksByProject {
#[must_use]
pub fn get(&self, project: &ProjectName) -> Option<&BTreeMap<TaskName, Task>> {
self.0.get(project)
}
pub fn iter(&self) -> impl Iterator<Item = (&ProjectName, &BTreeMap<TaskName, Task>)> {
self.0.iter()
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn into_inner(self) -> BTreeMap<ProjectName, BTreeMap<TaskName, Task>> {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum DefinitionSource {
ProjectLevel {
project: ProjectName,
},
Overlay {
name: OverlayName,
},
}
impl fmt::Display for DefinitionSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DefinitionSource::ProjectLevel { project } => {
write!(f, "project-level `{project}/haz.yml`")
}
DefinitionSource::Overlay { name } => write!(f, "overlay `{name}.yml`"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum OverlayMergeError {
#[snafu(display(
"task `{project}:{task}`: overlay-merge collision among {n} sources (DAG-006)",
n = sources.len()
))]
Collision {
project: ProjectName,
task: TaskName,
sources: BTreeSet<DefinitionSource>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OverlayMergeErrors(Vec<OverlayMergeError>);
impl OverlayMergeErrors {
#[must_use]
pub fn as_slice(&self) -> &[OverlayMergeError] {
&self.0
}
#[must_use]
pub fn into_inner(self) -> Vec<OverlayMergeError> {
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 OverlayMergeErrors {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let n = self.0.len();
writeln!(f, "{n} error(s) during overlay merging:")?;
for (i, err) in self.0.iter().enumerate() {
writeln!(f, " {}. {err}", i + 1)?;
}
Ok(())
}
}
impl std::error::Error for OverlayMergeErrors {}
pub fn compute_effective_tasks(
workspace: &Workspace,
) -> Result<EffectiveTasksByProject, OverlayMergeErrors> {
let mut result: BTreeMap<ProjectName, BTreeMap<TaskName, Task>> = BTreeMap::new();
let mut errors: Vec<OverlayMergeError> = Vec::new();
for (project_name, project) in &workspace.projects {
let attached_overlays: Vec<&Overlay> = workspace
.overlays
.values()
.filter(|o| o.matched_projects.contains(project_name))
.collect();
let mut task_names: BTreeSet<&TaskName> = project.tasks.keys().collect();
for overlay in &attached_overlays {
for n in overlay.tasks.keys() {
task_names.insert(n);
}
}
let mut effective: BTreeMap<TaskName, Task> = BTreeMap::new();
for task_name in task_names {
let project_level_body = project.tasks.get(task_name);
let overlay_sources: Vec<&Overlay> = attached_overlays
.iter()
.copied()
.filter(|o| o.tasks.contains_key(task_name))
.collect();
if let Some(body) = pick_winner(project_level_body, &overlay_sources, task_name) {
effective.insert(task_name.clone(), body.clone());
} else {
let mut sources: BTreeSet<DefinitionSource> = overlay_sources
.iter()
.map(|o| DefinitionSource::Overlay {
name: o.name.clone(),
})
.collect();
if project_level_body.is_some() {
sources.insert(DefinitionSource::ProjectLevel {
project: project_name.clone(),
});
}
errors.push(OverlayMergeError::Collision {
project: project_name.clone(),
task: task_name.clone(),
sources,
});
}
}
result.insert(project_name.clone(), effective);
}
if errors.is_empty() {
Ok(EffectiveTasksByProject(result))
} else {
Err(OverlayMergeErrors(errors))
}
}
fn pick_winner<'a>(
project_level: Option<&'a Task>,
overlays: &[&'a Overlay],
task_name: &TaskName,
) -> Option<&'a Task> {
if let Some(body) = project_level {
return Some(body);
}
match overlays.len() {
0 => None,
1 => overlays[0].tasks.get(task_name),
_ => {
let winners: Vec<&Overlay> = overlays
.iter()
.copied()
.filter(|x| {
overlays.iter().copied().all(|y| {
x.name == y.name
|| (x.matched_projects.is_subset(&y.matched_projects)
&& x.matched_projects != y.matched_projects)
})
})
.collect();
if winners.len() == 1 {
winners[0].tasks.get(task_name)
} else {
None
}
}
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::str::FromStr;
use haz_domain::action::TaskAction;
use haz_domain::env::EnvSettings;
use haz_domain::name::{OverlayName, ProjectName, TaskName};
use haz_domain::overlay::Overlay;
use haz_domain::path::{CanonicalPath, HazPath, ProjectRoot, WorkspaceRootPath};
use haz_domain::project::Project;
use haz_domain::settings::WorkspaceSettings;
use haz_domain::task::Task;
use haz_domain::workspace::Workspace;
use nonempty::NonEmpty;
use super::{DefinitionSource, OverlayMergeError, compute_effective_tasks, pick_winner};
fn project_name(s: &str) -> ProjectName {
ProjectName::from_str(s).unwrap()
}
fn task_name(s: &str) -> TaskName {
TaskName::from_str(s).unwrap()
}
fn overlay_name(s: &str) -> OverlayName {
OverlayName::from_str(s).unwrap()
}
fn task_with_marker(name: &str, marker: &str) -> Task {
Task {
name: task_name(name),
action: TaskAction::Command(
NonEmpty::from_vec(vec!["echo".to_owned(), marker.to_owned()]).unwrap(),
),
inputs: vec![],
outputs: vec![],
deps: vec![],
weak_deps: vec![],
mutex: None,
env: EnvSettings::default(),
}
}
fn project_with(name: &str, root: &str, tasks: Vec<Task>) -> Project {
Project {
name: project_name(name),
root: ProjectRoot::Nested(
CanonicalPath::from_absolute(&HazPath::parse(root).unwrap()).unwrap(),
),
tags: BTreeSet::new(),
tasks: tasks.into_iter().map(|t| (t.name.clone(), t)).collect(),
}
}
fn overlay_with(name: &str, matched_projects: &[&str], tasks: Vec<Task>) -> 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(projects: Vec<Project>, overlays: Vec<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(),
}
}
fn marker_of(task: &Task) -> &str {
match &task.action {
TaskAction::Command(parts) => parts[1].as_str(),
TaskAction::Shell { .. } => panic!("expected Command action with two parts"),
}
}
#[test]
fn dag_002_no_overlays_returns_project_tasks_unchanged() {
let p = project_with("p", "/p", vec![task_with_marker("build", "p-build")]);
let q = project_with(
"q",
"/q",
vec![
task_with_marker("build", "q-build"),
task_with_marker("test", "q-test"),
],
);
let workspace = workspace_with(vec![p, q], vec![]);
let effective = compute_effective_tasks(&workspace).unwrap();
assert_eq!(effective.len(), 2);
let p_tasks = effective.get(&project_name("p")).unwrap();
let q_tasks = effective.get(&project_name("q")).unwrap();
assert_eq!(p_tasks.len(), 1);
assert_eq!(q_tasks.len(), 2);
assert_eq!(marker_of(&p_tasks[&task_name("build")]), "p-build");
assert_eq!(marker_of(&q_tasks[&task_name("test")]), "q-test");
}
#[test]
fn empty_workspace_returns_empty_effective_tasks() {
let workspace = workspace_with(vec![], vec![]);
let effective = compute_effective_tasks(&workspace).unwrap();
assert!(effective.is_empty());
}
#[test]
fn dag_002_overlay_attaches_task_only_to_matched_projects() {
let p = project_with("p", "/p", vec![]);
let q = project_with("q", "/q", vec![]);
let r = project_with("r", "/r", vec![]);
let lint = overlay_with(
"lint",
&["p", "r"],
vec![task_with_marker("lint", "lint-body")],
);
let workspace = workspace_with(vec![p, q, r], vec![lint]);
let effective = compute_effective_tasks(&workspace).unwrap();
assert!(
effective
.get(&project_name("p"))
.unwrap()
.contains_key(&task_name("lint"))
);
assert!(
effective
.get(&project_name("r"))
.unwrap()
.contains_key(&task_name("lint"))
);
assert!(
effective.get(&project_name("q")).unwrap().is_empty(),
"overlay must NOT attach to unmatched project q"
);
}
#[test]
fn dag_002_overlay_only_task_appears_in_effective_set_for_matched_project() {
let p = project_with("p", "/p", vec![task_with_marker("build", "p-build")]);
let lint = overlay_with("lint", &["p"], vec![task_with_marker("lint", "lint-body")]);
let workspace = workspace_with(vec![p], vec![lint]);
let effective = compute_effective_tasks(&workspace).unwrap();
let p_tasks = effective.get(&project_name("p")).unwrap();
assert_eq!(p_tasks.len(), 2);
assert_eq!(marker_of(&p_tasks[&task_name("build")]), "p-build");
assert_eq!(marker_of(&p_tasks[&task_name("lint")]), "lint-body");
}
#[test]
fn dag_004_single_overlay_source_is_trivial_winner() {
let p = project_with("p", "/p", vec![]);
let lint = overlay_with(
"lint",
&["p"],
vec![task_with_marker("lint", "overlay-body")],
);
let workspace = workspace_with(vec![p], vec![lint]);
let effective = compute_effective_tasks(&workspace).unwrap();
let body = &effective.get(&project_name("p")).unwrap()[&task_name("lint")];
assert_eq!(marker_of(body), "overlay-body");
}
#[test]
fn dag_005_project_level_always_wins_over_overlay() {
let p = project_with("p", "/p", vec![task_with_marker("build", "project-body")]);
let universal = overlay_with(
"universal",
&["p"],
vec![task_with_marker("build", "overlay-body")],
);
let workspace = workspace_with(vec![p], vec![universal]);
let effective = compute_effective_tasks(&workspace).unwrap();
let body = &effective.get(&project_name("p")).unwrap()[&task_name("build")];
assert_eq!(marker_of(body), "project-body");
}
#[test]
fn dag_005_project_level_wins_even_against_singleton_overlay() {
let p = project_with("p", "/p", vec![task_with_marker("build", "project-body")]);
let singleton = overlay_with(
"singleton",
&["p"],
vec![task_with_marker("build", "overlay-body")],
);
let workspace = workspace_with(vec![p], vec![singleton]);
let effective = compute_effective_tasks(&workspace).unwrap();
let body = &effective.get(&project_name("p")).unwrap()[&task_name("build")];
assert_eq!(marker_of(body), "project-body");
}
#[test]
fn dag_005_more_specific_overlay_wins_over_less_specific() {
let p = project_with("p", "/p", vec![]);
let q = project_with("q", "/q", vec![]);
let universal = overlay_with(
"universal",
&["p", "q"],
vec![task_with_marker("lint", "universal-body")],
);
let specific = overlay_with(
"specific",
&["p"],
vec![task_with_marker("lint", "specific-body")],
);
let workspace = workspace_with(vec![p, q], vec![universal, specific]);
let effective = compute_effective_tasks(&workspace).unwrap();
let p_body = &effective.get(&project_name("p")).unwrap()[&task_name("lint")];
let q_body = &effective.get(&project_name("q")).unwrap()[&task_name("lint")];
assert_eq!(marker_of(p_body), "specific-body");
assert_eq!(marker_of(q_body), "universal-body");
}
#[test]
fn dag_005_three_overlay_chain_smallest_wins() {
let p = project_with("p", "/p", vec![]);
let q = project_with("q", "/q", vec![]);
let r = project_with("r", "/r", vec![]);
let pqr = overlay_with(
"pqr",
&["p", "q", "r"],
vec![task_with_marker("compile", "pqr-body")],
);
let pq = overlay_with(
"pq",
&["p", "q"],
vec![task_with_marker("compile", "pq-body")],
);
let p_only = overlay_with(
"p_only",
&["p"],
vec![task_with_marker("compile", "p-only-body")],
);
let workspace = workspace_with(vec![p, q, r], vec![pqr, pq, p_only]);
let effective = compute_effective_tasks(&workspace).unwrap();
let body = &effective.get(&project_name("p")).unwrap()[&task_name("compile")];
assert_eq!(
marker_of(body),
"p-only-body",
"{{p}} ⊂ {{p,q}} ⊂ {{p,q,r}}: smallest wins"
);
}
#[test]
fn dag_006_incomparable_overlays_emit_collision() {
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_marker("lint", "pq-body")]);
let pr = overlay_with("pr", &["p", "r"], vec![task_with_marker("lint", "pr-body")]);
let workspace = workspace_with(vec![p, q, r], vec![pq, pr]);
let errors = compute_effective_tasks(&workspace).unwrap_err();
let collision_for_p = errors.as_slice().iter().any(|e| {
matches!(
e,
OverlayMergeError::Collision { 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_for_p, "expected DAG-006 collision for (p, lint)");
}
#[test]
fn dag_006_equal_matched_projects_overlays_emit_collision() {
let p = project_with("p", "/p", vec![]);
let a = overlay_with("a", &["p"], vec![task_with_marker("lint", "a-body")]);
let b = overlay_with("b", &["p"], vec![task_with_marker("lint", "b-body")]);
let workspace = workspace_with(vec![p], vec![a, b]);
let errors = compute_effective_tasks(&workspace).unwrap_err();
assert_eq!(errors.len(), 1);
let OverlayMergeError::Collision { sources, .. } = &errors.as_slice()[0];
assert_eq!(sources.len(), 2);
assert!(sources.contains(&DefinitionSource::Overlay {
name: overlay_name("a")
}));
assert!(sources.contains(&DefinitionSource::Overlay {
name: overlay_name("b")
}));
}
#[test]
fn dag_006_collision_does_not_block_other_tasks_or_projects() {
let p = project_with("p", "/p", vec![]);
let q = project_with("q", "/q", vec![task_with_marker("build", "q-build")]);
let a = overlay_with(
"a",
&["p"],
vec![
task_with_marker("collide", "a-body"),
task_with_marker("good", "a-good"),
],
);
let b = overlay_with("b", &["p"], vec![task_with_marker("collide", "b-body")]);
let workspace = workspace_with(vec![p, q], vec![a, b]);
let errors = compute_effective_tasks(&workspace).unwrap_err();
assert!(
errors
.as_slice()
.iter()
.all(|e| matches!(e, OverlayMergeError::Collision { task, .. } if task == &task_name("collide"))),
"only `collide` should fail"
);
}
#[test]
fn errors_accumulate_across_projects_and_tasks() {
let p = project_with("p", "/p", vec![]);
let q = project_with("q", "/q", vec![]);
let a = overlay_with(
"a",
&["p", "q"],
vec![task_with_marker("collide", "a-body")],
);
let b = overlay_with(
"b",
&["p", "q"],
vec![task_with_marker("collide", "b-body")],
);
let workspace = workspace_with(vec![p, q], vec![a, b]);
let errors = compute_effective_tasks(&workspace).unwrap_err();
assert_eq!(
errors.len(),
2,
"expected one collision per affected project"
);
}
#[test]
fn dag_003_definition_source_ordering_puts_project_level_first() {
let pl = DefinitionSource::ProjectLevel {
project: project_name("z"),
};
let ov = DefinitionSource::Overlay {
name: overlay_name("a"),
};
assert!(pl < ov);
}
#[test]
fn display_overlay_merge_errors_renders_count_and_list() {
let p = project_with("p", "/p", vec![]);
let a = overlay_with("a", &["p"], vec![task_with_marker("lint", "a")]);
let b = overlay_with("b", &["p"], vec![task_with_marker("lint", "b")]);
let workspace = workspace_with(vec![p], vec![a, b]);
let errors = compute_effective_tasks(&workspace).unwrap_err();
let rendered = errors.to_string();
assert!(rendered.starts_with("1 error(s) during overlay merging:"));
assert!(rendered.contains("overlay-merge collision"));
assert!(rendered.contains("DAG-006"));
}
#[test]
fn pick_winner_returns_none_for_empty_sources() {
let result = pick_winner(None, &[], &task_name("anything"));
assert!(result.is_none());
}
#[test]
fn pick_winner_returns_project_level_over_overlays() {
let project_body = task_with_marker("x", "pl");
let overlay = overlay_with("a", &["p"], vec![task_with_marker("x", "ov")]);
let body = pick_winner(Some(&project_body), &[&overlay], &task_name("x")).unwrap();
assert_eq!(marker_of(body), "pl");
}
}