1use std::collections::{BTreeMap, BTreeSet};
39use std::fmt;
40
41use haz_domain::name::{OverlayName, ProjectName, TaskName};
42use haz_domain::overlay::Overlay;
43use haz_domain::task::Task;
44use haz_domain::workspace::Workspace;
45use snafu::Snafu;
46
47#[derive(Debug, Clone, PartialEq, Eq, Default)]
53pub struct EffectiveTasksByProject(BTreeMap<ProjectName, BTreeMap<TaskName, Task>>);
54
55impl EffectiveTasksByProject {
56 #[must_use]
58 pub fn get(&self, project: &ProjectName) -> Option<&BTreeMap<TaskName, Task>> {
59 self.0.get(project)
60 }
61
62 pub fn iter(&self) -> impl Iterator<Item = (&ProjectName, &BTreeMap<TaskName, Task>)> {
65 self.0.iter()
66 }
67
68 #[must_use]
71 pub fn len(&self) -> usize {
72 self.0.len()
73 }
74
75 #[must_use]
78 pub fn is_empty(&self) -> bool {
79 self.0.is_empty()
80 }
81
82 #[must_use]
84 pub fn into_inner(self) -> BTreeMap<ProjectName, BTreeMap<TaskName, Task>> {
85 self.0
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
99pub enum DefinitionSource {
100 ProjectLevel {
102 project: ProjectName,
104 },
105 Overlay {
107 name: OverlayName,
109 },
110}
111
112impl fmt::Display for DefinitionSource {
113 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114 match self {
115 DefinitionSource::ProjectLevel { project } => {
116 write!(f, "project-level `{project}/haz.yml`")
117 }
118 DefinitionSource::Overlay { name } => write!(f, "overlay `{name}.yml`"),
119 }
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
129pub enum OverlayMergeError {
130 #[snafu(display(
135 "task `{project}:{task}`: overlay-merge collision among {n} sources (DAG-006)",
136 n = sources.len()
137 ))]
138 Collision {
139 project: ProjectName,
141 task: TaskName,
143 sources: BTreeSet<DefinitionSource>,
148 },
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct OverlayMergeErrors(Vec<OverlayMergeError>);
157
158impl OverlayMergeErrors {
159 #[must_use]
161 pub fn as_slice(&self) -> &[OverlayMergeError] {
162 &self.0
163 }
164
165 #[must_use]
167 pub fn into_inner(self) -> Vec<OverlayMergeError> {
168 self.0
169 }
170
171 #[must_use]
174 pub fn len(&self) -> usize {
175 self.0.len()
176 }
177
178 #[must_use]
181 pub fn is_empty(&self) -> bool {
182 self.0.is_empty()
183 }
184}
185
186impl fmt::Display for OverlayMergeErrors {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 let n = self.0.len();
189 writeln!(f, "{n} error(s) during overlay merging:")?;
190 for (i, err) in self.0.iter().enumerate() {
191 writeln!(f, " {}. {err}", i + 1)?;
192 }
193 Ok(())
194 }
195}
196
197impl std::error::Error for OverlayMergeErrors {}
198
199pub fn compute_effective_tasks(
212 workspace: &Workspace,
213) -> Result<EffectiveTasksByProject, OverlayMergeErrors> {
214 let mut result: BTreeMap<ProjectName, BTreeMap<TaskName, Task>> = BTreeMap::new();
215 let mut errors: Vec<OverlayMergeError> = Vec::new();
216
217 for (project_name, project) in &workspace.projects {
218 let attached_overlays: Vec<&Overlay> = workspace
219 .overlays
220 .values()
221 .filter(|o| o.matched_projects.contains(project_name))
222 .collect();
223
224 let mut task_names: BTreeSet<&TaskName> = project.tasks.keys().collect();
225 for overlay in &attached_overlays {
226 for n in overlay.tasks.keys() {
227 task_names.insert(n);
228 }
229 }
230
231 let mut effective: BTreeMap<TaskName, Task> = BTreeMap::new();
232
233 for task_name in task_names {
234 let project_level_body = project.tasks.get(task_name);
235 let overlay_sources: Vec<&Overlay> = attached_overlays
236 .iter()
237 .copied()
238 .filter(|o| o.tasks.contains_key(task_name))
239 .collect();
240
241 if let Some(body) = pick_winner(project_level_body, &overlay_sources, task_name) {
242 effective.insert(task_name.clone(), body.clone());
243 } else {
244 let mut sources: BTreeSet<DefinitionSource> = overlay_sources
245 .iter()
246 .map(|o| DefinitionSource::Overlay {
247 name: o.name.clone(),
248 })
249 .collect();
250 if project_level_body.is_some() {
251 sources.insert(DefinitionSource::ProjectLevel {
252 project: project_name.clone(),
253 });
254 }
255 errors.push(OverlayMergeError::Collision {
256 project: project_name.clone(),
257 task: task_name.clone(),
258 sources,
259 });
260 }
261 }
262
263 result.insert(project_name.clone(), effective);
264 }
265
266 if errors.is_empty() {
267 Ok(EffectiveTasksByProject(result))
268 } else {
269 Err(OverlayMergeErrors(errors))
270 }
271}
272
273fn pick_winner<'a>(
283 project_level: Option<&'a Task>,
284 overlays: &[&'a Overlay],
285 task_name: &TaskName,
286) -> Option<&'a Task> {
287 if let Some(body) = project_level {
289 return Some(body);
290 }
291
292 match overlays.len() {
293 0 => None,
294 1 => overlays[0].tasks.get(task_name),
296 _ => {
297 let winners: Vec<&Overlay> = overlays
303 .iter()
304 .copied()
305 .filter(|x| {
306 overlays.iter().copied().all(|y| {
307 x.name == y.name
308 || (x.matched_projects.is_subset(&y.matched_projects)
309 && x.matched_projects != y.matched_projects)
310 })
311 })
312 .collect();
313 if winners.len() == 1 {
314 winners[0].tasks.get(task_name)
315 } else {
316 None
317 }
318 }
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use std::collections::BTreeSet;
325 use std::path::PathBuf;
326 use std::str::FromStr;
327
328 use haz_domain::action::TaskAction;
329 use haz_domain::env::EnvSettings;
330 use haz_domain::name::{OverlayName, ProjectName, TaskName};
331 use haz_domain::overlay::Overlay;
332 use haz_domain::path::{CanonicalPath, HazPath, ProjectRoot, WorkspaceRootPath};
333 use haz_domain::project::Project;
334 use haz_domain::settings::WorkspaceSettings;
335 use haz_domain::task::Task;
336 use haz_domain::workspace::Workspace;
337 use nonempty::NonEmpty;
338
339 use super::{DefinitionSource, OverlayMergeError, compute_effective_tasks, pick_winner};
340
341 fn project_name(s: &str) -> ProjectName {
342 ProjectName::from_str(s).unwrap()
343 }
344
345 fn task_name(s: &str) -> TaskName {
346 TaskName::from_str(s).unwrap()
347 }
348
349 fn overlay_name(s: &str) -> OverlayName {
350 OverlayName::from_str(s).unwrap()
351 }
352
353 fn task_with_marker(name: &str, marker: &str) -> Task {
354 Task {
355 name: task_name(name),
356 action: TaskAction::Command(
357 NonEmpty::from_vec(vec!["echo".to_owned(), marker.to_owned()]).unwrap(),
358 ),
359 inputs: vec![],
360 outputs: vec![],
361 deps: vec![],
362 weak_deps: vec![],
363 mutex: None,
364 env: EnvSettings::default(),
365 }
366 }
367
368 fn project_with(name: &str, root: &str, tasks: Vec<Task>) -> Project {
369 Project {
370 name: project_name(name),
371 root: ProjectRoot::Nested(
372 CanonicalPath::from_absolute(&HazPath::parse(root).unwrap()).unwrap(),
373 ),
374 tags: BTreeSet::new(),
375 tasks: tasks.into_iter().map(|t| (t.name.clone(), t)).collect(),
376 }
377 }
378
379 fn overlay_with(name: &str, matched_projects: &[&str], tasks: Vec<Task>) -> Overlay {
380 Overlay {
381 name: overlay_name(name),
382 matched_projects: matched_projects.iter().map(|p| project_name(p)).collect(),
383 tasks: tasks.into_iter().map(|t| (t.name.clone(), t)).collect(),
384 }
385 }
386
387 fn workspace_with(projects: Vec<Project>, overlays: Vec<Overlay>) -> Workspace {
388 Workspace {
389 root: WorkspaceRootPath::try_new(PathBuf::from("/abs/ws")).unwrap(),
390 projects: projects.into_iter().map(|p| (p.name.clone(), p)).collect(),
391 overlays: overlays.into_iter().map(|o| (o.name.clone(), o)).collect(),
392 settings: WorkspaceSettings::default(),
393 }
394 }
395
396 fn marker_of(task: &Task) -> &str {
397 match &task.action {
398 TaskAction::Command(parts) => parts[1].as_str(),
399 TaskAction::Shell { .. } => panic!("expected Command action with two parts"),
400 }
401 }
402
403 #[test]
407 fn dag_002_no_overlays_returns_project_tasks_unchanged() {
408 let p = project_with("p", "/p", vec![task_with_marker("build", "p-build")]);
409 let q = project_with(
410 "q",
411 "/q",
412 vec![
413 task_with_marker("build", "q-build"),
414 task_with_marker("test", "q-test"),
415 ],
416 );
417 let workspace = workspace_with(vec![p, q], vec![]);
418 let effective = compute_effective_tasks(&workspace).unwrap();
419
420 assert_eq!(effective.len(), 2);
421 let p_tasks = effective.get(&project_name("p")).unwrap();
422 let q_tasks = effective.get(&project_name("q")).unwrap();
423 assert_eq!(p_tasks.len(), 1);
424 assert_eq!(q_tasks.len(), 2);
425 assert_eq!(marker_of(&p_tasks[&task_name("build")]), "p-build");
426 assert_eq!(marker_of(&q_tasks[&task_name("test")]), "q-test");
427 }
428
429 #[test]
430 fn empty_workspace_returns_empty_effective_tasks() {
431 let workspace = workspace_with(vec![], vec![]);
432 let effective = compute_effective_tasks(&workspace).unwrap();
433 assert!(effective.is_empty());
434 }
435
436 #[test]
440 fn dag_002_overlay_attaches_task_only_to_matched_projects() {
441 let p = project_with("p", "/p", vec![]);
442 let q = project_with("q", "/q", vec![]);
443 let r = project_with("r", "/r", vec![]);
444 let lint = overlay_with(
445 "lint",
446 &["p", "r"],
447 vec![task_with_marker("lint", "lint-body")],
448 );
449 let workspace = workspace_with(vec![p, q, r], vec![lint]);
450
451 let effective = compute_effective_tasks(&workspace).unwrap();
452
453 assert!(
454 effective
455 .get(&project_name("p"))
456 .unwrap()
457 .contains_key(&task_name("lint"))
458 );
459 assert!(
460 effective
461 .get(&project_name("r"))
462 .unwrap()
463 .contains_key(&task_name("lint"))
464 );
465 assert!(
466 effective.get(&project_name("q")).unwrap().is_empty(),
467 "overlay must NOT attach to unmatched project q"
468 );
469 }
470
471 #[test]
472 fn dag_002_overlay_only_task_appears_in_effective_set_for_matched_project() {
473 let p = project_with("p", "/p", vec![task_with_marker("build", "p-build")]);
474 let lint = overlay_with("lint", &["p"], vec![task_with_marker("lint", "lint-body")]);
475 let workspace = workspace_with(vec![p], vec![lint]);
476
477 let effective = compute_effective_tasks(&workspace).unwrap();
478 let p_tasks = effective.get(&project_name("p")).unwrap();
479 assert_eq!(p_tasks.len(), 2);
480 assert_eq!(marker_of(&p_tasks[&task_name("build")]), "p-build");
481 assert_eq!(marker_of(&p_tasks[&task_name("lint")]), "lint-body");
482 }
483
484 #[test]
488 fn dag_004_single_overlay_source_is_trivial_winner() {
489 let p = project_with("p", "/p", vec![]);
490 let lint = overlay_with(
491 "lint",
492 &["p"],
493 vec![task_with_marker("lint", "overlay-body")],
494 );
495 let workspace = workspace_with(vec![p], vec![lint]);
496
497 let effective = compute_effective_tasks(&workspace).unwrap();
498 let body = &effective.get(&project_name("p")).unwrap()[&task_name("lint")];
499 assert_eq!(marker_of(body), "overlay-body");
500 }
501
502 #[test]
505 fn dag_005_project_level_always_wins_over_overlay() {
506 let p = project_with("p", "/p", vec![task_with_marker("build", "project-body")]);
507 let universal = overlay_with(
508 "universal",
509 &["p"],
510 vec![task_with_marker("build", "overlay-body")],
511 );
512 let workspace = workspace_with(vec![p], vec![universal]);
513
514 let effective = compute_effective_tasks(&workspace).unwrap();
515 let body = &effective.get(&project_name("p")).unwrap()[&task_name("build")];
516 assert_eq!(marker_of(body), "project-body");
517 }
518
519 #[test]
520 fn dag_005_project_level_wins_even_against_singleton_overlay() {
521 let p = project_with("p", "/p", vec![task_with_marker("build", "project-body")]);
524 let singleton = overlay_with(
525 "singleton",
526 &["p"],
527 vec![task_with_marker("build", "overlay-body")],
528 );
529 let workspace = workspace_with(vec![p], vec![singleton]);
530
531 let effective = compute_effective_tasks(&workspace).unwrap();
532 let body = &effective.get(&project_name("p")).unwrap()[&task_name("build")];
533 assert_eq!(marker_of(body), "project-body");
534 }
535
536 #[test]
540 fn dag_005_more_specific_overlay_wins_over_less_specific() {
541 let p = project_with("p", "/p", vec![]);
542 let q = project_with("q", "/q", vec![]);
543 let universal = overlay_with(
544 "universal",
545 &["p", "q"],
546 vec![task_with_marker("lint", "universal-body")],
547 );
548 let specific = overlay_with(
549 "specific",
550 &["p"],
551 vec![task_with_marker("lint", "specific-body")],
552 );
553 let workspace = workspace_with(vec![p, q], vec![universal, specific]);
554
555 let effective = compute_effective_tasks(&workspace).unwrap();
556 let p_body = &effective.get(&project_name("p")).unwrap()[&task_name("lint")];
557 let q_body = &effective.get(&project_name("q")).unwrap()[&task_name("lint")];
558 assert_eq!(marker_of(p_body), "specific-body");
560 assert_eq!(marker_of(q_body), "universal-body");
562 }
563
564 #[test]
565 fn dag_005_three_overlay_chain_smallest_wins() {
566 let p = project_with("p", "/p", vec![]);
567 let q = project_with("q", "/q", vec![]);
568 let r = project_with("r", "/r", vec![]);
569 let pqr = overlay_with(
570 "pqr",
571 &["p", "q", "r"],
572 vec![task_with_marker("compile", "pqr-body")],
573 );
574 let pq = overlay_with(
575 "pq",
576 &["p", "q"],
577 vec![task_with_marker("compile", "pq-body")],
578 );
579 let p_only = overlay_with(
580 "p_only",
581 &["p"],
582 vec![task_with_marker("compile", "p-only-body")],
583 );
584 let workspace = workspace_with(vec![p, q, r], vec![pqr, pq, p_only]);
585
586 let effective = compute_effective_tasks(&workspace).unwrap();
587 let body = &effective.get(&project_name("p")).unwrap()[&task_name("compile")];
588 assert_eq!(
589 marker_of(body),
590 "p-only-body",
591 "{{p}} ⊂ {{p,q}} ⊂ {{p,q,r}}: smallest wins"
592 );
593 }
594
595 #[test]
598 fn dag_006_incomparable_overlays_emit_collision() {
599 let p = project_with("p", "/p", vec![]);
600 let q = project_with("q", "/q", vec![]);
601 let r = project_with("r", "/r", vec![]);
602 let pq = overlay_with("pq", &["p", "q"], vec![task_with_marker("lint", "pq-body")]);
603 let pr = overlay_with("pr", &["p", "r"], vec![task_with_marker("lint", "pr-body")]);
604 let workspace = workspace_with(vec![p, q, r], vec![pq, pr]);
605
606 let errors = compute_effective_tasks(&workspace).unwrap_err();
607 let collision_for_p = errors.as_slice().iter().any(|e| {
608 matches!(
609 e,
610 OverlayMergeError::Collision { project, task, sources }
611 if project == &project_name("p")
612 && task == &task_name("lint")
613 && sources.contains(&DefinitionSource::Overlay { name: overlay_name("pq") })
614 && sources.contains(&DefinitionSource::Overlay { name: overlay_name("pr") })
615 )
616 });
617 assert!(collision_for_p, "expected DAG-006 collision for (p, lint)");
618 }
619
620 #[test]
621 fn dag_006_equal_matched_projects_overlays_emit_collision() {
622 let p = project_with("p", "/p", vec![]);
626 let a = overlay_with("a", &["p"], vec![task_with_marker("lint", "a-body")]);
627 let b = overlay_with("b", &["p"], vec![task_with_marker("lint", "b-body")]);
628 let workspace = workspace_with(vec![p], vec![a, b]);
629
630 let errors = compute_effective_tasks(&workspace).unwrap_err();
631 assert_eq!(errors.len(), 1);
632 let OverlayMergeError::Collision { sources, .. } = &errors.as_slice()[0];
633 assert_eq!(sources.len(), 2);
634 assert!(sources.contains(&DefinitionSource::Overlay {
635 name: overlay_name("a")
636 }));
637 assert!(sources.contains(&DefinitionSource::Overlay {
638 name: overlay_name("b")
639 }));
640 }
641
642 #[test]
643 fn dag_006_collision_does_not_block_other_tasks_or_projects() {
644 let p = project_with("p", "/p", vec![]);
646 let q = project_with("q", "/q", vec![task_with_marker("build", "q-build")]);
647 let a = overlay_with(
648 "a",
649 &["p"],
650 vec![
651 task_with_marker("collide", "a-body"),
652 task_with_marker("good", "a-good"),
653 ],
654 );
655 let b = overlay_with("b", &["p"], vec![task_with_marker("collide", "b-body")]);
656 let workspace = workspace_with(vec![p, q], vec![a, b]);
657
658 let errors = compute_effective_tasks(&workspace).unwrap_err();
659 assert!(
660 errors
661 .as_slice()
662 .iter()
663 .all(|e| matches!(e, OverlayMergeError::Collision { task, .. } if task == &task_name("collide"))),
664 "only `collide` should fail"
665 );
666 }
667
668 #[test]
669 fn errors_accumulate_across_projects_and_tasks() {
670 let p = project_with("p", "/p", vec![]);
672 let q = project_with("q", "/q", vec![]);
673 let a = overlay_with(
674 "a",
675 &["p", "q"],
676 vec![task_with_marker("collide", "a-body")],
677 );
678 let b = overlay_with(
679 "b",
680 &["p", "q"],
681 vec![task_with_marker("collide", "b-body")],
682 );
683 let workspace = workspace_with(vec![p, q], vec![a, b]);
684
685 let errors = compute_effective_tasks(&workspace).unwrap_err();
686 assert_eq!(
687 errors.len(),
688 2,
689 "expected one collision per affected project"
690 );
691 }
692
693 #[test]
697 fn dag_003_definition_source_ordering_puts_project_level_first() {
698 let pl = DefinitionSource::ProjectLevel {
699 project: project_name("z"),
700 };
701 let ov = DefinitionSource::Overlay {
702 name: overlay_name("a"),
703 };
704 assert!(pl < ov);
705 }
706
707 #[test]
711 fn display_overlay_merge_errors_renders_count_and_list() {
712 let p = project_with("p", "/p", vec![]);
713 let a = overlay_with("a", &["p"], vec![task_with_marker("lint", "a")]);
714 let b = overlay_with("b", &["p"], vec![task_with_marker("lint", "b")]);
715 let workspace = workspace_with(vec![p], vec![a, b]);
716
717 let errors = compute_effective_tasks(&workspace).unwrap_err();
718 let rendered = errors.to_string();
719 assert!(rendered.starts_with("1 error(s) during overlay merging:"));
720 assert!(rendered.contains("overlay-merge collision"));
721 assert!(rendered.contains("DAG-006"));
722 }
723
724 #[test]
728 fn pick_winner_returns_none_for_empty_sources() {
729 let result = pick_winner(None, &[], &task_name("anything"));
730 assert!(result.is_none());
731 }
732
733 #[test]
734 fn pick_winner_returns_project_level_over_overlays() {
735 let project_body = task_with_marker("x", "pl");
736 let overlay = overlay_with("a", &["p"], vec![task_with_marker("x", "ov")]);
737 let body = pick_winner(Some(&project_body), &[&overlay], &task_name("x")).unwrap();
738 assert_eq!(marker_of(body), "pl");
739 }
740}