Skip to main content

haz_query/engine/
candidate.rs

1//! Candidate-set computation per `QRY` vocabulary.
2//!
3//! The candidate set is the task population the engine considers
4//! before applying filters. Its size depends on the cwd-derived
5//! bearing project per `EXEC-022`:
6//!
7//! - With a bearing project, the candidate set is that
8//!   project's effective task set (`DAG-006`).
9//! - Without a bearing project, the candidate set is the entire
10//!   workspace's effective task set.
11//!
12//! The engine carries the validated workspace; effective task
13//! merging (`DAG-001..006`) has already happened during
14//! workspace load.
15
16use haz_domain::name::{ProjectName, TaskName};
17use haz_domain::project::Project;
18use haz_domain::task::Task;
19use haz_domain::workspace::Workspace;
20
21use crate::engine::spec::QueryError;
22
23/// A candidate task plus the project context the engine needs
24/// to evaluate per-attribute and relational filters against it.
25#[derive(Debug, Clone, Copy)]
26pub struct CandidateTask<'w> {
27    /// The task's owning project name.
28    pub project_name: &'w ProjectName,
29    /// The task's owning project (carrying tags and root for
30    /// canonicalisation).
31    pub project: &'w Project,
32    /// The task's own name.
33    pub task_name: &'w TaskName,
34    /// The task body.
35    pub task: &'w Task,
36}
37
38/// Collect the candidate set per `QRY` vocabulary.
39///
40/// Returns the candidate tasks in canonical `(ProjectName,
41/// TaskName)` order (the workspace's `BTreeMap` natural order
42/// is canonical because both keys are case-sensitive identifier
43/// types).
44///
45/// # Errors
46///
47/// Returns [`QueryError::BearingProjectNotInWorkspace`] when
48/// `bearing_project` is `Some(name)` and `name` is not a key in
49/// `workspace.projects`.
50pub fn collect_candidates<'w>(
51    workspace: &'w Workspace,
52    bearing_project: Option<&ProjectName>,
53) -> Result<Vec<CandidateTask<'w>>, QueryError> {
54    match bearing_project {
55        Some(name) => {
56            let project = workspace.projects.get(name).ok_or_else(|| {
57                QueryError::BearingProjectNotInWorkspace {
58                    name: name.to_string(),
59                }
60            })?;
61            Ok(project
62                .tasks
63                .iter()
64                .map(|(task_name, task)| CandidateTask {
65                    project_name: &project.name,
66                    project,
67                    task_name,
68                    task,
69                })
70                .collect())
71        }
72        None => Ok(workspace
73            .projects
74            .values()
75            .flat_map(|project| {
76                project
77                    .tasks
78                    .iter()
79                    .map(move |(task_name, task)| CandidateTask {
80                        project_name: &project.name,
81                        project,
82                        task_name,
83                        task,
84                    })
85            })
86            .collect()),
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use std::collections::{BTreeMap, BTreeSet};
93    use std::path::PathBuf;
94    use std::str::FromStr;
95
96    use haz_domain::action::TaskAction;
97    use haz_domain::env::EnvSettings;
98    use haz_domain::name::ProjectName;
99    use haz_domain::path::{CanonicalPath, HazPath, ProjectRoot, WorkspaceRootPath};
100    use haz_domain::project::Project;
101    use haz_domain::settings::WorkspaceSettings;
102    use haz_domain::task::Task;
103    use haz_domain::workspace::Workspace;
104    use nonempty::NonEmpty;
105
106    use super::*;
107
108    fn argv(parts: &[&str]) -> NonEmpty<String> {
109        NonEmpty::from_vec(parts.iter().map(|s| (*s).to_owned()).collect()).unwrap()
110    }
111
112    fn bare_task(name: &str) -> Task {
113        Task {
114            name: TaskName::from_str(name).unwrap(),
115            action: TaskAction::Command(argv(&["true"])),
116            inputs: vec![],
117            outputs: vec![],
118            deps: vec![],
119            weak_deps: vec![],
120            mutex: None,
121            env: EnvSettings::default(),
122        }
123    }
124
125    fn project(name: &str, root: &str, task_names: &[&str]) -> Project {
126        let mut tasks = BTreeMap::new();
127        for &task_name in task_names {
128            tasks.insert(TaskName::from_str(task_name).unwrap(), bare_task(task_name));
129        }
130        Project {
131            name: ProjectName::from_str(name).unwrap(),
132            root: ProjectRoot::Nested(
133                CanonicalPath::from_absolute(&HazPath::parse(root).unwrap()).unwrap(),
134            ),
135            tags: BTreeSet::new(),
136            tasks,
137        }
138    }
139
140    fn workspace(projects: Vec<Project>) -> Workspace {
141        let mut map = BTreeMap::new();
142        for project in projects {
143            map.insert(project.name.clone(), project);
144        }
145        Workspace {
146            root: WorkspaceRootPath::try_new(PathBuf::from("/abs/workspace")).unwrap(),
147            projects: map,
148            overlays: BTreeMap::new(),
149            settings: WorkspaceSettings::default(),
150        }
151    }
152
153    #[test]
154    fn qry_vocabulary_workspace_candidate_set_covers_every_task() {
155        let ws = workspace(vec![
156            project("lib", "/lib", &["build", "test"]),
157            project("web", "/web", &["bundle"]),
158        ]);
159        let candidates = collect_candidates(&ws, None).unwrap();
160        let identities: Vec<(String, String)> = candidates
161            .iter()
162            .map(|c| (c.project_name.to_string(), c.task_name.to_string()))
163            .collect();
164        assert_eq!(
165            identities,
166            vec![
167                ("lib".to_owned(), "build".to_owned()),
168                ("lib".to_owned(), "test".to_owned()),
169                ("web".to_owned(), "bundle".to_owned()),
170            ],
171        );
172    }
173
174    #[test]
175    fn qry_vocabulary_bearing_project_candidate_set_restricts_to_that_project() {
176        let ws = workspace(vec![
177            project("lib", "/lib", &["build", "test"]),
178            project("web", "/web", &["bundle"]),
179        ]);
180        let bearing = ProjectName::from_str("lib").unwrap();
181        let candidates = collect_candidates(&ws, Some(&bearing)).unwrap();
182        let identities: Vec<(String, String)> = candidates
183            .iter()
184            .map(|c| (c.project_name.to_string(), c.task_name.to_string()))
185            .collect();
186        assert_eq!(
187            identities,
188            vec![
189                ("lib".to_owned(), "build".to_owned()),
190                ("lib".to_owned(), "test".to_owned()),
191            ],
192        );
193    }
194
195    #[test]
196    fn qry_engine_rejects_unknown_bearing_project() {
197        let ws = workspace(vec![project("lib", "/lib", &["build"])]);
198        let bearing = ProjectName::from_str("absent").unwrap();
199        let err = collect_candidates(&ws, Some(&bearing)).unwrap_err();
200        match err {
201            QueryError::BearingProjectNotInWorkspace { name } => assert_eq!(name, "absent"),
202            other => panic!("expected BearingProjectNotInWorkspace, got {other:?}"),
203        }
204    }
205}