haz_query/expr/
identifier.rs1use haz_domain::name::{ProjectName, TagName, TaskName};
10use haz_query_lang::expr::RawAtom;
11
12use crate::expr::atom::AtomError;
13
14pub 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
29pub 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
40pub 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 #[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 #[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 #[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 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 #[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 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 #[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}