Skip to main content

haz_query/engine/
filter.rs

1//! Per-task evaluation of the non-relational filters of
2//! `QRY-003` and `QRY-005`.
3//!
4//! The engine canonicalises every path pattern to its
5//! workspace-absolute form before intersecting, so the
6//! `--inputs` / `--outputs` predicates agree with `DAG-013`'s
7//! producer-matching semantics regardless of whether the user
8//! typed a workspace-absolute or project-relative atom.
9//!
10//! Relational filters (`--child-of` etc.) need graph traversal
11//! and live in a sibling module.
12
13use haz_dag::producer::anchor_to_workspace_absolute;
14use haz_domain::path::{InputSpec, OutputSpec, PathPattern, ProjectRoot};
15use haz_query_lang::expr::Expr;
16
17use crate::engine::candidate::CandidateTask;
18use crate::engine::spec::{QueryError, QuerySpec};
19use crate::expr::path;
20
21/// Evaluate every non-relational filter in `spec` against
22/// `candidate`. Returns `Ok(true)` iff the candidate satisfies
23/// every active filter (or no non-relational filter is set).
24///
25/// The `bearing_project_root` is the cwd-derived bearing
26/// project's root per `EXEC-022`. It is consulted only when the
27/// user typed a project-relative path-pattern atom for
28/// `--inputs` / `--outputs`; absent bearing project together
29/// with a project-relative atom is a typed error.
30///
31/// # Errors
32///
33/// Returns [`QueryError`] when path-pattern canonicalisation
34/// fails (project-relative atom without bearing context, or
35/// re-parse regression) or when the glob-glob intersection
36/// routine fails for one of the patterns.
37pub fn passes_non_relational(
38    candidate: &CandidateTask<'_>,
39    spec: &QuerySpec,
40    bearing_project_root: Option<&ProjectRoot>,
41) -> Result<bool, QueryError> {
42    if let Some(expr) = &spec.tags
43        && !expr.eval(|tag| candidate.project.tags.contains(tag))
44    {
45        return Ok(false);
46    }
47
48    if let Some(expr) = &spec.projects
49        && !expr.eval(|name| candidate.project_name == name)
50    {
51        return Ok(false);
52    }
53
54    if let Some(expr) = &spec.tasks
55        && !expr.eval(|name| candidate.task_name == name)
56    {
57        return Ok(false);
58    }
59
60    if let Some(expr) = &spec.inputs {
61        let task_canonical = canonicalise_task_patterns(
62            candidate.task.inputs.iter().map(InputSpec::pattern),
63            &candidate.project.root,
64        )?;
65        let matches = pattern_expr_matches(expr, &task_canonical, bearing_project_root)?;
66        if !matches {
67            return Ok(false);
68        }
69    }
70
71    if let Some(expr) = &spec.outputs {
72        let task_canonical = canonicalise_task_patterns(
73            candidate.task.outputs.iter().map(OutputSpec::pattern),
74            &candidate.project.root,
75        )?;
76        let matches = pattern_expr_matches(expr, &task_canonical, bearing_project_root)?;
77        if !matches {
78            return Ok(false);
79        }
80    }
81
82    for shortcut in &spec.shortcuts {
83        if !shortcut.matches(candidate.task) {
84            return Ok(false);
85        }
86    }
87
88    Ok(true)
89}
90
91/// Canonicalise a sequence of task-declared patterns to their
92/// workspace-absolute form using the task's owning project root.
93///
94/// Returns the canonical [`PathPattern`]s in input order.
95fn canonicalise_task_patterns<'p>(
96    patterns: impl Iterator<Item = &'p PathPattern>,
97    project_root: &ProjectRoot,
98) -> Result<Vec<PathPattern>, QueryError> {
99    patterns
100        .map(|pattern| canonicalise(pattern, project_root))
101        .collect()
102}
103
104/// Evaluate a user-supplied `Expr<PathPattern>` against a task's
105/// canonicalised pattern list. The atom matches iff any task
106/// pattern intersects it (canonicalisation makes the comparison
107/// coordinate-system-correct).
108fn pattern_expr_matches(
109    expr: &Expr<PathPattern>,
110    task_canonical: &[PathPattern],
111    bearing_project_root: Option<&ProjectRoot>,
112) -> Result<bool, QueryError> {
113    expr.try_eval(|atom_pattern| {
114        let atom_canonical = canonicalise_user_atom(atom_pattern, bearing_project_root)?;
115        for task_pattern in task_canonical {
116            let hit = path::intersects(&atom_canonical, task_pattern)
117                .map_err(|source| QueryError::GlobIntersect { source })?;
118            if hit {
119                return Ok(true);
120            }
121        }
122        Ok(false)
123    })
124}
125
126/// Canonicalise a user-supplied atom against the bearing
127/// project's root. Workspace-absolute atoms pass through.
128/// Project-relative atoms without a bearing project are a
129/// typed error.
130fn canonicalise_user_atom(
131    atom: &PathPattern,
132    bearing_project_root: Option<&ProjectRoot>,
133) -> Result<PathPattern, QueryError> {
134    if matches!(
135        atom.anchor(),
136        haz_domain::path::PathAnchor::WorkspaceAbsolute
137    ) {
138        return Ok(atom.clone());
139    }
140    let Some(root) = bearing_project_root else {
141        return Err(QueryError::CanonicalisePattern {
142            canonical: atom.to_string(),
143        });
144    };
145    canonicalise(atom, root)
146}
147
148/// Canonicalise a single pattern against a known project root.
149fn canonicalise(
150    pattern: &PathPattern,
151    project_root: &ProjectRoot,
152) -> Result<PathPattern, QueryError> {
153    let canonical_string = anchor_to_workspace_absolute(pattern, project_root);
154    PathPattern::parse(&canonical_string).map_err(|_| QueryError::CanonicalisePattern {
155        canonical: canonical_string,
156    })
157}