1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub enum RelationalAtom {
30 Name(TaskName),
33 Project(ProjectName),
36 Tag(TagName),
39}
40
41impl RelationalAtom {
42 #[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
64pub 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 #[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 #[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 #[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 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 #[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 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 #[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 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 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}