Skip to main content

haz_query/expr/
shortcut.rs

1//! Boolean shortcut flags for `haz query` per `QRY-005`.
2//!
3//! The boolean shortcuts are flag-level predicates that carry
4//! no expression argument; the CLI flag itself is the entire
5//! filter. The engine evaluates each shortcut against every
6//! task in the candidate set independently.
7//!
8//! Within each pair (`--has-X` vs `--no-X`), the two flags are
9//! mutually exclusive on a single invocation; that constraint
10//! is enforced at the CLI-parser level, not at this type.
11
12use haz_domain::task::Task;
13
14/// A boolean shortcut filter per `QRY-005`.
15///
16/// Each variant maps 1:1 to a CLI flag. The variant identity
17/// captures everything the engine needs to evaluate the
18/// predicate against a task; no further parameters are
19/// required.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum BooleanShortcut {
22    /// `--has-deps`: the task's `deps:` is non-empty.
23    HasDeps,
24    /// `--no-deps`: the task's `deps:` is empty.
25    NoDeps,
26    /// `--has-inputs`: the task's `inputs:` is non-empty.
27    HasInputs,
28    /// `--no-inputs`: the task's `inputs:` is empty.
29    NoInputs,
30    /// `--has-outputs`: the task's `outputs:` is non-empty.
31    HasOutputs,
32    /// `--no-outputs`: the task's `outputs:` is empty.
33    NoOutputs,
34    /// `--mutex`: the task declares a `mutex:` field per
35    /// `CFG-019`.
36    Mutex,
37}
38
39impl BooleanShortcut {
40    /// Evaluate this shortcut against a task per `QRY-005`.
41    ///
42    /// Each predicate inspects exactly one structural field of
43    /// the task; no workspace or graph context is required.
44    #[must_use]
45    pub fn matches(&self, task: &Task) -> bool {
46        match self {
47            Self::HasDeps => !task.deps.is_empty(),
48            Self::NoDeps => task.deps.is_empty(),
49            Self::HasInputs => !task.inputs.is_empty(),
50            Self::NoInputs => task.inputs.is_empty(),
51            Self::HasOutputs => !task.outputs.is_empty(),
52            Self::NoOutputs => task.outputs.is_empty(),
53            Self::Mutex => task.mutex.is_some(),
54        }
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use std::str::FromStr;
61
62    use haz_domain::action::TaskAction;
63    use haz_domain::env::EnvSettings;
64    use haz_domain::mutex::{Mutex, MutexMode, MutexScope};
65    use haz_domain::name::{MutexName, TaskName};
66    use haz_domain::path::{InputSpec, OutputSpec};
67    use haz_domain::task::Task;
68    use haz_domain::task_ref::TaskRef;
69    use nonempty::NonEmpty;
70
71    use super::*;
72
73    fn argv(parts: &[&str]) -> NonEmpty<String> {
74        NonEmpty::from_vec(parts.iter().map(|s| (*s).to_owned()).collect()).unwrap()
75    }
76
77    fn bare_task(name: &str) -> Task {
78        Task {
79            name: TaskName::from_str(name).unwrap(),
80            action: TaskAction::Command(argv(&["true"])),
81            inputs: vec![],
82            outputs: vec![],
83            deps: vec![],
84            weak_deps: vec![],
85            mutex: None,
86            env: EnvSettings::default(),
87        }
88    }
89
90    #[test]
91    fn shortcut_variants_are_distinct() {
92        assert_ne!(BooleanShortcut::HasDeps, BooleanShortcut::NoDeps);
93        assert_ne!(BooleanShortcut::HasInputs, BooleanShortcut::NoInputs);
94        assert_ne!(BooleanShortcut::HasOutputs, BooleanShortcut::NoOutputs);
95        assert_ne!(BooleanShortcut::HasDeps, BooleanShortcut::HasInputs);
96        assert_ne!(BooleanShortcut::Mutex, BooleanShortcut::HasDeps);
97    }
98
99    #[test]
100    fn shortcut_variants_are_copy_and_hashable() {
101        use std::collections::HashSet;
102        let a = BooleanShortcut::HasDeps;
103        let b = a;
104        let mut set: HashSet<BooleanShortcut> = HashSet::new();
105        set.insert(a);
106        set.insert(b);
107        assert_eq!(set.len(), 1);
108    }
109
110    // --- QRY-005 matches() ----------------------------------
111
112    #[test]
113    fn qry_005_has_deps_and_no_deps_are_mutually_negating() {
114        let mut task = bare_task("t");
115        // Initially deps is empty.
116        assert!(!BooleanShortcut::HasDeps.matches(&task));
117        assert!(BooleanShortcut::NoDeps.matches(&task));
118
119        task.deps.push(TaskRef::parse("~:other").unwrap());
120        assert!(BooleanShortcut::HasDeps.matches(&task));
121        assert!(!BooleanShortcut::NoDeps.matches(&task));
122    }
123
124    #[test]
125    fn qry_005_has_inputs_and_no_inputs_check_inputs_field() {
126        let mut task = bare_task("t");
127        assert!(BooleanShortcut::NoInputs.matches(&task));
128        assert!(!BooleanShortcut::HasInputs.matches(&task));
129
130        task.inputs.push(InputSpec::parse("src/main.rs").unwrap());
131        assert!(BooleanShortcut::HasInputs.matches(&task));
132        assert!(!BooleanShortcut::NoInputs.matches(&task));
133    }
134
135    #[test]
136    fn qry_005_has_outputs_and_no_outputs_check_outputs_field() {
137        let mut task = bare_task("t");
138        assert!(BooleanShortcut::NoOutputs.matches(&task));
139        assert!(!BooleanShortcut::HasOutputs.matches(&task));
140
141        task.outputs
142            .push(OutputSpec::parse("dist/bundle.js").unwrap());
143        assert!(BooleanShortcut::HasOutputs.matches(&task));
144        assert!(!BooleanShortcut::NoOutputs.matches(&task));
145    }
146
147    #[test]
148    fn qry_005_mutex_checks_mutex_field_presence() {
149        let mut task = bare_task("t");
150        assert!(!BooleanShortcut::Mutex.matches(&task));
151
152        task.mutex = Some(Mutex {
153            scope: MutexScope::Workspace,
154            name: MutexName::from_str("db").unwrap(),
155            mode: MutexMode::Exclusive,
156        });
157        assert!(BooleanShortcut::Mutex.matches(&task));
158    }
159}