Skip to main content

haz_query/expr/
relational.rs

1//! Relational atoms for `--child-of`, `--parent-of`,
2//! `--depends-on`, and `--ancestor-of`.
3//!
4//! Per `QRY-004`, each atom takes the textual form
5//! `<KIND>:<VALUE>` where `KIND` is one of the case-sensitive
6//! keywords `name`, `project`, `tag`, and `VALUE` is the
7//! matching identifier kind.
8//!
9//! Atoms compose under `|`, `&`, `!`, and `()` per `QRY-002`.
10//! Each atom is a per-task predicate identifying *target tasks*
11//! in the workspace; the relational filter flag (`--child-of`
12//! etc.) then matches every task whose relation to that target
13//! set is non-empty.
14
15use std::collections::BTreeSet;
16
17use haz_domain::name::{ProjectName, TagName, TaskName};
18use haz_query_lang::expr::RawAtom;
19
20use crate::expr::atom::AtomError;
21
22/// A typed relational atom per `QRY-004`.
23///
24/// Each variant carries the validated identifier that the atom's
25/// predicate compares against. The relational filter flag
26/// (`--child-of` etc.) decides whether the relation walked is
27/// direct or transitive.
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub enum RelationalAtom {
30    /// `name:<TaskName>`: the target task's name equals the
31    /// given identifier.
32    Name(TaskName),
33    /// `project:<ProjectName>`: the target task's owning project
34    /// name equals the given identifier.
35    Project(ProjectName),
36    /// `tag:<TagName>`: the target task's owning project carries
37    /// the given tag.
38    Tag(TagName),
39}
40
41impl RelationalAtom {
42    /// Evaluate this atom against a candidate target task per
43    /// `QRY-004`.
44    ///
45    /// `task_name` is the task's own name; `project_name` is the
46    /// task's owning project; `project_tags` is the project's
47    /// `tags:` set. Returns `true` iff the atom's predicate
48    /// matches the task.
49    #[must_use]
50    pub fn matches(
51        &self,
52        project_name: &ProjectName,
53        task_name: &TaskName,
54        project_tags: &BTreeSet<TagName>,
55    ) -> bool {
56        match self {
57            Self::Name(n) => task_name == n,
58            Self::Project(p) => project_name == p,
59            Self::Tag(t) => project_tags.contains(t),
60        }
61    }
62}
63
64/// Parse a raw atom as a [`RelationalAtom`] per `QRY-004`.
65///
66/// Designed for use with
67/// [`haz_query_lang::expr::Expr::try_map`] to lift an entire
68/// parsed expression to `Expr<RelationalAtom>` in one step.
69///
70/// # Errors
71///
72/// Returns:
73///
74/// - [`AtomError::MissingRelationalColon`] when the atom has
75///   no `:` separator.
76/// - [`AtomError::UnknownRelationalKind`] when the kind
77///   keyword is not exactly one of `name`, `project`, `tag`
78///   (case-sensitive).
79/// - [`AtomError::InvalidTaskName`] /
80///   [`AtomError::InvalidProjectName`] /
81///   [`AtomError::InvalidTagName`] when the value violates
82///   `ID-001..ID-005` for the corresponding kind.
83pub fn parse_relational_atom(atom: RawAtom) -> Result<RelationalAtom, AtomError> {
84    let RawAtom { text, span } = atom;
85    let Some((kind, value)) = text.split_once(':') else {
86        return Err(AtomError::MissingRelationalColon { span });
87    };
88    match kind {
89        "name" => TaskName::try_new(value)
90            .map(RelationalAtom::Name)
91            .map_err(|source| AtomError::InvalidTaskName { span, source }),
92        "project" => ProjectName::try_new(value)
93            .map(RelationalAtom::Project)
94            .map_err(|source| AtomError::InvalidProjectName { span, source }),
95        "tag" => TagName::try_new(value)
96            .map(RelationalAtom::Tag)
97            .map_err(|source| AtomError::InvalidTagName { span, source }),
98        unknown => Err(AtomError::UnknownRelationalKind {
99            span,
100            kind: unknown.to_owned(),
101        }),
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use haz_domain::name::NameError;
109    use haz_query_lang::expr::Expr;
110    use haz_query_lang::parser::parse;
111    use haz_query_lang::span::Span;
112
113    fn raw(text: &str, start: usize, end: usize) -> RawAtom {
114        RawAtom {
115            text: text.to_owned(),
116            span: Span { start, end },
117        }
118    }
119
120    // --- Happy paths per QRY-004 ------------------------------
121
122    #[test]
123    fn qry_004_parses_name_kind_atom() {
124        let atom = parse_relational_atom(raw("name:compile", 0, 12)).unwrap();
125        let expected = RelationalAtom::Name(TaskName::try_new("compile").unwrap());
126        assert_eq!(atom, expected);
127    }
128
129    #[test]
130    fn qry_004_parses_project_kind_atom() {
131        let atom = parse_relational_atom(raw("project:lib_core", 0, 16)).unwrap();
132        let expected = RelationalAtom::Project(ProjectName::try_new("lib_core").unwrap());
133        assert_eq!(atom, expected);
134    }
135
136    #[test]
137    fn qry_004_parses_tag_kind_atom() {
138        let atom = parse_relational_atom(raw("tag:backend", 0, 11)).unwrap();
139        let expected = RelationalAtom::Tag(TagName::try_new("backend").unwrap());
140        assert_eq!(atom, expected);
141    }
142
143    // --- Rejection: missing colon -----------------------------
144
145    #[test]
146    fn qry_004_rejects_atom_without_colon() {
147        let err = parse_relational_atom(raw("name", 4, 8)).unwrap_err();
148        match err {
149            AtomError::MissingRelationalColon { span } => {
150                assert_eq!(span, Span { start: 4, end: 8 });
151            }
152            other => panic!("expected MissingRelationalColon, got {other:?}"),
153        }
154    }
155
156    // --- Rejection: unknown kind ------------------------------
157
158    #[test]
159    fn qry_004_rejects_unknown_kind() {
160        let err = parse_relational_atom(raw("foo:bar", 0, 7)).unwrap_err();
161        match err {
162            AtomError::UnknownRelationalKind { span, kind } => {
163                assert_eq!(span, Span { start: 0, end: 7 });
164                assert_eq!(kind, "foo");
165            }
166            other => panic!("expected UnknownRelationalKind, got {other:?}"),
167        }
168    }
169
170    #[test]
171    fn qry_004_rejects_uppercase_kind_keyword() {
172        // Spec: "the kind keyword MUST be exactly one of the
173        // three listed (case-sensitive)".
174        let err = parse_relational_atom(raw("Name:compile", 0, 12)).unwrap_err();
175        match err {
176            AtomError::UnknownRelationalKind { kind, .. } => {
177                assert_eq!(kind, "Name");
178            }
179            other => panic!("expected UnknownRelationalKind, got {other:?}"),
180        }
181    }
182
183    #[test]
184    fn qry_004_rejects_empty_kind_keyword() {
185        let err = parse_relational_atom(raw(":compile", 5, 13)).unwrap_err();
186        match err {
187            AtomError::UnknownRelationalKind { kind, .. } => {
188                assert_eq!(kind, "");
189            }
190            other => panic!("expected UnknownRelationalKind, got {other:?}"),
191        }
192    }
193
194    // --- Rejection: invalid identifier value ------------------
195
196    #[test]
197    fn qry_004_rejects_invalid_task_name_value() {
198        let err = parse_relational_atom(raw("name:foo.bar", 0, 12)).unwrap_err();
199        match err {
200            AtomError::InvalidTaskName { span, source } => {
201                assert_eq!(span, Span { start: 0, end: 12 });
202                assert!(matches!(source, NameError::InvalidChar { c: '.' }));
203            }
204            other => panic!("expected InvalidTaskName, got {other:?}"),
205        }
206    }
207
208    #[test]
209    fn qry_004_rejects_invalid_project_name_value() {
210        let err = parse_relational_atom(raw("project:-leading", 2, 18)).unwrap_err();
211        match err {
212            AtomError::InvalidProjectName { span, source } => {
213                assert_eq!(span, Span { start: 2, end: 18 });
214                assert!(matches!(source, NameError::InvalidLeadingChar { c: '-' }));
215            }
216            other => panic!("expected InvalidProjectName, got {other:?}"),
217        }
218    }
219
220    #[test]
221    fn qry_004_rejects_invalid_tag_name_value() {
222        // `tag:` followed by the empty string violates ID-003.
223        let err = parse_relational_atom(raw("tag:", 1, 5)).unwrap_err();
224        match err {
225            AtomError::InvalidTagName { span, source } => {
226                assert_eq!(span, Span { start: 1, end: 5 });
227                assert!(matches!(source, NameError::Empty));
228            }
229            other => panic!("expected InvalidTagName, got {other:?}"),
230        }
231    }
232
233    // --- End-to-end lift via Expr::try_map --------------------
234
235    #[test]
236    fn qry_004_lifts_parsed_expression_to_typed_relational_expression() {
237        let expr = parse("name:compile & (project:lib_core | tag:backend)").unwrap();
238        let typed = expr.try_map(parse_relational_atom).unwrap();
239
240        let compile = TaskName::try_new("compile").unwrap();
241        let lib_core = ProjectName::try_new("lib_core").unwrap();
242        let backend = TagName::try_new("backend").unwrap();
243        let expected = Expr::And(
244            Box::new(Expr::Atom(RelationalAtom::Name(compile))),
245            Box::new(Expr::Or(
246                Box::new(Expr::Atom(RelationalAtom::Project(lib_core))),
247                Box::new(Expr::Atom(RelationalAtom::Tag(backend))),
248            )),
249        );
250        assert_eq!(typed, expected);
251    }
252
253    #[test]
254    fn qry_004_lift_propagates_first_invalid_atom_with_correct_span() {
255        let expr = parse("tag:backend | bogus:thing | name:foo").unwrap();
256        let err = expr.try_map(parse_relational_atom).unwrap_err();
257        match err {
258            AtomError::UnknownRelationalKind { span, kind } => {
259                // `bogus:thing` starts at byte 14.
260                assert_eq!(span, Span { start: 14, end: 25 });
261                assert_eq!(kind, "bogus");
262            }
263            other => panic!("expected UnknownRelationalKind, got {other:?}"),
264        }
265    }
266
267    // --- QRY-004 matches() ---------------------------------------
268
269    fn ctx() -> (ProjectName, TaskName, BTreeSet<TagName>) {
270        let project = ProjectName::try_new("lib_core").unwrap();
271        let task = TaskName::try_new("compile").unwrap();
272        let mut tags = BTreeSet::new();
273        tags.insert(TagName::try_new("backend").unwrap());
274        tags.insert(TagName::try_new("rust").unwrap());
275        (project, task, tags)
276    }
277
278    #[test]
279    fn qry_004_name_atom_matches_task_name_exactly() {
280        let (p, t, tags) = ctx();
281        let atom = RelationalAtom::Name(TaskName::try_new("compile").unwrap());
282        assert!(atom.matches(&p, &t, &tags));
283    }
284
285    #[test]
286    fn qry_004_name_atom_rejects_mismatching_task() {
287        let (p, t, tags) = ctx();
288        let atom = RelationalAtom::Name(TaskName::try_new("test").unwrap());
289        assert!(!atom.matches(&p, &t, &tags));
290    }
291
292    #[test]
293    fn qry_004_project_atom_matches_owning_project_exactly() {
294        let (p, t, tags) = ctx();
295        let atom = RelationalAtom::Project(ProjectName::try_new("lib_core").unwrap());
296        assert!(atom.matches(&p, &t, &tags));
297    }
298
299    #[test]
300    fn qry_004_project_atom_rejects_other_project() {
301        let (p, t, tags) = ctx();
302        let atom = RelationalAtom::Project(ProjectName::try_new("web").unwrap());
303        assert!(!atom.matches(&p, &t, &tags));
304    }
305
306    #[test]
307    fn qry_004_tag_atom_matches_when_project_carries_tag() {
308        let (p, t, tags) = ctx();
309        let atom = RelationalAtom::Tag(TagName::try_new("backend").unwrap());
310        assert!(atom.matches(&p, &t, &tags));
311    }
312
313    #[test]
314    fn qry_004_tag_atom_rejects_when_project_lacks_tag() {
315        let (p, t, tags) = ctx();
316        let atom = RelationalAtom::Tag(TagName::try_new("frontend").unwrap());
317        assert!(!atom.matches(&p, &t, &tags));
318    }
319}