Skip to main content

haz_query/engine/
mod.rs

1//! Query-engine entry point and module map.
2//!
3//! `engine::execute` takes a loaded workspace, its validated task
4//! graph, the bearing project (if any), and a typed
5//! [`spec::QuerySpec`] covering every filter family (`QRY-003`
6//! per-attribute, `QRY-004` relational, `QRY-005` boolean
7//! shortcuts), and returns the matching tasks' `TaskId`s in
8//! canonical `(ProjectName, TaskName)` order per `QRY-007`.
9//!
10//! `execute_non_relational` is a convenience wrapper that uses
11//! an empty graph; it is appropriate only for specs that contain
12//! no relational filters.
13
14pub mod candidate;
15pub mod filter;
16pub mod relational;
17pub mod spec;
18
19use haz_dag::graph::TaskGraph;
20use haz_domain::name::ProjectName;
21use haz_domain::task_id::TaskId;
22use haz_domain::workspace::Workspace;
23
24use crate::engine::candidate::collect_candidates;
25use crate::engine::filter::passes_non_relational;
26use crate::engine::relational::{RelationalTargets, passes_relational};
27use crate::engine::spec::{QueryError, QuerySpec};
28
29/// Run the engine against a workspace and return the matching
30/// task identities in canonical `(ProjectName, TaskName)` order.
31///
32/// Evaluates every filter family of `QRY-003`..`QRY-005` against
33/// the candidate set determined by `bearing_project` per
34/// `QRY`'s candidate-set vocabulary. Filters compose by
35/// intersection per `QRY-006`.
36///
37/// `graph` MUST be the validated hard-edge graph (`DAG-008`) of
38/// `workspace`; the engine consults it only to evaluate the four
39/// `QRY-004` relational filters. Callers without any relational
40/// filter in `spec` MAY pass `&TaskGraph::default()` or use
41/// [`execute_non_relational`].
42///
43/// `bearing_project` is the cwd-derived bearing project name per
44/// `EXEC-022`. It restricts the candidate set per `QRY`
45/// vocabulary and supplies the canonicalisation root for any
46/// project-relative `--inputs` / `--outputs` atoms.
47///
48/// # Errors
49///
50/// Returns [`QueryError`] when the bearing project does not
51/// exist, when a project-relative atom is supplied without a
52/// bearing project, or when the glob-glob intersection routine
53/// fails.
54pub fn execute(
55    workspace: &Workspace,
56    graph: &TaskGraph,
57    bearing_project: Option<&ProjectName>,
58    spec: &QuerySpec,
59) -> Result<Vec<TaskId>, QueryError> {
60    let candidates = collect_candidates(workspace, bearing_project)?;
61    let bearing_root = bearing_project
62        .and_then(|name| workspace.projects.get(name))
63        .map(|p| &p.root);
64    let targets = RelationalTargets::from_spec(workspace, spec);
65
66    let mut selected: Vec<TaskId> = Vec::new();
67    for candidate in &candidates {
68        if !passes_non_relational(candidate, spec, bearing_root)? {
69            continue;
70        }
71        let candidate_id = TaskId {
72            project: candidate.project_name.clone(),
73            task: candidate.task_name.clone(),
74        };
75        if !passes_relational(graph, &candidate_id, &targets) {
76            continue;
77        }
78        selected.push(candidate_id);
79    }
80    Ok(selected)
81}
82
83/// Convenience wrapper for [`execute`] for specs that contain no
84/// relational filters. Passes an empty graph; if any relational
85/// filter is set on `spec`, that filter's target set will be
86/// computed but the relation check will always fail (no edges to
87/// traverse), producing zero matches. New code SHOULD use
88/// [`execute`] directly.
89///
90/// # Errors
91///
92/// Same as [`execute`].
93pub fn execute_non_relational(
94    workspace: &Workspace,
95    bearing_project: Option<&ProjectName>,
96    spec: &QuerySpec,
97) -> Result<Vec<TaskId>, QueryError> {
98    let graph = TaskGraph::default();
99    execute(workspace, &graph, bearing_project, spec)
100}
101
102#[cfg(test)]
103mod tests {
104    use std::collections::{BTreeMap, BTreeSet};
105    use std::path::PathBuf;
106    use std::str::FromStr;
107
108    use haz_dag::edge::{Edge, EdgeKind};
109    use haz_dag::graph::TaskGraph;
110    use haz_domain::action::TaskAction;
111    use haz_domain::env::EnvSettings;
112    use haz_domain::mutex::{Mutex, MutexMode, MutexScope};
113    use haz_domain::name::{MutexName, ProjectName, TagName, TaskName};
114    use haz_domain::path::{
115        CanonicalPath, HazPath, InputSpec, OutputSpec, PathPattern, ProjectRoot, WorkspaceRootPath,
116    };
117    use haz_domain::project::Project;
118    use haz_domain::settings::WorkspaceSettings;
119    use haz_domain::task::Task;
120    use haz_domain::task_id::TaskId;
121    use haz_domain::workspace::Workspace;
122    use haz_query_lang::expr::{Expr, RawAtom};
123    use haz_query_lang::span::Span;
124    use nonempty::NonEmpty;
125
126    use super::*;
127    use crate::expr::relational::{RelationalAtom, parse_relational_atom};
128    use crate::expr::shortcut::BooleanShortcut;
129
130    fn argv(parts: &[&str]) -> NonEmpty<String> {
131        NonEmpty::from_vec(parts.iter().map(|s| (*s).to_owned()).collect()).unwrap()
132    }
133
134    fn task(name: &str, inputs: &[&str], outputs: &[&str], with_mutex: bool) -> Task {
135        Task {
136            name: TaskName::from_str(name).unwrap(),
137            action: TaskAction::Command(argv(&["true"])),
138            inputs: inputs
139                .iter()
140                .map(|s| InputSpec::parse(s).unwrap())
141                .collect(),
142            outputs: outputs
143                .iter()
144                .map(|s| OutputSpec::parse(s).unwrap())
145                .collect(),
146            deps: vec![],
147            weak_deps: vec![],
148            mutex: if with_mutex {
149                Some(Mutex {
150                    scope: MutexScope::Workspace,
151                    name: MutexName::from_str("db").unwrap(),
152                    mode: MutexMode::Exclusive,
153                })
154            } else {
155                None
156            },
157            env: EnvSettings::default(),
158        }
159    }
160
161    fn project(name: &str, root: &str, tags: &[&str], tasks: Vec<Task>) -> Project {
162        let mut task_map = BTreeMap::new();
163        for t in tasks {
164            task_map.insert(t.name.clone(), t);
165        }
166        Project {
167            name: ProjectName::from_str(name).unwrap(),
168            root: ProjectRoot::Nested(
169                CanonicalPath::from_absolute(&HazPath::parse(root).unwrap()).unwrap(),
170            ),
171            tags: tags
172                .iter()
173                .map(|t| TagName::from_str(t).unwrap())
174                .collect::<BTreeSet<_>>(),
175            tasks: task_map,
176        }
177    }
178
179    fn workspace(projects: Vec<Project>) -> Workspace {
180        let mut map = BTreeMap::new();
181        for project in projects {
182            map.insert(project.name.clone(), project);
183        }
184        Workspace {
185            root: WorkspaceRootPath::try_new(PathBuf::from("/abs/workspace")).unwrap(),
186            projects: map,
187            overlays: BTreeMap::new(),
188            settings: WorkspaceSettings::default(),
189        }
190    }
191
192    fn ids(names: &[(&str, &str)]) -> Vec<haz_domain::task_id::TaskId> {
193        names
194            .iter()
195            .map(|(p, t)| haz_domain::task_id::TaskId {
196                project: ProjectName::from_str(p).unwrap(),
197                task: TaskName::from_str(t).unwrap(),
198            })
199            .collect()
200    }
201
202    fn tag_atom(s: &str) -> Expr<TagName> {
203        Expr::Atom(TagName::from_str(s).unwrap())
204    }
205
206    fn project_atom(s: &str) -> Expr<ProjectName> {
207        Expr::Atom(ProjectName::from_str(s).unwrap())
208    }
209
210    fn task_atom(s: &str) -> Expr<TaskName> {
211        Expr::Atom(TaskName::from_str(s).unwrap())
212    }
213
214    fn pattern_atom(s: &str) -> Expr<PathPattern> {
215        Expr::Atom(PathPattern::parse(s).unwrap())
216    }
217
218    fn three_project_workspace() -> Workspace {
219        workspace(vec![
220            project(
221                "lib",
222                "/lib",
223                &["backend", "rust"],
224                vec![
225                    task("build", &["src/**/*.rs"], &["target/lib.so"], false),
226                    task("test", &["src/**/*.rs"], &[], true),
227                ],
228            ),
229            project(
230                "web",
231                "/web",
232                &["frontend"],
233                vec![task("bundle", &["src/index.ts"], &["dist/app.js"], false)],
234            ),
235            project(
236                "tools",
237                "/tools",
238                &["backend"],
239                vec![task("lint", &[], &[], false)],
240            ),
241        ])
242    }
243
244    // --- QRY-007: empty spec returns the candidate set --------
245
246    #[test]
247    fn qry_007_empty_spec_returns_full_workspace_candidate_set() {
248        let ws = three_project_workspace();
249        let selected = execute_non_relational(&ws, None, &QuerySpec::default()).unwrap();
250        assert_eq!(
251            selected,
252            ids(&[
253                ("lib", "build"),
254                ("lib", "test"),
255                ("tools", "lint"),
256                ("web", "bundle"),
257            ]),
258        );
259    }
260
261    #[test]
262    fn qry_007_empty_spec_with_bearing_project_restricts_to_that_project() {
263        let ws = three_project_workspace();
264        let bearing = ProjectName::from_str("lib").unwrap();
265        let selected = execute_non_relational(&ws, Some(&bearing), &QuerySpec::default()).unwrap();
266        assert_eq!(selected, ids(&[("lib", "build"), ("lib", "test")]));
267    }
268
269    // --- QRY-003 per-attribute (identifier) -------------------
270
271    #[test]
272    fn qry_003_tags_filter_selects_only_tagged_projects() {
273        let ws = three_project_workspace();
274        let spec = QuerySpec {
275            tags: Some(tag_atom("backend")),
276            ..QuerySpec::default()
277        };
278        let selected = execute_non_relational(&ws, None, &spec).unwrap();
279        assert_eq!(
280            selected,
281            ids(&[("lib", "build"), ("lib", "test"), ("tools", "lint")]),
282        );
283    }
284
285    #[test]
286    fn qry_003_projects_filter_selects_only_matching_project() {
287        let ws = three_project_workspace();
288        let spec = QuerySpec {
289            projects: Some(project_atom("web")),
290            ..QuerySpec::default()
291        };
292        let selected = execute_non_relational(&ws, None, &spec).unwrap();
293        assert_eq!(selected, ids(&[("web", "bundle")]));
294    }
295
296    #[test]
297    fn qry_003_tasks_filter_selects_only_tasks_with_that_name() {
298        let ws = three_project_workspace();
299        let spec = QuerySpec {
300            tasks: Some(task_atom("build")),
301            ..QuerySpec::default()
302        };
303        let selected = execute_non_relational(&ws, None, &spec).unwrap();
304        assert_eq!(selected, ids(&[("lib", "build")]));
305    }
306
307    #[test]
308    fn qry_003_combined_per_attribute_filters_intersect() {
309        let ws = three_project_workspace();
310        let spec = QuerySpec {
311            tags: Some(tag_atom("backend")),
312            tasks: Some(task_atom("test")),
313            ..QuerySpec::default()
314        };
315        let selected = execute_non_relational(&ws, None, &spec).unwrap();
316        assert_eq!(selected, ids(&[("lib", "test")]));
317    }
318
319    // --- QRY-002 boolean operators in atoms ------------------
320
321    #[test]
322    fn qry_002_tag_expression_with_negation_evaluates() {
323        let ws = three_project_workspace();
324        // tags: backend & !frontend  -> backend-tagged but not frontend-tagged
325        let spec = QuerySpec {
326            tags: Some(Expr::And(
327                Box::new(tag_atom("backend")),
328                Box::new(Expr::Not(Box::new(tag_atom("frontend")))),
329            )),
330            ..QuerySpec::default()
331        };
332        let selected = execute_non_relational(&ws, None, &spec).unwrap();
333        assert_eq!(
334            selected,
335            ids(&[("lib", "build"), ("lib", "test"), ("tools", "lint")]),
336        );
337    }
338
339    // --- QRY-003 inputs / outputs with canonicalisation -------
340
341    #[test]
342    fn qry_003_inputs_workspace_absolute_atom_matches_canonicalised_task_pattern() {
343        let ws = three_project_workspace();
344        let spec = QuerySpec {
345            inputs: Some(pattern_atom("/lib/src/main.rs")),
346            ..QuerySpec::default()
347        };
348        let selected = execute_non_relational(&ws, None, &spec).unwrap();
349        // lib's build + test both declare `src/**/*.rs`, which
350        // canonicalises to `/lib/src/**/*.rs` and matches the
351        // user's literal.
352        assert_eq!(selected, ids(&[("lib", "build"), ("lib", "test")]));
353    }
354
355    #[test]
356    fn qry_003_inputs_disjoint_workspace_root_excludes_other_projects() {
357        let ws = three_project_workspace();
358        let spec = QuerySpec {
359            inputs: Some(pattern_atom("/web/src/index.ts")),
360            ..QuerySpec::default()
361        };
362        let selected = execute_non_relational(&ws, None, &spec).unwrap();
363        assert_eq!(selected, ids(&[("web", "bundle")]));
364    }
365
366    #[test]
367    fn qry_003_outputs_glob_atom_matches_literal_task_output() {
368        let ws = three_project_workspace();
369        let spec = QuerySpec {
370            outputs: Some(pattern_atom("/lib/target/*.so")),
371            ..QuerySpec::default()
372        };
373        let selected = execute_non_relational(&ws, None, &spec).unwrap();
374        assert_eq!(selected, ids(&[("lib", "build")]));
375    }
376
377    #[test]
378    fn qry_003_inputs_project_relative_atom_with_bearing_project() {
379        let ws = three_project_workspace();
380        let bearing = ProjectName::from_str("lib").unwrap();
381        let spec = QuerySpec {
382            // User typed `src/main.rs` from inside `lib`.
383            inputs: Some(pattern_atom("src/main.rs")),
384            ..QuerySpec::default()
385        };
386        let selected = execute_non_relational(&ws, Some(&bearing), &spec).unwrap();
387        assert_eq!(selected, ids(&[("lib", "build"), ("lib", "test")]));
388    }
389
390    #[test]
391    fn qry_003_inputs_project_relative_atom_without_bearing_project_errors() {
392        let ws = three_project_workspace();
393        let spec = QuerySpec {
394            inputs: Some(pattern_atom("src/main.rs")),
395            ..QuerySpec::default()
396        };
397        let err = execute_non_relational(&ws, None, &spec).unwrap_err();
398        match err {
399            QueryError::CanonicalisePattern { canonical } => assert_eq!(canonical, "src/main.rs"),
400            other => panic!("expected CanonicalisePattern, got {other:?}"),
401        }
402    }
403
404    // --- QRY-005 shortcuts ------------------------------------
405
406    #[test]
407    fn qry_005_no_inputs_shortcut_selects_input_less_tasks() {
408        let ws = three_project_workspace();
409        let spec = QuerySpec {
410            shortcuts: vec![BooleanShortcut::NoInputs],
411            ..QuerySpec::default()
412        };
413        let selected = execute_non_relational(&ws, None, &spec).unwrap();
414        assert_eq!(selected, ids(&[("tools", "lint")]));
415    }
416
417    #[test]
418    fn qry_005_mutex_shortcut_selects_only_mutex_tasks() {
419        let ws = three_project_workspace();
420        let spec = QuerySpec {
421            shortcuts: vec![BooleanShortcut::Mutex],
422            ..QuerySpec::default()
423        };
424        let selected = execute_non_relational(&ws, None, &spec).unwrap();
425        assert_eq!(selected, ids(&[("lib", "test")]));
426    }
427
428    #[test]
429    fn qry_006_combining_multiple_filter_families_intersects() {
430        let ws = three_project_workspace();
431        let spec = QuerySpec {
432            tags: Some(tag_atom("backend")),
433            shortcuts: vec![BooleanShortcut::Mutex],
434            ..QuerySpec::default()
435        };
436        let selected = execute_non_relational(&ws, None, &spec).unwrap();
437        assert_eq!(selected, ids(&[("lib", "test")]));
438    }
439
440    // --- QRY-004 relational filters ---------------------------
441
442    fn task_id(project: &str, task: &str) -> TaskId {
443        TaskId {
444            project: ProjectName::from_str(project).unwrap(),
445            task: TaskName::from_str(task).unwrap(),
446        }
447    }
448
449    fn relational_atom(text: &str) -> Expr<RelationalAtom> {
450        Expr::Atom(
451            parse_relational_atom(RawAtom {
452                text: text.to_owned(),
453                span: Span { start: 0, end: 0 },
454            })
455            .unwrap(),
456        )
457    }
458
459    /// Graph aligned with [`three_project_workspace`]:
460    /// `lib:build -> lib:test`, `lib:build -> web:bundle`,
461    /// `lib:test -> tools:lint`.
462    fn three_project_hard_edge_graph() -> TaskGraph {
463        let pairs = [
464            (task_id("lib", "build"), task_id("lib", "test")),
465            (task_id("lib", "build"), task_id("web", "bundle")),
466            (task_id("lib", "test"), task_id("tools", "lint")),
467        ];
468        let mut nodes: BTreeSet<TaskId> = BTreeSet::new();
469        let mut edges: BTreeSet<Edge> = BTreeSet::new();
470        for (from, to) in &pairs {
471            nodes.insert(from.clone());
472            nodes.insert(to.clone());
473            edges.insert(Edge {
474                from: from.clone(),
475                to: to.clone(),
476                kind: EdgeKind::Hard,
477            });
478        }
479        TaskGraph { nodes, edges }
480    }
481
482    #[test]
483    fn qry_004_child_of_selects_direct_successors_of_target_set() {
484        let ws = three_project_workspace();
485        let graph = three_project_hard_edge_graph();
486        let spec = QuerySpec {
487            child_of: Some(relational_atom("name:build")),
488            ..QuerySpec::default()
489        };
490        let selected = execute(&ws, &graph, None, &spec).unwrap();
491        assert_eq!(selected, ids(&[("lib", "test"), ("web", "bundle")]));
492    }
493
494    #[test]
495    fn qry_004_parent_of_selects_direct_predecessors_of_target_set() {
496        let ws = three_project_workspace();
497        let graph = three_project_hard_edge_graph();
498        let spec = QuerySpec {
499            parent_of: Some(relational_atom("name:test")),
500            ..QuerySpec::default()
501        };
502        let selected = execute(&ws, &graph, None, &spec).unwrap();
503        assert_eq!(selected, ids(&[("lib", "build")]));
504    }
505
506    #[test]
507    fn qry_004_depends_on_traverses_transitively() {
508        let ws = three_project_workspace();
509        let graph = three_project_hard_edge_graph();
510        let spec = QuerySpec {
511            depends_on: Some(relational_atom("name:build")),
512            ..QuerySpec::default()
513        };
514        let selected = execute(&ws, &graph, None, &spec).unwrap();
515        // lib:test depends on lib:build directly; tools:lint
516        // transitively (lib:build -> lib:test -> tools:lint);
517        // web:bundle directly. lib:build itself is in target_set
518        // and excluded.
519        assert_eq!(
520            selected,
521            ids(&[("lib", "test"), ("tools", "lint"), ("web", "bundle")]),
522        );
523    }
524
525    #[test]
526    fn qry_004_ancestor_of_traverses_transitively() {
527        let ws = three_project_workspace();
528        let graph = three_project_hard_edge_graph();
529        let spec = QuerySpec {
530            ancestor_of: Some(relational_atom("name:lint")),
531            ..QuerySpec::default()
532        };
533        let selected = execute(&ws, &graph, None, &spec).unwrap();
534        // tools:lint <- lib:test <- lib:build, so lib:build and
535        // lib:test are ancestors; tools:lint itself is in
536        // target_set and excluded.
537        assert_eq!(selected, ids(&[("lib", "build"), ("lib", "test")]));
538    }
539
540    #[test]
541    fn qry_004_depends_on_excludes_tasks_in_target_set() {
542        let ws = three_project_workspace();
543        let graph = three_project_hard_edge_graph();
544        let spec = QuerySpec {
545            depends_on: Some(relational_atom("project:lib")),
546            ..QuerySpec::default()
547        };
548        let selected = execute(&ws, &graph, None, &spec).unwrap();
549        // target_set = {lib:build, lib:test}. lib:test depends on
550        // lib:build, but is itself in target_set and so is
551        // excluded by the "minus EXPR-matching tasks themselves"
552        // clause. tools:lint and web:bundle stand.
553        assert_eq!(selected, ids(&[("tools", "lint"), ("web", "bundle")]));
554    }
555
556    #[test]
557    fn qry_004_relational_filter_intersects_with_per_attribute() {
558        let ws = three_project_workspace();
559        let graph = three_project_hard_edge_graph();
560        let spec = QuerySpec {
561            child_of: Some(relational_atom("name:build")),
562            tags: Some(tag_atom("frontend")),
563            ..QuerySpec::default()
564        };
565        let selected = execute(&ws, &graph, None, &spec).unwrap();
566        // child_of name:build => {lib:test, web:bundle}.
567        // tag:frontend => {web:bundle}.
568        // Intersection: {web:bundle}.
569        assert_eq!(selected, ids(&[("web", "bundle")]));
570    }
571
572    #[test]
573    fn qry_004_empty_target_set_yields_zero_matches() {
574        let ws = three_project_workspace();
575        let graph = three_project_hard_edge_graph();
576        let spec = QuerySpec {
577            child_of: Some(relational_atom("name:absent")),
578            ..QuerySpec::default()
579        };
580        let selected = execute(&ws, &graph, None, &spec).unwrap();
581        assert!(selected.is_empty());
582    }
583
584    #[test]
585    fn qry_004_relational_with_boolean_composition_in_atom() {
586        let ws = three_project_workspace();
587        let graph = three_project_hard_edge_graph();
588        // child_of (name:build | name:test) => direct successors
589        // of {lib:build, lib:test} = {lib:test, web:bundle,
590        // tools:lint}.
591        let spec = QuerySpec {
592            child_of: Some(Expr::Or(
593                Box::new(relational_atom("name:build")),
594                Box::new(relational_atom("name:test")),
595            )),
596            ..QuerySpec::default()
597        };
598        let selected = execute(&ws, &graph, None, &spec).unwrap();
599        assert_eq!(
600            selected,
601            ids(&[("lib", "test"), ("tools", "lint"), ("web", "bundle")]),
602        );
603    }
604
605    #[test]
606    fn qry_004_combined_relational_filters_intersect() {
607        let ws = three_project_workspace();
608        let graph = three_project_hard_edge_graph();
609        // depends_on name:build => {lib:test, web:bundle, tools:lint}
610        // ancestor_of name:lint => {lib:build, lib:test}
611        // Intersection: {lib:test}.
612        let spec = QuerySpec {
613            depends_on: Some(relational_atom("name:build")),
614            ancestor_of: Some(relational_atom("name:lint")),
615            ..QuerySpec::default()
616        };
617        let selected = execute(&ws, &graph, None, &spec).unwrap();
618        assert_eq!(selected, ids(&[("lib", "test")]));
619    }
620
621    #[test]
622    fn execute_with_no_relational_filter_matches_non_relational_entry() {
623        let ws = three_project_workspace();
624        let graph = three_project_hard_edge_graph();
625        let spec = QuerySpec {
626            tags: Some(tag_atom("backend")),
627            ..QuerySpec::default()
628        };
629        let with_graph = execute(&ws, &graph, None, &spec).unwrap();
630        let without_graph = execute_non_relational(&ws, None, &spec).unwrap();
631        assert_eq!(with_graph, without_graph);
632    }
633}