Skip to main content

haz_query/expr/
identifier.rs

1//! Lifters that convert raw atoms into typed identifier values.
2//!
3//! Per `QRY-003`, the per-attribute filters `--tags`,
4//! `--projects`, and `--tasks` each constrain their atoms to a
5//! specific identifier domain. Each function in this module is a
6//! pure [`RawAtom`] -> [`Result`] conversion designed to plug
7//! into [`haz_query_lang::expr::Expr::try_map`].
8
9use haz_domain::name::{ProjectName, TagName, TaskName};
10use haz_query_lang::expr::RawAtom;
11
12use crate::expr::atom::AtomError;
13
14/// Parse a raw atom as a [`TagName`] per `QRY-003`.
15///
16/// Designed for use with
17/// [`haz_query_lang::expr::Expr::try_map`] to lift an entire
18/// parsed expression to `Expr<TagName>` in one step.
19///
20/// # Errors
21///
22/// Returns [`AtomError::InvalidTagName`] when the atom text
23/// violates `ID-001..ID-005`.
24pub fn parse_tag_atom(atom: RawAtom) -> Result<TagName, AtomError> {
25    let RawAtom { text, span } = atom;
26    TagName::try_new(text).map_err(|source| AtomError::InvalidTagName { span, source })
27}
28
29/// Parse a raw atom as a [`ProjectName`] per `QRY-003`.
30///
31/// # Errors
32///
33/// Returns [`AtomError::InvalidProjectName`] when the atom text
34/// violates `ID-001..ID-005`.
35pub fn parse_project_atom(atom: RawAtom) -> Result<ProjectName, AtomError> {
36    let RawAtom { text, span } = atom;
37    ProjectName::try_new(text).map_err(|source| AtomError::InvalidProjectName { span, source })
38}
39
40/// Parse a raw atom as a [`TaskName`] per `QRY-003`.
41///
42/// # Errors
43///
44/// Returns [`AtomError::InvalidTaskName`] when the atom text
45/// violates `ID-001..ID-005`.
46pub fn parse_task_atom(atom: RawAtom) -> Result<TaskName, AtomError> {
47    let RawAtom { text, span } = atom;
48    TaskName::try_new(text).map_err(|source| AtomError::InvalidTaskName { span, source })
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54    use haz_domain::name::NameError;
55    use haz_query_lang::expr::Expr;
56    use haz_query_lang::parser::parse;
57    use haz_query_lang::span::Span;
58
59    fn raw(text: &str, start: usize, end: usize) -> RawAtom {
60        RawAtom {
61            text: text.to_owned(),
62            span: Span { start, end },
63        }
64    }
65
66    // --- TagName atom parser ----------------------------------
67
68    #[test]
69    fn qry_003_parses_valid_tag_atom() {
70        let tag = parse_tag_atom(raw("backend", 0, 7)).unwrap();
71        assert_eq!(tag.to_string(), "backend");
72    }
73
74    #[test]
75    fn qry_003_rejects_invalid_tag_atom_and_records_span() {
76        let err = parse_tag_atom(raw("with space", 4, 14)).unwrap_err();
77        match err {
78            AtomError::InvalidTagName { span, source } => {
79                assert_eq!(span, Span { start: 4, end: 14 });
80                assert!(matches!(source, NameError::InvalidChar { c: ' ' }));
81            }
82            other => panic!("expected InvalidTagName, got {other:?}"),
83        }
84    }
85
86    #[test]
87    fn qry_003_rejects_empty_tag_atom() {
88        let err = parse_tag_atom(raw("", 7, 7)).unwrap_err();
89        assert!(matches!(
90            err,
91            AtomError::InvalidTagName {
92                source: NameError::Empty,
93                ..
94            }
95        ));
96    }
97
98    // --- ProjectName atom parser ------------------------------
99
100    #[test]
101    fn qry_003_parses_valid_project_atom() {
102        let project = parse_project_atom(raw("lib_core", 0, 8)).unwrap();
103        assert_eq!(project.to_string(), "lib_core");
104    }
105
106    #[test]
107    fn qry_003_rejects_invalid_project_atom_and_records_span() {
108        let err = parse_project_atom(raw("-leading", 2, 10)).unwrap_err();
109        match err {
110            AtomError::InvalidProjectName { span, source } => {
111                assert_eq!(span, Span { start: 2, end: 10 });
112                assert!(matches!(source, NameError::InvalidLeadingChar { c: '-' }));
113            }
114            other => panic!("expected InvalidProjectName, got {other:?}"),
115        }
116    }
117
118    // --- TaskName atom parser ---------------------------------
119
120    #[test]
121    fn qry_003_parses_valid_task_atom() {
122        let task = parse_task_atom(raw("compile", 0, 7)).unwrap();
123        assert_eq!(task.to_string(), "compile");
124    }
125
126    #[test]
127    fn qry_003_rejects_invalid_task_atom_and_records_span() {
128        // `path/segment` contains `/`, banned by ID-001.
129        let err = parse_task_atom(raw("path/segment", 1, 13)).unwrap_err();
130        match err {
131            AtomError::InvalidTaskName { span, source } => {
132                assert_eq!(span, Span { start: 1, end: 13 });
133                assert!(matches!(source, NameError::InvalidChar { c: '/' }));
134            }
135            other => panic!("expected InvalidTaskName, got {other:?}"),
136        }
137    }
138
139    // --- End-to-end lift via Expr::try_map --------------------
140
141    #[test]
142    fn qry_003_lifts_parsed_expression_to_typed_tag_expression() {
143        let expr = parse("backend & !legacy | infra").unwrap();
144        let typed = expr.try_map(parse_tag_atom).unwrap();
145
146        // Expected shape, post-precedence:
147        // ((backend & !legacy) | infra)
148        let backend = TagName::try_new("backend").unwrap();
149        let legacy = TagName::try_new("legacy").unwrap();
150        let infra = TagName::try_new("infra").unwrap();
151        let expected = Expr::Or(
152            Box::new(Expr::And(
153                Box::new(Expr::Atom(backend)),
154                Box::new(Expr::Not(Box::new(Expr::Atom(legacy)))),
155            )),
156            Box::new(Expr::Atom(infra)),
157        );
158        assert_eq!(typed, expected);
159    }
160
161    #[test]
162    fn qry_003_lifts_parsed_expression_to_typed_project_expression() {
163        let expr = parse("lib_core | web-frontend").unwrap();
164        let typed = expr.try_map(parse_project_atom).unwrap();
165        let lib_core = ProjectName::try_new("lib_core").unwrap();
166        let web_frontend = ProjectName::try_new("web-frontend").unwrap();
167        let expected = Expr::Or(
168            Box::new(Expr::Atom(lib_core)),
169            Box::new(Expr::Atom(web_frontend)),
170        );
171        assert_eq!(typed, expected);
172    }
173
174    #[test]
175    fn qry_003_lifts_parsed_expression_to_typed_task_expression() {
176        let expr = parse("!(test_unit & test_integration)").unwrap();
177        let typed = expr.try_map(parse_task_atom).unwrap();
178        let unit = TaskName::try_new("test_unit").unwrap();
179        let integration = TaskName::try_new("test_integration").unwrap();
180        let expected = Expr::Not(Box::new(Expr::And(
181            Box::new(Expr::Atom(unit)),
182            Box::new(Expr::Atom(integration)),
183        )));
184        assert_eq!(typed, expected);
185    }
186
187    #[test]
188    fn qry_003_lift_propagates_first_invalid_atom_with_correct_span() {
189        let expr = parse("good | foo.bar | other").unwrap();
190        let err = expr.try_map(parse_tag_atom).unwrap_err();
191        match err {
192            AtomError::InvalidTagName { span, source } => {
193                assert_eq!(span, Span { start: 7, end: 14 });
194                assert!(matches!(source, NameError::InvalidChar { c: '.' }));
195            }
196            other => panic!("expected InvalidTagName, got {other:?}"),
197        }
198    }
199
200    // --- AtomError display ------------------------------------
201
202    #[test]
203    fn atom_error_display_includes_span_and_underlying_message() {
204        let err = parse_tag_atom(raw("foo.bar", 2, 9)).unwrap_err();
205        let rendered = err.to_string();
206        assert!(
207            rendered.contains("bytes 2..9"),
208            "missing span in: {rendered}"
209        );
210        assert!(
211            rendered.contains('.'),
212            "missing source detail in: {rendered}"
213        );
214    }
215}