haz-query 0.1.0

Query evaluator over haz task DAGs.
Documentation
//! Candidate-set computation per `QRY` vocabulary.
//!
//! The candidate set is the task population the engine considers
//! before applying filters. Its size depends on the cwd-derived
//! bearing project per `EXEC-022`:
//!
//! - With a bearing project, the candidate set is that
//!   project's effective task set (`DAG-006`).
//! - Without a bearing project, the candidate set is the entire
//!   workspace's effective task set.
//!
//! The engine carries the validated workspace; effective task
//! merging (`DAG-001..006`) has already happened during
//! workspace load.

use haz_domain::name::{ProjectName, TaskName};
use haz_domain::project::Project;
use haz_domain::task::Task;
use haz_domain::workspace::Workspace;

use crate::engine::spec::QueryError;

/// A candidate task plus the project context the engine needs
/// to evaluate per-attribute and relational filters against it.
#[derive(Debug, Clone, Copy)]
pub struct CandidateTask<'w> {
    /// The task's owning project name.
    pub project_name: &'w ProjectName,
    /// The task's owning project (carrying tags and root for
    /// canonicalisation).
    pub project: &'w Project,
    /// The task's own name.
    pub task_name: &'w TaskName,
    /// The task body.
    pub task: &'w Task,
}

/// Collect the candidate set per `QRY` vocabulary.
///
/// Returns the candidate tasks in canonical `(ProjectName,
/// TaskName)` order (the workspace's `BTreeMap` natural order
/// is canonical because both keys are case-sensitive identifier
/// types).
///
/// # Errors
///
/// Returns [`QueryError::BearingProjectNotInWorkspace`] when
/// `bearing_project` is `Some(name)` and `name` is not a key in
/// `workspace.projects`.
pub fn collect_candidates<'w>(
    workspace: &'w Workspace,
    bearing_project: Option<&ProjectName>,
) -> Result<Vec<CandidateTask<'w>>, QueryError> {
    match bearing_project {
        Some(name) => {
            let project = workspace.projects.get(name).ok_or_else(|| {
                QueryError::BearingProjectNotInWorkspace {
                    name: name.to_string(),
                }
            })?;
            Ok(project
                .tasks
                .iter()
                .map(|(task_name, task)| CandidateTask {
                    project_name: &project.name,
                    project,
                    task_name,
                    task,
                })
                .collect())
        }
        None => Ok(workspace
            .projects
            .values()
            .flat_map(|project| {
                project
                    .tasks
                    .iter()
                    .map(move |(task_name, task)| CandidateTask {
                        project_name: &project.name,
                        project,
                        task_name,
                        task,
                    })
            })
            .collect()),
    }
}

#[cfg(test)]
mod tests {
    use std::collections::{BTreeMap, BTreeSet};
    use std::path::PathBuf;
    use std::str::FromStr;

    use haz_domain::action::TaskAction;
    use haz_domain::env::EnvSettings;
    use haz_domain::name::ProjectName;
    use haz_domain::path::{CanonicalPath, HazPath, ProjectRoot, WorkspaceRootPath};
    use haz_domain::project::Project;
    use haz_domain::settings::WorkspaceSettings;
    use haz_domain::task::Task;
    use haz_domain::workspace::Workspace;
    use nonempty::NonEmpty;

    use super::*;

    fn argv(parts: &[&str]) -> NonEmpty<String> {
        NonEmpty::from_vec(parts.iter().map(|s| (*s).to_owned()).collect()).unwrap()
    }

    fn bare_task(name: &str) -> Task {
        Task {
            name: TaskName::from_str(name).unwrap(),
            action: TaskAction::Command(argv(&["true"])),
            inputs: vec![],
            outputs: vec![],
            deps: vec![],
            weak_deps: vec![],
            mutex: None,
            env: EnvSettings::default(),
        }
    }

    fn project(name: &str, root: &str, task_names: &[&str]) -> Project {
        let mut tasks = BTreeMap::new();
        for &task_name in task_names {
            tasks.insert(TaskName::from_str(task_name).unwrap(), bare_task(task_name));
        }
        Project {
            name: ProjectName::from_str(name).unwrap(),
            root: ProjectRoot::Nested(
                CanonicalPath::from_absolute(&HazPath::parse(root).unwrap()).unwrap(),
            ),
            tags: BTreeSet::new(),
            tasks,
        }
    }

    fn workspace(projects: Vec<Project>) -> Workspace {
        let mut map = BTreeMap::new();
        for project in projects {
            map.insert(project.name.clone(), project);
        }
        Workspace {
            root: WorkspaceRootPath::try_new(PathBuf::from("/abs/workspace")).unwrap(),
            projects: map,
            overlays: BTreeMap::new(),
            settings: WorkspaceSettings::default(),
        }
    }

    #[test]
    fn qry_vocabulary_workspace_candidate_set_covers_every_task() {
        let ws = workspace(vec![
            project("lib", "/lib", &["build", "test"]),
            project("web", "/web", &["bundle"]),
        ]);
        let candidates = collect_candidates(&ws, None).unwrap();
        let identities: Vec<(String, String)> = candidates
            .iter()
            .map(|c| (c.project_name.to_string(), c.task_name.to_string()))
            .collect();
        assert_eq!(
            identities,
            vec![
                ("lib".to_owned(), "build".to_owned()),
                ("lib".to_owned(), "test".to_owned()),
                ("web".to_owned(), "bundle".to_owned()),
            ],
        );
    }

    #[test]
    fn qry_vocabulary_bearing_project_candidate_set_restricts_to_that_project() {
        let ws = workspace(vec![
            project("lib", "/lib", &["build", "test"]),
            project("web", "/web", &["bundle"]),
        ]);
        let bearing = ProjectName::from_str("lib").unwrap();
        let candidates = collect_candidates(&ws, Some(&bearing)).unwrap();
        let identities: Vec<(String, String)> = candidates
            .iter()
            .map(|c| (c.project_name.to_string(), c.task_name.to_string()))
            .collect();
        assert_eq!(
            identities,
            vec![
                ("lib".to_owned(), "build".to_owned()),
                ("lib".to_owned(), "test".to_owned()),
            ],
        );
    }

    #[test]
    fn qry_engine_rejects_unknown_bearing_project() {
        let ws = workspace(vec![project("lib", "/lib", &["build"])]);
        let bearing = ProjectName::from_str("absent").unwrap();
        let err = collect_candidates(&ws, Some(&bearing)).unwrap_err();
        match err {
            QueryError::BearingProjectNotInWorkspace { name } => assert_eq!(name, "absent"),
            other => panic!("expected BearingProjectNotInWorkspace, got {other:?}"),
        }
    }
}