haz-domain 0.2.0

Shared domain types for the haz task runner (identifiers, paths, errors).
Documentation
//! Task-level mutex aggregate.
//!
//! A declared mutex carries three pieces of identity that together
//! determine which other tasks compete for the same abstract
//! resource (`MUTEX-001`, `MUTEX-002`):
//!
//! - [`MutexScope`]: workspace-global vs project-scoped (the
//!   YAML `~:` prefix selects project scope).
//! - [`crate::name::MutexName`]: the validated identifier.
//! - [`MutexMode`]: exclusive (single writer) vs shared (multiple
//!   readers).
//!
//! The [`Mutex`] aggregate bundles the three so a task carries
//! exactly one mutex declaration (v1's "at most one mutex per
//! task" rule, per `MUTEX-002`, becomes a single
//! `Option<Mutex>` field).
//!
//! `MutexScope::Project` only records the *kind* of scoping; the
//! concrete project identity is the task's owning project, known
//! at the workspace level. Two tasks in different projects, each
//! declaring `mutex: ~:db`, carry equal [`Mutex`] values at the
//! task layer; the executor distinguishes their locks by combining
//! `(owning_project, mutex)` at runtime.

use crate::name::MutexName;

/// Whether a mutex name is workspace-global or project-scoped.
///
/// The YAML form `mutex: <name>` selects [`MutexScope::Workspace`];
/// the prefixed form `mutex: ~:<name>` selects
/// [`MutexScope::Project`] (`MUTEX-001`).
///
/// # Examples
///
/// ```
/// use haz_domain::mutex::MutexScope;
///
/// assert_ne!(MutexScope::Workspace, MutexScope::Project);
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum MutexScope {
    /// Workspace-global scope (the bare YAML form).
    Workspace,
    /// Project-scope (the `~:` YAML prefix). The owning project
    /// is determined by the task that bears the declaration.
    Project,
}

/// Lock mode declared alongside a mutex name (`MUTEX-002`).
///
/// `Default::default()` returns [`MutexMode::Exclusive`], matching
/// the shorthand semantics: a bare-string mutex declaration is
/// equivalent to the object form with `mode: exclusive`.
///
/// # Examples
///
/// ```
/// use haz_domain::mutex::MutexMode;
///
/// assert_eq!(MutexMode::default(), MutexMode::Exclusive);
/// assert_ne!(MutexMode::Exclusive, MutexMode::Shared);
/// ```
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum MutexMode {
    /// Exclusive hold: no other task may hold this mutex
    /// concurrently in any mode (`MUTEX-003`).
    #[default]
    Exclusive,
    /// Shared hold: any number of tasks may hold this mutex in
    /// shared mode concurrently, but no holder may be exclusive
    /// (`MUTEX-004`).
    Shared,
}

/// A task's full mutex declaration: scope, name, and mode.
///
/// `Mutex` is the value-object form of a `mutex:` field in a task
/// body, irrespective of the YAML syntactic variant used to express
/// it. A bare-string declaration `mutex: db` becomes
/// `Mutex { scope: Workspace, name: db, mode: Exclusive }`; the
/// object form `mutex: { name: db, mode: shared }` produces an
/// equivalent value with `mode: Shared`.
///
/// # Examples
///
/// ```
/// use std::str::FromStr;
/// use haz_domain::mutex::{Mutex, MutexMode, MutexScope};
/// use haz_domain::name::MutexName;
///
/// let m = Mutex {
///     scope: MutexScope::Project,
///     name: MutexName::from_str("db").unwrap(),
///     mode: MutexMode::Shared,
/// };
/// assert_eq!(m.name.as_ref(), "db");
/// assert_eq!(m.scope, MutexScope::Project);
/// assert_eq!(m.mode, MutexMode::Shared);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Mutex {
    /// Whether the name is workspace-global or project-scoped.
    pub scope: MutexScope,
    /// The validated mutex identifier (`ID-001`..`ID-003`).
    pub name: MutexName,
    /// The mode in which the declaring task intends to hold this
    /// mutex.
    pub mode: MutexMode,
}

#[cfg(test)]
mod tests {
    use std::str::FromStr;

    use crate::mutex::{Mutex, MutexMode, MutexScope};
    use crate::name::MutexName;

    fn name(s: &str) -> MutexName {
        MutexName::from_str(s).unwrap()
    }

    #[test]
    fn mutex_scope_variants_are_distinct() {
        assert_ne!(MutexScope::Workspace, MutexScope::Project);
    }

    #[test]
    fn mutex_mode_default_is_exclusive() {
        assert_eq!(MutexMode::default(), MutexMode::Exclusive);
    }

    #[test]
    fn mutex_mode_variants_are_distinct() {
        assert_ne!(MutexMode::Exclusive, MutexMode::Shared);
    }

    #[test]
    fn mutex_aggregate_carries_three_fields() {
        let m = Mutex {
            scope: MutexScope::Workspace,
            name: name("db"),
            mode: MutexMode::Exclusive,
        };
        assert_eq!(m.scope, MutexScope::Workspace);
        assert_eq!(m.name.as_ref(), "db");
        assert_eq!(m.mode, MutexMode::Exclusive);
    }

    #[test]
    fn mutexes_distinct_by_scope_with_same_name() {
        let ws = Mutex {
            scope: MutexScope::Workspace,
            name: name("db"),
            mode: MutexMode::Exclusive,
        };
        let proj = Mutex {
            scope: MutexScope::Project,
            name: name("db"),
            mode: MutexMode::Exclusive,
        };
        assert_ne!(ws, proj);
    }

    #[test]
    fn mutexes_distinct_by_mode_with_same_name_and_scope() {
        let excl = Mutex {
            scope: MutexScope::Workspace,
            name: name("db"),
            mode: MutexMode::Exclusive,
        };
        let shared = Mutex {
            scope: MutexScope::Workspace,
            name: name("db"),
            mode: MutexMode::Shared,
        };
        assert_ne!(excl, shared);
    }

    #[test]
    fn mutex_is_hashable() {
        use std::collections::HashSet;
        let mut s = HashSet::new();
        s.insert(Mutex {
            scope: MutexScope::Workspace,
            name: name("db"),
            mode: MutexMode::Exclusive,
        });
        s.insert(Mutex {
            scope: MutexScope::Workspace,
            name: name("db"),
            mode: MutexMode::Exclusive,
        });
        assert_eq!(s.len(), 1);
    }
}