haz-domain 0.2.0

Shared domain types for the haz task runner (identifiers, paths, errors).
Documentation
//! [`Overlay`]: an in-workspace `.haz/tasks/*.yml` file as a
//! domain value object.
//!
//! An overlay file contributes additional task definitions that
//! attach to a subset of the workspace's projects per `DAG-001`.
//! The subset is determined at discovery time from the overlay's
//! `tagsFilter` (`CFG-024`, `CFG-026`) and each project's tags;
//! it is stored here as a pre-computed [`BTreeSet`] of
//! [`ProjectName`]s so downstream layers (notably `haz-dag`
//! overlay merging) consume the relation directly rather than
//! recomputing it.
//!
//! This module defines the value object only. The merging
//! algorithm itself (`DAG-001`..`DAG-006`) lives in `haz-dag`
//! because it operates on a fully-loaded `Workspace`.

use std::collections::{BTreeMap, BTreeSet};

use crate::name::{OverlayName, ProjectName, TaskName};
use crate::task::Task;

/// One overlay file as it exists in a workspace context.
///
/// # Examples
///
/// ```
/// use std::collections::{BTreeMap, BTreeSet};
/// use std::str::FromStr;
/// use haz_domain::name::{OverlayName, ProjectName};
/// use haz_domain::overlay::Overlay;
///
/// let overlay = Overlay {
///     name: OverlayName::from_str("lint").unwrap(),
///     matched_projects: BTreeSet::from([
///         ProjectName::from_str("lib_core").unwrap(),
///     ]),
///     tasks: BTreeMap::new(),
/// };
/// assert_eq!(overlay.matched_projects.len(), 1);
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Overlay {
    /// Identity of this overlay: the stem of its
    /// `.haz/tasks/*.yml` file, validated per `CFG-023`.
    pub name: OverlayName,
    /// Projects this overlay attaches to per `CFG-026` +
    /// `DAG-001`. Computed at discovery time from `tagsFilter`
    /// against each project's tags; can be empty if the
    /// `tagsFilter` mentions tags that no project carries.
    pub matched_projects: BTreeSet<ProjectName>,
    /// Task definitions this overlay contributes per `CFG-025`.
    pub tasks: BTreeMap<TaskName, Task>,
}

#[cfg(test)]
mod tests {
    use std::collections::{BTreeMap, BTreeSet};
    use std::str::FromStr;

    use nonempty::NonEmpty;

    use crate::action::TaskAction;
    use crate::env::EnvSettings;
    use crate::name::{OverlayName, ProjectName, TaskName};
    use crate::overlay::Overlay;
    use crate::task::Task;

    fn task(name: &str) -> Task {
        Task {
            name: TaskName::from_str(name).unwrap(),
            action: TaskAction::Command(NonEmpty::from_vec(vec!["true".to_owned()]).unwrap()),
            inputs: vec![],
            outputs: vec![],
            deps: vec![],
            weak_deps: vec![],
            mutex: None,
            env: EnvSettings::default(),
        }
    }

    fn project_name(s: &str) -> ProjectName {
        ProjectName::from_str(s).unwrap()
    }

    fn overlay_name(s: &str) -> OverlayName {
        OverlayName::from_str(s).unwrap()
    }

    #[test]
    fn overlay_with_one_matched_project_and_one_task() {
        let overlay = Overlay {
            name: overlay_name("lint"),
            matched_projects: BTreeSet::from([project_name("lib_core")]),
            tasks: BTreeMap::from([(TaskName::from_str("lint").unwrap(), task("lint"))]),
        };
        assert_eq!(overlay.matched_projects.len(), 1);
        assert_eq!(overlay.tasks.len(), 1);
    }

    #[test]
    fn overlay_with_empty_matched_projects_is_representable() {
        // CFG-024 + CFG-026: an overlay with `tagsFilter`
        // mentioning a tag no project carries has an empty
        // matched-projects set. The value object accepts it; how
        // such an overlay contributes is a downstream concern.
        let overlay = Overlay {
            name: overlay_name("ghost_filter"),
            matched_projects: BTreeSet::new(),
            tasks: BTreeMap::from([(TaskName::from_str("lint").unwrap(), task("lint"))]),
        };
        assert!(overlay.matched_projects.is_empty());
    }

    #[test]
    fn overlay_equality_is_structural() {
        let a = Overlay {
            name: overlay_name("lint"),
            matched_projects: BTreeSet::from([project_name("lib_core")]),
            tasks: BTreeMap::new(),
        };
        let b = Overlay {
            name: overlay_name("lint"),
            matched_projects: BTreeSet::from([project_name("lib_core")]),
            tasks: BTreeMap::new(),
        };
        let c = Overlay {
            name: overlay_name("lint"),
            matched_projects: BTreeSet::from([project_name("web_frontend")]),
            tasks: BTreeMap::new(),
        };
        assert_eq!(a, b);
        assert_ne!(a, c, "different matched_projects => different overlay");
    }

    #[test]
    fn overlay_with_multiple_matched_projects_and_tasks() {
        let overlay = Overlay {
            name: overlay_name("ts_pipeline"),
            matched_projects: BTreeSet::from([
                project_name("web_frontend"),
                project_name("admin_panel"),
            ]),
            tasks: BTreeMap::from([
                (TaskName::from_str("compile").unwrap(), task("compile")),
                (TaskName::from_str("test").unwrap(), task("test")),
            ]),
        };
        assert_eq!(overlay.matched_projects.len(), 2);
        assert_eq!(overlay.tasks.len(), 2);
    }
}