haz-domain 0.2.0

Shared domain types for the haz task runner (identifiers, paths, errors).
Documentation
//! [`Workspace`]: a filesystem-rooted collection of [`Project`]s
//! and overlay files.
//!
//! A `Workspace` ties together four pieces:
//!
//! - a [`WorkspaceRootPath`]: the OS-level absolute path under which
//!   all workspace files live (the directory containing `.haz/`);
//! - a [`BTreeMap`] of [`Project`]s keyed by [`ProjectName`];
//! - a [`BTreeMap`] of [`Overlay`]s keyed by [`OverlayName`],
//!   each carrying its pre-computed matched-projects set per
//!   `CFG-026` and `DAG-001`;
//! - the workspace-wide [`WorkspaceSettings`] (`CFG-027` ..
//!   `CFG-031`).
//!
//! Project and overlay uniqueness are enforced structurally by
//! the maps. The workspace root path is validated at
//! construction (see [`crate::path::workspace_root`]); existence
//! on disk is an I/O concern handled by workspace discovery, not
//! the domain layer.

use std::collections::BTreeMap;

use crate::name::{OverlayName, ProjectName};
use crate::overlay::Overlay;
use crate::path::WorkspaceRootPath;
use crate::project::Project;
use crate::settings::WorkspaceSettings;

/// A workspace: a filesystem root plus a collection of projects,
/// overlay files, and workspace-wide settings.
///
/// # Examples
///
/// ```
/// use std::collections::BTreeMap;
/// use std::path::PathBuf;
/// use haz_domain::path::WorkspaceRootPath;
/// use haz_domain::settings::WorkspaceSettings;
/// use haz_domain::workspace::Workspace;
///
/// let ws = Workspace {
///     root: WorkspaceRootPath::try_new(PathBuf::from("/abs/workspace")).unwrap(),
///     projects: BTreeMap::new(),
///     overlays: BTreeMap::new(),
///     settings: WorkspaceSettings::default(),
/// };
/// assert!(ws.projects.is_empty());
/// assert!(ws.overlays.is_empty());
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Workspace {
    /// OS-level absolute path of the workspace root.
    pub root: WorkspaceRootPath,
    /// Projects in this workspace, keyed by name.
    pub projects: BTreeMap<ProjectName, Project>,
    /// Overlay files in this workspace, keyed by overlay name.
    /// Each overlay carries its pre-computed matched-projects
    /// set (`CFG-026` + `DAG-001`). Empty when the workspace has
    /// no `.haz/tasks/*.yml` files (or none survived parsing).
    pub overlays: BTreeMap<OverlayName, Overlay>,
    /// Workspace-wide settings (`CFG-027` .. `CFG-031`). Equal
    /// to [`WorkspaceSettings::default`] when the workspace YAML
    /// omits the corresponding mappings.
    pub settings: WorkspaceSettings,
}

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

    use crate::name::ProjectName;
    use crate::path::{CanonicalPath, HazPath, ProjectRoot, WorkspaceRootPath};
    use crate::project::Project;
    use crate::workspace::Workspace;

    fn project(name: &str, root: &str) -> Project {
        Project {
            name: ProjectName::from_str(name).unwrap(),
            root: ProjectRoot::Nested(
                CanonicalPath::from_absolute(&HazPath::parse(root).unwrap()).unwrap(),
            ),
            tags: BTreeSet::new(),
            tasks: BTreeMap::new(),
        }
    }

    #[cfg(unix)]
    #[test]
    fn workspace_with_projects() {
        use crate::settings::WorkspaceSettings;

        let mut projects = BTreeMap::new();
        let p1 = project("lib_core", "/lib_core");
        let p2 = project("web_frontend", "/web/frontend");
        projects.insert(p1.name.clone(), p1);
        projects.insert(p2.name.clone(), p2);

        let ws = Workspace {
            root: WorkspaceRootPath::try_new(PathBuf::from("/abs/workspace")).unwrap(),
            projects,
            overlays: BTreeMap::new(),
            settings: WorkspaceSettings::default(),
        };
        assert_eq!(ws.projects.len(), 2);
        assert!(ws.overlays.is_empty());
    }

    #[cfg(unix)]
    #[test]
    fn workspace_with_overlays() {
        use std::str::FromStr;

        use crate::name::{OverlayName, TaskName};
        use crate::overlay::Overlay;
        use crate::settings::WorkspaceSettings;

        let mut projects = BTreeMap::new();
        let p = project("lib_core", "/lib_core");
        projects.insert(p.name.clone(), p);

        let mut overlays = BTreeMap::new();
        let lint = Overlay {
            name: OverlayName::from_str("lint").unwrap(),
            matched_projects: BTreeSet::from([ProjectName::from_str("lib_core").unwrap()]),
            tasks: BTreeMap::new(),
        };
        overlays.insert(lint.name.clone(), lint);

        let ws = Workspace {
            root: WorkspaceRootPath::try_new(PathBuf::from("/abs/workspace")).unwrap(),
            projects,
            overlays,
            settings: WorkspaceSettings::default(),
        };
        assert_eq!(ws.projects.len(), 1);
        assert_eq!(ws.overlays.len(), 1);
        assert!(
            ws.overlays
                .contains_key(&OverlayName::from_str("lint").unwrap())
        );
        assert!(
            ws.overlays
                .values()
                .next()
                .unwrap()
                .tasks
                .keys()
                .all(|_: &TaskName| true)
        );
    }

    #[test]
    fn workspace_project_uniqueness_is_structural() {
        let mut projects = BTreeMap::new();
        let p1 = project("lib_core", "/lib_core");
        let key = p1.name.clone();
        let inserted_first = projects.insert(key.clone(), p1);
        assert!(inserted_first.is_none());
        let inserted_again = projects.insert(key, project("lib_core", "/lib_core"));
        assert!(inserted_again.is_some());
        assert_eq!(projects.len(), 1);
    }

    #[test]
    fn workspace_overlay_uniqueness_is_structural() {
        use std::str::FromStr;

        use crate::name::OverlayName;
        use crate::overlay::Overlay;

        let mut overlays = BTreeMap::new();
        let lint = Overlay {
            name: OverlayName::from_str("lint").unwrap(),
            matched_projects: BTreeSet::new(),
            tasks: BTreeMap::new(),
        };
        let key = lint.name.clone();
        let inserted_first = overlays.insert(key.clone(), lint);
        assert!(inserted_first.is_none());
        let inserted_again = overlays.insert(
            key,
            Overlay {
                name: OverlayName::from_str("lint").unwrap(),
                matched_projects: BTreeSet::new(),
                tasks: BTreeMap::new(),
            },
        );
        assert!(inserted_again.is_some());
        assert_eq!(overlays.len(), 1);
    }
}