1pub mod candidate;
15pub mod filter;
16pub mod relational;
17pub mod spec;
18
19use haz_dag::graph::TaskGraph;
20use haz_domain::name::ProjectName;
21use haz_domain::task_id::TaskId;
22use haz_domain::workspace::Workspace;
23
24use crate::engine::candidate::collect_candidates;
25use crate::engine::filter::passes_non_relational;
26use crate::engine::relational::{RelationalTargets, passes_relational};
27use crate::engine::spec::{QueryError, QuerySpec};
28
29pub fn execute(
55 workspace: &Workspace,
56 graph: &TaskGraph,
57 bearing_project: Option<&ProjectName>,
58 spec: &QuerySpec,
59) -> Result<Vec<TaskId>, QueryError> {
60 let candidates = collect_candidates(workspace, bearing_project)?;
61 let bearing_root = bearing_project
62 .and_then(|name| workspace.projects.get(name))
63 .map(|p| &p.root);
64 let targets = RelationalTargets::from_spec(workspace, spec);
65
66 let mut selected: Vec<TaskId> = Vec::new();
67 for candidate in &candidates {
68 if !passes_non_relational(candidate, spec, bearing_root)? {
69 continue;
70 }
71 let candidate_id = TaskId {
72 project: candidate.project_name.clone(),
73 task: candidate.task_name.clone(),
74 };
75 if !passes_relational(graph, &candidate_id, &targets) {
76 continue;
77 }
78 selected.push(candidate_id);
79 }
80 Ok(selected)
81}
82
83pub fn execute_non_relational(
94 workspace: &Workspace,
95 bearing_project: Option<&ProjectName>,
96 spec: &QuerySpec,
97) -> Result<Vec<TaskId>, QueryError> {
98 let graph = TaskGraph::default();
99 execute(workspace, &graph, bearing_project, spec)
100}
101
102#[cfg(test)]
103mod tests {
104 use std::collections::{BTreeMap, BTreeSet};
105 use std::path::PathBuf;
106 use std::str::FromStr;
107
108 use haz_dag::edge::{Edge, EdgeKind};
109 use haz_dag::graph::TaskGraph;
110 use haz_domain::action::TaskAction;
111 use haz_domain::env::EnvSettings;
112 use haz_domain::mutex::{Mutex, MutexMode, MutexScope};
113 use haz_domain::name::{MutexName, ProjectName, TagName, TaskName};
114 use haz_domain::path::{
115 CanonicalPath, HazPath, InputSpec, OutputSpec, PathPattern, ProjectRoot, WorkspaceRootPath,
116 };
117 use haz_domain::project::Project;
118 use haz_domain::settings::WorkspaceSettings;
119 use haz_domain::task::Task;
120 use haz_domain::task_id::TaskId;
121 use haz_domain::workspace::Workspace;
122 use haz_query_lang::expr::{Expr, RawAtom};
123 use haz_query_lang::span::Span;
124 use nonempty::NonEmpty;
125
126 use super::*;
127 use crate::expr::relational::{RelationalAtom, parse_relational_atom};
128 use crate::expr::shortcut::BooleanShortcut;
129
130 fn argv(parts: &[&str]) -> NonEmpty<String> {
131 NonEmpty::from_vec(parts.iter().map(|s| (*s).to_owned()).collect()).unwrap()
132 }
133
134 fn task(name: &str, inputs: &[&str], outputs: &[&str], with_mutex: bool) -> Task {
135 Task {
136 name: TaskName::from_str(name).unwrap(),
137 action: TaskAction::Command(argv(&["true"])),
138 inputs: inputs
139 .iter()
140 .map(|s| InputSpec::parse(s).unwrap())
141 .collect(),
142 outputs: outputs
143 .iter()
144 .map(|s| OutputSpec::parse(s).unwrap())
145 .collect(),
146 deps: vec![],
147 weak_deps: vec![],
148 mutex: if with_mutex {
149 Some(Mutex {
150 scope: MutexScope::Workspace,
151 name: MutexName::from_str("db").unwrap(),
152 mode: MutexMode::Exclusive,
153 })
154 } else {
155 None
156 },
157 env: EnvSettings::default(),
158 }
159 }
160
161 fn project(name: &str, root: &str, tags: &[&str], tasks: Vec<Task>) -> Project {
162 let mut task_map = BTreeMap::new();
163 for t in tasks {
164 task_map.insert(t.name.clone(), t);
165 }
166 Project {
167 name: ProjectName::from_str(name).unwrap(),
168 root: ProjectRoot::Nested(
169 CanonicalPath::from_absolute(&HazPath::parse(root).unwrap()).unwrap(),
170 ),
171 tags: tags
172 .iter()
173 .map(|t| TagName::from_str(t).unwrap())
174 .collect::<BTreeSet<_>>(),
175 tasks: task_map,
176 }
177 }
178
179 fn workspace(projects: Vec<Project>) -> Workspace {
180 let mut map = BTreeMap::new();
181 for project in projects {
182 map.insert(project.name.clone(), project);
183 }
184 Workspace {
185 root: WorkspaceRootPath::try_new(PathBuf::from("/abs/workspace")).unwrap(),
186 projects: map,
187 overlays: BTreeMap::new(),
188 settings: WorkspaceSettings::default(),
189 }
190 }
191
192 fn ids(names: &[(&str, &str)]) -> Vec<haz_domain::task_id::TaskId> {
193 names
194 .iter()
195 .map(|(p, t)| haz_domain::task_id::TaskId {
196 project: ProjectName::from_str(p).unwrap(),
197 task: TaskName::from_str(t).unwrap(),
198 })
199 .collect()
200 }
201
202 fn tag_atom(s: &str) -> Expr<TagName> {
203 Expr::Atom(TagName::from_str(s).unwrap())
204 }
205
206 fn project_atom(s: &str) -> Expr<ProjectName> {
207 Expr::Atom(ProjectName::from_str(s).unwrap())
208 }
209
210 fn task_atom(s: &str) -> Expr<TaskName> {
211 Expr::Atom(TaskName::from_str(s).unwrap())
212 }
213
214 fn pattern_atom(s: &str) -> Expr<PathPattern> {
215 Expr::Atom(PathPattern::parse(s).unwrap())
216 }
217
218 fn three_project_workspace() -> Workspace {
219 workspace(vec![
220 project(
221 "lib",
222 "/lib",
223 &["backend", "rust"],
224 vec![
225 task("build", &["src/**/*.rs"], &["target/lib.so"], false),
226 task("test", &["src/**/*.rs"], &[], true),
227 ],
228 ),
229 project(
230 "web",
231 "/web",
232 &["frontend"],
233 vec![task("bundle", &["src/index.ts"], &["dist/app.js"], false)],
234 ),
235 project(
236 "tools",
237 "/tools",
238 &["backend"],
239 vec![task("lint", &[], &[], false)],
240 ),
241 ])
242 }
243
244 #[test]
247 fn qry_007_empty_spec_returns_full_workspace_candidate_set() {
248 let ws = three_project_workspace();
249 let selected = execute_non_relational(&ws, None, &QuerySpec::default()).unwrap();
250 assert_eq!(
251 selected,
252 ids(&[
253 ("lib", "build"),
254 ("lib", "test"),
255 ("tools", "lint"),
256 ("web", "bundle"),
257 ]),
258 );
259 }
260
261 #[test]
262 fn qry_007_empty_spec_with_bearing_project_restricts_to_that_project() {
263 let ws = three_project_workspace();
264 let bearing = ProjectName::from_str("lib").unwrap();
265 let selected = execute_non_relational(&ws, Some(&bearing), &QuerySpec::default()).unwrap();
266 assert_eq!(selected, ids(&[("lib", "build"), ("lib", "test")]));
267 }
268
269 #[test]
272 fn qry_003_tags_filter_selects_only_tagged_projects() {
273 let ws = three_project_workspace();
274 let spec = QuerySpec {
275 tags: Some(tag_atom("backend")),
276 ..QuerySpec::default()
277 };
278 let selected = execute_non_relational(&ws, None, &spec).unwrap();
279 assert_eq!(
280 selected,
281 ids(&[("lib", "build"), ("lib", "test"), ("tools", "lint")]),
282 );
283 }
284
285 #[test]
286 fn qry_003_projects_filter_selects_only_matching_project() {
287 let ws = three_project_workspace();
288 let spec = QuerySpec {
289 projects: Some(project_atom("web")),
290 ..QuerySpec::default()
291 };
292 let selected = execute_non_relational(&ws, None, &spec).unwrap();
293 assert_eq!(selected, ids(&[("web", "bundle")]));
294 }
295
296 #[test]
297 fn qry_003_tasks_filter_selects_only_tasks_with_that_name() {
298 let ws = three_project_workspace();
299 let spec = QuerySpec {
300 tasks: Some(task_atom("build")),
301 ..QuerySpec::default()
302 };
303 let selected = execute_non_relational(&ws, None, &spec).unwrap();
304 assert_eq!(selected, ids(&[("lib", "build")]));
305 }
306
307 #[test]
308 fn qry_003_combined_per_attribute_filters_intersect() {
309 let ws = three_project_workspace();
310 let spec = QuerySpec {
311 tags: Some(tag_atom("backend")),
312 tasks: Some(task_atom("test")),
313 ..QuerySpec::default()
314 };
315 let selected = execute_non_relational(&ws, None, &spec).unwrap();
316 assert_eq!(selected, ids(&[("lib", "test")]));
317 }
318
319 #[test]
322 fn qry_002_tag_expression_with_negation_evaluates() {
323 let ws = three_project_workspace();
324 let spec = QuerySpec {
326 tags: Some(Expr::And(
327 Box::new(tag_atom("backend")),
328 Box::new(Expr::Not(Box::new(tag_atom("frontend")))),
329 )),
330 ..QuerySpec::default()
331 };
332 let selected = execute_non_relational(&ws, None, &spec).unwrap();
333 assert_eq!(
334 selected,
335 ids(&[("lib", "build"), ("lib", "test"), ("tools", "lint")]),
336 );
337 }
338
339 #[test]
342 fn qry_003_inputs_workspace_absolute_atom_matches_canonicalised_task_pattern() {
343 let ws = three_project_workspace();
344 let spec = QuerySpec {
345 inputs: Some(pattern_atom("/lib/src/main.rs")),
346 ..QuerySpec::default()
347 };
348 let selected = execute_non_relational(&ws, None, &spec).unwrap();
349 assert_eq!(selected, ids(&[("lib", "build"), ("lib", "test")]));
353 }
354
355 #[test]
356 fn qry_003_inputs_disjoint_workspace_root_excludes_other_projects() {
357 let ws = three_project_workspace();
358 let spec = QuerySpec {
359 inputs: Some(pattern_atom("/web/src/index.ts")),
360 ..QuerySpec::default()
361 };
362 let selected = execute_non_relational(&ws, None, &spec).unwrap();
363 assert_eq!(selected, ids(&[("web", "bundle")]));
364 }
365
366 #[test]
367 fn qry_003_outputs_glob_atom_matches_literal_task_output() {
368 let ws = three_project_workspace();
369 let spec = QuerySpec {
370 outputs: Some(pattern_atom("/lib/target/*.so")),
371 ..QuerySpec::default()
372 };
373 let selected = execute_non_relational(&ws, None, &spec).unwrap();
374 assert_eq!(selected, ids(&[("lib", "build")]));
375 }
376
377 #[test]
378 fn qry_003_inputs_project_relative_atom_with_bearing_project() {
379 let ws = three_project_workspace();
380 let bearing = ProjectName::from_str("lib").unwrap();
381 let spec = QuerySpec {
382 inputs: Some(pattern_atom("src/main.rs")),
384 ..QuerySpec::default()
385 };
386 let selected = execute_non_relational(&ws, Some(&bearing), &spec).unwrap();
387 assert_eq!(selected, ids(&[("lib", "build"), ("lib", "test")]));
388 }
389
390 #[test]
391 fn qry_003_inputs_project_relative_atom_without_bearing_project_errors() {
392 let ws = three_project_workspace();
393 let spec = QuerySpec {
394 inputs: Some(pattern_atom("src/main.rs")),
395 ..QuerySpec::default()
396 };
397 let err = execute_non_relational(&ws, None, &spec).unwrap_err();
398 match err {
399 QueryError::CanonicalisePattern { canonical } => assert_eq!(canonical, "src/main.rs"),
400 other => panic!("expected CanonicalisePattern, got {other:?}"),
401 }
402 }
403
404 #[test]
407 fn qry_005_no_inputs_shortcut_selects_input_less_tasks() {
408 let ws = three_project_workspace();
409 let spec = QuerySpec {
410 shortcuts: vec![BooleanShortcut::NoInputs],
411 ..QuerySpec::default()
412 };
413 let selected = execute_non_relational(&ws, None, &spec).unwrap();
414 assert_eq!(selected, ids(&[("tools", "lint")]));
415 }
416
417 #[test]
418 fn qry_005_mutex_shortcut_selects_only_mutex_tasks() {
419 let ws = three_project_workspace();
420 let spec = QuerySpec {
421 shortcuts: vec![BooleanShortcut::Mutex],
422 ..QuerySpec::default()
423 };
424 let selected = execute_non_relational(&ws, None, &spec).unwrap();
425 assert_eq!(selected, ids(&[("lib", "test")]));
426 }
427
428 #[test]
429 fn qry_006_combining_multiple_filter_families_intersects() {
430 let ws = three_project_workspace();
431 let spec = QuerySpec {
432 tags: Some(tag_atom("backend")),
433 shortcuts: vec![BooleanShortcut::Mutex],
434 ..QuerySpec::default()
435 };
436 let selected = execute_non_relational(&ws, None, &spec).unwrap();
437 assert_eq!(selected, ids(&[("lib", "test")]));
438 }
439
440 fn task_id(project: &str, task: &str) -> TaskId {
443 TaskId {
444 project: ProjectName::from_str(project).unwrap(),
445 task: TaskName::from_str(task).unwrap(),
446 }
447 }
448
449 fn relational_atom(text: &str) -> Expr<RelationalAtom> {
450 Expr::Atom(
451 parse_relational_atom(RawAtom {
452 text: text.to_owned(),
453 span: Span { start: 0, end: 0 },
454 })
455 .unwrap(),
456 )
457 }
458
459 fn three_project_hard_edge_graph() -> TaskGraph {
463 let pairs = [
464 (task_id("lib", "build"), task_id("lib", "test")),
465 (task_id("lib", "build"), task_id("web", "bundle")),
466 (task_id("lib", "test"), task_id("tools", "lint")),
467 ];
468 let mut nodes: BTreeSet<TaskId> = BTreeSet::new();
469 let mut edges: BTreeSet<Edge> = BTreeSet::new();
470 for (from, to) in &pairs {
471 nodes.insert(from.clone());
472 nodes.insert(to.clone());
473 edges.insert(Edge {
474 from: from.clone(),
475 to: to.clone(),
476 kind: EdgeKind::Hard,
477 });
478 }
479 TaskGraph { nodes, edges }
480 }
481
482 #[test]
483 fn qry_004_child_of_selects_direct_successors_of_target_set() {
484 let ws = three_project_workspace();
485 let graph = three_project_hard_edge_graph();
486 let spec = QuerySpec {
487 child_of: Some(relational_atom("name:build")),
488 ..QuerySpec::default()
489 };
490 let selected = execute(&ws, &graph, None, &spec).unwrap();
491 assert_eq!(selected, ids(&[("lib", "test"), ("web", "bundle")]));
492 }
493
494 #[test]
495 fn qry_004_parent_of_selects_direct_predecessors_of_target_set() {
496 let ws = three_project_workspace();
497 let graph = three_project_hard_edge_graph();
498 let spec = QuerySpec {
499 parent_of: Some(relational_atom("name:test")),
500 ..QuerySpec::default()
501 };
502 let selected = execute(&ws, &graph, None, &spec).unwrap();
503 assert_eq!(selected, ids(&[("lib", "build")]));
504 }
505
506 #[test]
507 fn qry_004_depends_on_traverses_transitively() {
508 let ws = three_project_workspace();
509 let graph = three_project_hard_edge_graph();
510 let spec = QuerySpec {
511 depends_on: Some(relational_atom("name:build")),
512 ..QuerySpec::default()
513 };
514 let selected = execute(&ws, &graph, None, &spec).unwrap();
515 assert_eq!(
520 selected,
521 ids(&[("lib", "test"), ("tools", "lint"), ("web", "bundle")]),
522 );
523 }
524
525 #[test]
526 fn qry_004_ancestor_of_traverses_transitively() {
527 let ws = three_project_workspace();
528 let graph = three_project_hard_edge_graph();
529 let spec = QuerySpec {
530 ancestor_of: Some(relational_atom("name:lint")),
531 ..QuerySpec::default()
532 };
533 let selected = execute(&ws, &graph, None, &spec).unwrap();
534 assert_eq!(selected, ids(&[("lib", "build"), ("lib", "test")]));
538 }
539
540 #[test]
541 fn qry_004_depends_on_excludes_tasks_in_target_set() {
542 let ws = three_project_workspace();
543 let graph = three_project_hard_edge_graph();
544 let spec = QuerySpec {
545 depends_on: Some(relational_atom("project:lib")),
546 ..QuerySpec::default()
547 };
548 let selected = execute(&ws, &graph, None, &spec).unwrap();
549 assert_eq!(selected, ids(&[("tools", "lint"), ("web", "bundle")]));
554 }
555
556 #[test]
557 fn qry_004_relational_filter_intersects_with_per_attribute() {
558 let ws = three_project_workspace();
559 let graph = three_project_hard_edge_graph();
560 let spec = QuerySpec {
561 child_of: Some(relational_atom("name:build")),
562 tags: Some(tag_atom("frontend")),
563 ..QuerySpec::default()
564 };
565 let selected = execute(&ws, &graph, None, &spec).unwrap();
566 assert_eq!(selected, ids(&[("web", "bundle")]));
570 }
571
572 #[test]
573 fn qry_004_empty_target_set_yields_zero_matches() {
574 let ws = three_project_workspace();
575 let graph = three_project_hard_edge_graph();
576 let spec = QuerySpec {
577 child_of: Some(relational_atom("name:absent")),
578 ..QuerySpec::default()
579 };
580 let selected = execute(&ws, &graph, None, &spec).unwrap();
581 assert!(selected.is_empty());
582 }
583
584 #[test]
585 fn qry_004_relational_with_boolean_composition_in_atom() {
586 let ws = three_project_workspace();
587 let graph = three_project_hard_edge_graph();
588 let spec = QuerySpec {
592 child_of: Some(Expr::Or(
593 Box::new(relational_atom("name:build")),
594 Box::new(relational_atom("name:test")),
595 )),
596 ..QuerySpec::default()
597 };
598 let selected = execute(&ws, &graph, None, &spec).unwrap();
599 assert_eq!(
600 selected,
601 ids(&[("lib", "test"), ("tools", "lint"), ("web", "bundle")]),
602 );
603 }
604
605 #[test]
606 fn qry_004_combined_relational_filters_intersect() {
607 let ws = three_project_workspace();
608 let graph = three_project_hard_edge_graph();
609 let spec = QuerySpec {
613 depends_on: Some(relational_atom("name:build")),
614 ancestor_of: Some(relational_atom("name:lint")),
615 ..QuerySpec::default()
616 };
617 let selected = execute(&ws, &graph, None, &spec).unwrap();
618 assert_eq!(selected, ids(&[("lib", "test")]));
619 }
620
621 #[test]
622 fn execute_with_no_relational_filter_matches_non_relational_entry() {
623 let ws = three_project_workspace();
624 let graph = three_project_hard_edge_graph();
625 let spec = QuerySpec {
626 tags: Some(tag_atom("backend")),
627 ..QuerySpec::default()
628 };
629 let with_graph = execute(&ws, &graph, None, &spec).unwrap();
630 let without_graph = execute_non_relational(&ws, None, &spec).unwrap();
631 assert_eq!(with_graph, without_graph);
632 }
633}