Skip to main content

haz_query/engine/
relational.rs

1//! Per-task evaluation of the relational filters of `QRY-004`.
2//!
3//! Each relational filter resolves a user expression to a *target
4//! set* (every task whose project/task/tag attributes satisfy the
5//! expression), then asks whether the candidate's hard-edge
6//! relation to that target set is non-empty.
7//!
8//! Direct relations (`--child-of`, `--parent-of`) read the
9//! candidate's direct hard-edge neighbours and intersect them with
10//! the target set. Transitive relations (`--depends-on`,
11//! `--ancestor-of`) BFS over hard edges; per `QRY-004` the result
12//! excludes the target set itself ("minus the EXPR-matching tasks
13//! themselves").
14//!
15//! Target sets are computed once per relational flag in
16//! [`RelationalTargets::from_spec`] and shared across all
17//! candidates; per-candidate work is then a set intersection or a
18//! BFS.
19
20use std::collections::BTreeSet;
21
22use haz_dag::graph::TaskGraph;
23use haz_dag::traversal::{
24    direct_predecessors, direct_successors, transitive_predecessors, transitive_successors,
25};
26use haz_domain::task_id::TaskId;
27use haz_domain::workspace::Workspace;
28use haz_query_lang::expr::Expr;
29
30use crate::engine::spec::QuerySpec;
31use crate::expr::relational::RelationalAtom;
32
33/// Target sets for every relational filter in a [`QuerySpec`].
34///
35/// A `None` field means the corresponding flag was not supplied;
36/// `Some(set)` means the flag was supplied and the contained set
37/// is its target set.
38#[derive(Debug, Default, Clone)]
39pub struct RelationalTargets {
40    /// Target set of `--child-of`.
41    pub child_of: Option<BTreeSet<TaskId>>,
42    /// Target set of `--parent-of`.
43    pub parent_of: Option<BTreeSet<TaskId>>,
44    /// Target set of `--depends-on`.
45    pub depends_on: Option<BTreeSet<TaskId>>,
46    /// Target set of `--ancestor-of`.
47    pub ancestor_of: Option<BTreeSet<TaskId>>,
48}
49
50impl RelationalTargets {
51    /// Compute the target set of every relational filter present
52    /// in `spec`. An absent filter yields a `None` field; the
53    /// computation is therefore proportional to the number of
54    /// supplied flags, never four full walks.
55    #[must_use]
56    pub fn from_spec(workspace: &Workspace, spec: &QuerySpec) -> Self {
57        Self {
58            child_of: spec
59                .child_of
60                .as_ref()
61                .map(|expr| compute_target_set(workspace, expr)),
62            parent_of: spec
63                .parent_of
64                .as_ref()
65                .map(|expr| compute_target_set(workspace, expr)),
66            depends_on: spec
67                .depends_on
68                .as_ref()
69                .map(|expr| compute_target_set(workspace, expr)),
70            ancestor_of: spec
71                .ancestor_of
72                .as_ref()
73                .map(|expr| compute_target_set(workspace, expr)),
74        }
75    }
76}
77
78/// Compute the target set of a relational expression: every task
79/// in the workspace whose `(project_name, task_name,
80/// project_tags)` triple satisfies `expr`.
81///
82/// Returned as a `BTreeSet`, which iterates in canonical
83/// `(ProjectName, TaskName)` order.
84#[must_use]
85pub fn compute_target_set(workspace: &Workspace, expr: &Expr<RelationalAtom>) -> BTreeSet<TaskId> {
86    let mut target_set: BTreeSet<TaskId> = BTreeSet::new();
87    for project in workspace.projects.values() {
88        for task_name in project.tasks.keys() {
89            let matches = expr.eval(|atom| atom.matches(&project.name, task_name, &project.tags));
90            if matches {
91                target_set.insert(TaskId {
92                    project: project.name.clone(),
93                    task: task_name.clone(),
94                });
95            }
96        }
97    }
98    target_set
99}
100
101/// Whether `candidate` passes every relational filter in
102/// `targets`. A `None` filter contributes no constraint; a
103/// `Some(set)` filter requires the appropriate hard-edge relation
104/// to that set to be non-empty.
105#[must_use]
106pub fn passes_relational(
107    graph: &TaskGraph,
108    candidate: &TaskId,
109    targets: &RelationalTargets,
110) -> bool {
111    if let Some(set) = &targets.child_of
112        && !passes_child_of(graph, candidate, set)
113    {
114        return false;
115    }
116    if let Some(set) = &targets.parent_of
117        && !passes_parent_of(graph, candidate, set)
118    {
119        return false;
120    }
121    if let Some(set) = &targets.depends_on
122        && !passes_depends_on(graph, candidate, set)
123    {
124        return false;
125    }
126    if let Some(set) = &targets.ancestor_of
127        && !passes_ancestor_of(graph, candidate, set)
128    {
129        return false;
130    }
131    true
132}
133
134/// `--child-of EXPR`: at least one direct hard-edge predecessor of
135/// `candidate` is in `targets` (the candidate's `deps:` names a
136/// target).
137#[must_use]
138pub fn passes_child_of(graph: &TaskGraph, candidate: &TaskId, targets: &BTreeSet<TaskId>) -> bool {
139    !direct_predecessors(graph, candidate).is_disjoint(targets)
140}
141
142/// `--parent-of EXPR`: at least one direct hard-edge successor of
143/// `candidate` is in `targets` (some target's `deps:` names the
144/// candidate).
145#[must_use]
146pub fn passes_parent_of(graph: &TaskGraph, candidate: &TaskId, targets: &BTreeSet<TaskId>) -> bool {
147    !direct_successors(graph, candidate).is_disjoint(targets)
148}
149
150/// `--depends-on EXPR`: at least one transitive hard-edge
151/// predecessor of `candidate` is in `targets` AND `candidate` is
152/// not itself in `targets` (per the "minus the EXPR-matching
153/// tasks themselves" clause of `QRY-004`).
154#[must_use]
155pub fn passes_depends_on(
156    graph: &TaskGraph,
157    candidate: &TaskId,
158    targets: &BTreeSet<TaskId>,
159) -> bool {
160    if targets.contains(candidate) {
161        return false;
162    }
163    !transitive_predecessors(graph, candidate).is_disjoint(targets)
164}
165
166/// `--ancestor-of EXPR`: at least one transitive hard-edge
167/// successor of `candidate` is in `targets` AND `candidate` is
168/// not itself in `targets` (per the "minus the EXPR-matching
169/// tasks themselves" clause of `QRY-004`).
170#[must_use]
171pub fn passes_ancestor_of(
172    graph: &TaskGraph,
173    candidate: &TaskId,
174    targets: &BTreeSet<TaskId>,
175) -> bool {
176    if targets.contains(candidate) {
177        return false;
178    }
179    !transitive_successors(graph, candidate).is_disjoint(targets)
180}
181
182#[cfg(test)]
183mod tests {
184    use std::collections::{BTreeMap, BTreeSet};
185    use std::path::PathBuf;
186    use std::str::FromStr;
187
188    use haz_dag::edge::{Edge, EdgeKind};
189    use haz_dag::graph::TaskGraph;
190    use haz_domain::action::TaskAction;
191    use haz_domain::env::EnvSettings;
192    use haz_domain::name::{ProjectName, TagName, TaskName};
193    use haz_domain::path::{CanonicalPath, HazPath, ProjectRoot, WorkspaceRootPath};
194    use haz_domain::project::Project;
195    use haz_domain::settings::WorkspaceSettings;
196    use haz_domain::task::Task;
197    use haz_domain::task_id::TaskId;
198    use haz_domain::workspace::Workspace;
199    use haz_query_lang::expr::Expr;
200    use nonempty::NonEmpty;
201
202    use super::*;
203
204    fn argv(parts: &[&str]) -> NonEmpty<String> {
205        NonEmpty::from_vec(parts.iter().map(|s| (*s).to_owned()).collect()).unwrap()
206    }
207
208    fn bare_task(name: &str) -> Task {
209        Task {
210            name: TaskName::from_str(name).unwrap(),
211            action: TaskAction::Command(argv(&["true"])),
212            inputs: vec![],
213            outputs: vec![],
214            deps: vec![],
215            weak_deps: vec![],
216            mutex: None,
217            env: EnvSettings::default(),
218        }
219    }
220
221    fn project(name: &str, root: &str, tags: &[&str], task_names: &[&str]) -> Project {
222        let mut tasks = BTreeMap::new();
223        for &task_name in task_names {
224            tasks.insert(TaskName::from_str(task_name).unwrap(), bare_task(task_name));
225        }
226        Project {
227            name: ProjectName::from_str(name).unwrap(),
228            root: ProjectRoot::Nested(
229                CanonicalPath::from_absolute(&HazPath::parse(root).unwrap()).unwrap(),
230            ),
231            tags: tags
232                .iter()
233                .map(|t| TagName::from_str(t).unwrap())
234                .collect::<BTreeSet<_>>(),
235            tasks,
236        }
237    }
238
239    fn workspace(projects: Vec<Project>) -> Workspace {
240        let mut map = BTreeMap::new();
241        for p in projects {
242            map.insert(p.name.clone(), p);
243        }
244        Workspace {
245            root: WorkspaceRootPath::try_new(PathBuf::from("/abs/workspace")).unwrap(),
246            projects: map,
247            overlays: BTreeMap::new(),
248            settings: WorkspaceSettings::default(),
249        }
250    }
251
252    fn id(project: &str, task: &str) -> TaskId {
253        TaskId {
254            project: ProjectName::from_str(project).unwrap(),
255            task: TaskName::from_str(task).unwrap(),
256        }
257    }
258
259    fn ids(items: &[(&str, &str)]) -> BTreeSet<TaskId> {
260        items.iter().map(|(p, t)| id(p, t)).collect()
261    }
262
263    fn graph_with_hard_edges(nodes: &[TaskId], edges: &[(TaskId, TaskId)]) -> TaskGraph {
264        TaskGraph {
265            nodes: nodes.iter().cloned().collect(),
266            edges: edges
267                .iter()
268                .map(|(from, to)| Edge {
269                    from: from.clone(),
270                    to: to.clone(),
271                    kind: EdgeKind::Hard,
272                })
273                .collect(),
274        }
275    }
276
277    fn relational_atom(text: &str) -> Expr<RelationalAtom> {
278        use haz_query_lang::expr::RawAtom;
279        use haz_query_lang::span::Span;
280
281        use crate::expr::relational::parse_relational_atom;
282        Expr::Atom(
283            parse_relational_atom(RawAtom {
284                text: text.to_owned(),
285                span: Span { start: 0, end: 0 },
286            })
287            .unwrap(),
288        )
289    }
290
291    // --- compute_target_set -------------------------------------
292
293    #[test]
294    fn target_set_for_name_atom_collects_matching_tasks_across_projects() {
295        let ws = workspace(vec![
296            project("lib", "/lib", &[], &["build", "test"]),
297            project("web", "/web", &[], &["build", "bundle"]),
298        ]);
299        let expr = relational_atom("name:build");
300        let target = compute_target_set(&ws, &expr);
301        assert_eq!(target, ids(&[("lib", "build"), ("web", "build")]));
302    }
303
304    #[test]
305    fn target_set_for_project_atom_collects_every_task_in_that_project() {
306        let ws = workspace(vec![
307            project("lib", "/lib", &[], &["build", "test"]),
308            project("web", "/web", &[], &["bundle"]),
309        ]);
310        let expr = relational_atom("project:lib");
311        let target = compute_target_set(&ws, &expr);
312        assert_eq!(target, ids(&[("lib", "build"), ("lib", "test")]));
313    }
314
315    #[test]
316    fn target_set_for_tag_atom_collects_tasks_under_tagged_projects() {
317        let ws = workspace(vec![
318            project("lib", "/lib", &["backend"], &["build"]),
319            project("tools", "/tools", &["backend"], &["lint"]),
320            project("web", "/web", &["frontend"], &["bundle"]),
321        ]);
322        let expr = relational_atom("tag:backend");
323        let target = compute_target_set(&ws, &expr);
324        assert_eq!(target, ids(&[("lib", "build"), ("tools", "lint")]));
325    }
326
327    #[test]
328    fn target_set_under_boolean_composition_intersects() {
329        let ws = workspace(vec![
330            project("lib", "/lib", &["backend"], &["build", "test"]),
331            project("web", "/web", &["frontend"], &["test"]),
332        ]);
333        // tag:backend & name:test  -> only lib:test
334        let expr = Expr::And(
335            Box::new(relational_atom("tag:backend")),
336            Box::new(relational_atom("name:test")),
337        );
338        let target = compute_target_set(&ws, &expr);
339        assert_eq!(target, ids(&[("lib", "test")]));
340    }
341
342    #[test]
343    fn target_set_is_empty_when_no_task_matches() {
344        let ws = workspace(vec![project("lib", "/lib", &[], &["build"])]);
345        let expr = relational_atom("name:absent");
346        let target = compute_target_set(&ws, &expr);
347        assert!(target.is_empty());
348    }
349
350    // --- passes_child_of / passes_parent_of ---------------------
351
352    #[test]
353    fn child_of_passes_when_direct_predecessor_in_target_set() {
354        // graph: a -> b
355        let a = id("p", "a");
356        let b = id("p", "b");
357        let graph = graph_with_hard_edges(&[a.clone(), b.clone()], &[(a.clone(), b.clone())]);
358        let targets = ids(&[("p", "a")]);
359        assert!(passes_child_of(&graph, &b, &targets));
360        assert!(!passes_child_of(&graph, &a, &targets));
361    }
362
363    #[test]
364    fn parent_of_passes_when_direct_successor_in_target_set() {
365        // graph: a -> b
366        let a = id("p", "a");
367        let b = id("p", "b");
368        let graph = graph_with_hard_edges(&[a.clone(), b.clone()], &[(a.clone(), b.clone())]);
369        let targets = ids(&[("p", "b")]);
370        assert!(passes_parent_of(&graph, &a, &targets));
371        assert!(!passes_parent_of(&graph, &b, &targets));
372    }
373
374    #[test]
375    fn child_of_rejects_when_target_is_only_transitive_predecessor() {
376        // graph: a -> b -> c, target = a
377        let a = id("p", "a");
378        let b = id("p", "b");
379        let c = id("p", "c");
380        let graph = graph_with_hard_edges(
381            &[a.clone(), b.clone(), c.clone()],
382            &[(a.clone(), b.clone()), (b, c.clone())],
383        );
384        let targets = ids(&[("p", "a")]);
385        // c's only direct predecessor is b, not a.
386        assert!(!passes_child_of(&graph, &c, &targets));
387    }
388
389    // --- passes_depends_on --------------------------------------
390
391    #[test]
392    fn depends_on_passes_when_transitive_predecessor_in_target_set() {
393        // graph: a -> b -> c, target = a
394        let a = id("p", "a");
395        let b = id("p", "b");
396        let c = id("p", "c");
397        let graph = graph_with_hard_edges(
398            &[a.clone(), b.clone(), c.clone()],
399            &[(a.clone(), b.clone()), (b.clone(), c.clone())],
400        );
401        let targets = ids(&[("p", "a")]);
402        assert!(passes_depends_on(&graph, &c, &targets));
403        assert!(passes_depends_on(&graph, &b, &targets));
404        // a is in target_set, so excluded by the QRY-004 clause.
405        assert!(!passes_depends_on(&graph, &a, &targets));
406    }
407
408    #[test]
409    fn depends_on_excludes_self_when_candidate_in_target_set() {
410        // graph: a -> b -> c, target = {a, c} (both endpoints).
411        // c is in targets, so c MUST be excluded from `depends-on
412        // targets` even though c transitively depends on a.
413        let a = id("p", "a");
414        let b = id("p", "b");
415        let c = id("p", "c");
416        let graph = graph_with_hard_edges(
417            &[a.clone(), b.clone(), c.clone()],
418            &[(a.clone(), b.clone()), (b.clone(), c.clone())],
419        );
420        let targets = ids(&[("p", "a"), ("p", "c")]);
421        assert!(!passes_depends_on(&graph, &c, &targets));
422        assert!(passes_depends_on(&graph, &b, &targets));
423    }
424
425    // --- passes_ancestor_of -------------------------------------
426
427    #[test]
428    fn ancestor_of_passes_when_transitive_successor_in_target_set() {
429        // graph: a -> b -> c, target = c
430        let a = id("p", "a");
431        let b = id("p", "b");
432        let c = id("p", "c");
433        let graph = graph_with_hard_edges(
434            &[a.clone(), b.clone(), c.clone()],
435            &[(a.clone(), b.clone()), (b.clone(), c.clone())],
436        );
437        let targets = ids(&[("p", "c")]);
438        assert!(passes_ancestor_of(&graph, &a, &targets));
439        assert!(passes_ancestor_of(&graph, &b, &targets));
440        // c is in target_set, excluded.
441        assert!(!passes_ancestor_of(&graph, &c, &targets));
442    }
443
444    #[test]
445    fn ancestor_of_excludes_self_when_candidate_in_target_set() {
446        let a = id("p", "a");
447        let b = id("p", "b");
448        let c = id("p", "c");
449        let graph = graph_with_hard_edges(
450            &[a.clone(), b.clone(), c.clone()],
451            &[(a.clone(), b.clone()), (b.clone(), c.clone())],
452        );
453        let targets = ids(&[("p", "a"), ("p", "c")]);
454        assert!(!passes_ancestor_of(&graph, &a, &targets));
455        assert!(passes_ancestor_of(&graph, &b, &targets));
456    }
457
458    // --- empty target set ---------------------------------------
459
460    #[test]
461    fn every_relation_fails_when_target_set_is_empty() {
462        let a = id("p", "a");
463        let b = id("p", "b");
464        let graph = graph_with_hard_edges(&[a.clone(), b.clone()], &[(a.clone(), b.clone())]);
465        let empty: BTreeSet<TaskId> = BTreeSet::new();
466        assert!(!passes_child_of(&graph, &b, &empty));
467        assert!(!passes_parent_of(&graph, &a, &empty));
468        assert!(!passes_depends_on(&graph, &b, &empty));
469        assert!(!passes_ancestor_of(&graph, &a, &empty));
470    }
471
472    // --- passes_relational composes the four arms ---------------
473
474    #[test]
475    fn passes_relational_returns_true_when_no_filter_set() {
476        let a = id("p", "a");
477        let graph = graph_with_hard_edges(std::slice::from_ref(&a), &[]);
478        let targets = RelationalTargets::default();
479        assert!(passes_relational(&graph, &a, &targets));
480    }
481
482    #[test]
483    fn passes_relational_short_circuits_on_first_failing_filter() {
484        // a -> b, candidate = b.
485        // child_of = {a} (passes), parent_of = {a} (fails: a has
486        // no successor relationship from b).
487        let a = id("p", "a");
488        let b = id("p", "b");
489        let graph = graph_with_hard_edges(&[a.clone(), b.clone()], &[(a.clone(), b.clone())]);
490        let targets = RelationalTargets {
491            child_of: Some(ids(&[("p", "a")])),
492            parent_of: Some(ids(&[("p", "a")])),
493            ..RelationalTargets::default()
494        };
495        assert!(!passes_relational(&graph, &b, &targets));
496    }
497}