haz-query 0.1.0

Query evaluator over haz task DAGs.
Documentation
//! Boolean shortcut flags for `haz query` per `QRY-005`.
//!
//! The boolean shortcuts are flag-level predicates that carry
//! no expression argument; the CLI flag itself is the entire
//! filter. The engine evaluates each shortcut against every
//! task in the candidate set independently.
//!
//! Within each pair (`--has-X` vs `--no-X`), the two flags are
//! mutually exclusive on a single invocation; that constraint
//! is enforced at the CLI-parser level, not at this type.

use haz_domain::task::Task;

/// A boolean shortcut filter per `QRY-005`.
///
/// Each variant maps 1:1 to a CLI flag. The variant identity
/// captures everything the engine needs to evaluate the
/// predicate against a task; no further parameters are
/// required.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BooleanShortcut {
    /// `--has-deps`: the task's `deps:` is non-empty.
    HasDeps,
    /// `--no-deps`: the task's `deps:` is empty.
    NoDeps,
    /// `--has-inputs`: the task's `inputs:` is non-empty.
    HasInputs,
    /// `--no-inputs`: the task's `inputs:` is empty.
    NoInputs,
    /// `--has-outputs`: the task's `outputs:` is non-empty.
    HasOutputs,
    /// `--no-outputs`: the task's `outputs:` is empty.
    NoOutputs,
    /// `--mutex`: the task declares a `mutex:` field per
    /// `CFG-019`.
    Mutex,
}

impl BooleanShortcut {
    /// Evaluate this shortcut against a task per `QRY-005`.
    ///
    /// Each predicate inspects exactly one structural field of
    /// the task; no workspace or graph context is required.
    #[must_use]
    pub fn matches(&self, task: &Task) -> bool {
        match self {
            Self::HasDeps => !task.deps.is_empty(),
            Self::NoDeps => task.deps.is_empty(),
            Self::HasInputs => !task.inputs.is_empty(),
            Self::NoInputs => task.inputs.is_empty(),
            Self::HasOutputs => !task.outputs.is_empty(),
            Self::NoOutputs => task.outputs.is_empty(),
            Self::Mutex => task.mutex.is_some(),
        }
    }
}

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

    use haz_domain::action::TaskAction;
    use haz_domain::env::EnvSettings;
    use haz_domain::mutex::{Mutex, MutexMode, MutexScope};
    use haz_domain::name::{MutexName, TaskName};
    use haz_domain::path::{InputSpec, OutputSpec};
    use haz_domain::task::Task;
    use haz_domain::task_ref::TaskRef;
    use nonempty::NonEmpty;

    use super::*;

    fn argv(parts: &[&str]) -> NonEmpty<String> {
        NonEmpty::from_vec(parts.iter().map(|s| (*s).to_owned()).collect()).unwrap()
    }

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

    #[test]
    fn shortcut_variants_are_distinct() {
        assert_ne!(BooleanShortcut::HasDeps, BooleanShortcut::NoDeps);
        assert_ne!(BooleanShortcut::HasInputs, BooleanShortcut::NoInputs);
        assert_ne!(BooleanShortcut::HasOutputs, BooleanShortcut::NoOutputs);
        assert_ne!(BooleanShortcut::HasDeps, BooleanShortcut::HasInputs);
        assert_ne!(BooleanShortcut::Mutex, BooleanShortcut::HasDeps);
    }

    #[test]
    fn shortcut_variants_are_copy_and_hashable() {
        use std::collections::HashSet;
        let a = BooleanShortcut::HasDeps;
        let b = a;
        let mut set: HashSet<BooleanShortcut> = HashSet::new();
        set.insert(a);
        set.insert(b);
        assert_eq!(set.len(), 1);
    }

    // --- QRY-005 matches() ----------------------------------

    #[test]
    fn qry_005_has_deps_and_no_deps_are_mutually_negating() {
        let mut task = bare_task("t");
        // Initially deps is empty.
        assert!(!BooleanShortcut::HasDeps.matches(&task));
        assert!(BooleanShortcut::NoDeps.matches(&task));

        task.deps.push(TaskRef::parse("~:other").unwrap());
        assert!(BooleanShortcut::HasDeps.matches(&task));
        assert!(!BooleanShortcut::NoDeps.matches(&task));
    }

    #[test]
    fn qry_005_has_inputs_and_no_inputs_check_inputs_field() {
        let mut task = bare_task("t");
        assert!(BooleanShortcut::NoInputs.matches(&task));
        assert!(!BooleanShortcut::HasInputs.matches(&task));

        task.inputs.push(InputSpec::parse("src/main.rs").unwrap());
        assert!(BooleanShortcut::HasInputs.matches(&task));
        assert!(!BooleanShortcut::NoInputs.matches(&task));
    }

    #[test]
    fn qry_005_has_outputs_and_no_outputs_check_outputs_field() {
        let mut task = bare_task("t");
        assert!(BooleanShortcut::NoOutputs.matches(&task));
        assert!(!BooleanShortcut::HasOutputs.matches(&task));

        task.outputs
            .push(OutputSpec::parse("dist/bundle.js").unwrap());
        assert!(BooleanShortcut::HasOutputs.matches(&task));
        assert!(!BooleanShortcut::NoOutputs.matches(&task));
    }

    #[test]
    fn qry_005_mutex_checks_mutex_field_presence() {
        let mut task = bare_task("t");
        assert!(!BooleanShortcut::Mutex.matches(&task));

        task.mutex = Some(Mutex {
            scope: MutexScope::Workspace,
            name: MutexName::from_str("db").unwrap(),
            mode: MutexMode::Exclusive,
        });
        assert!(BooleanShortcut::Mutex.matches(&task));
    }
}