haz-query 0.1.0

Query evaluator over haz task DAGs.
Documentation
//! Per-task evaluation of the non-relational filters of
//! `QRY-003` and `QRY-005`.
//!
//! The engine canonicalises every path pattern to its
//! workspace-absolute form before intersecting, so the
//! `--inputs` / `--outputs` predicates agree with `DAG-013`'s
//! producer-matching semantics regardless of whether the user
//! typed a workspace-absolute or project-relative atom.
//!
//! Relational filters (`--child-of` etc.) need graph traversal
//! and live in a sibling module.

use haz_dag::producer::anchor_to_workspace_absolute;
use haz_domain::path::{InputSpec, OutputSpec, PathPattern, ProjectRoot};
use haz_query_lang::expr::Expr;

use crate::engine::candidate::CandidateTask;
use crate::engine::spec::{QueryError, QuerySpec};
use crate::expr::path;

/// Evaluate every non-relational filter in `spec` against
/// `candidate`. Returns `Ok(true)` iff the candidate satisfies
/// every active filter (or no non-relational filter is set).
///
/// The `bearing_project_root` is the cwd-derived bearing
/// project's root per `EXEC-022`. It is consulted only when the
/// user typed a project-relative path-pattern atom for
/// `--inputs` / `--outputs`; absent bearing project together
/// with a project-relative atom is a typed error.
///
/// # Errors
///
/// Returns [`QueryError`] when path-pattern canonicalisation
/// fails (project-relative atom without bearing context, or
/// re-parse regression) or when the glob-glob intersection
/// routine fails for one of the patterns.
pub fn passes_non_relational(
    candidate: &CandidateTask<'_>,
    spec: &QuerySpec,
    bearing_project_root: Option<&ProjectRoot>,
) -> Result<bool, QueryError> {
    if let Some(expr) = &spec.tags
        && !expr.eval(|tag| candidate.project.tags.contains(tag))
    {
        return Ok(false);
    }

    if let Some(expr) = &spec.projects
        && !expr.eval(|name| candidate.project_name == name)
    {
        return Ok(false);
    }

    if let Some(expr) = &spec.tasks
        && !expr.eval(|name| candidate.task_name == name)
    {
        return Ok(false);
    }

    if let Some(expr) = &spec.inputs {
        let task_canonical = canonicalise_task_patterns(
            candidate.task.inputs.iter().map(InputSpec::pattern),
            &candidate.project.root,
        )?;
        let matches = pattern_expr_matches(expr, &task_canonical, bearing_project_root)?;
        if !matches {
            return Ok(false);
        }
    }

    if let Some(expr) = &spec.outputs {
        let task_canonical = canonicalise_task_patterns(
            candidate.task.outputs.iter().map(OutputSpec::pattern),
            &candidate.project.root,
        )?;
        let matches = pattern_expr_matches(expr, &task_canonical, bearing_project_root)?;
        if !matches {
            return Ok(false);
        }
    }

    for shortcut in &spec.shortcuts {
        if !shortcut.matches(candidate.task) {
            return Ok(false);
        }
    }

    Ok(true)
}

/// Canonicalise a sequence of task-declared patterns to their
/// workspace-absolute form using the task's owning project root.
///
/// Returns the canonical [`PathPattern`]s in input order.
fn canonicalise_task_patterns<'p>(
    patterns: impl Iterator<Item = &'p PathPattern>,
    project_root: &ProjectRoot,
) -> Result<Vec<PathPattern>, QueryError> {
    patterns
        .map(|pattern| canonicalise(pattern, project_root))
        .collect()
}

/// Evaluate a user-supplied `Expr<PathPattern>` against a task's
/// canonicalised pattern list. The atom matches iff any task
/// pattern intersects it (canonicalisation makes the comparison
/// coordinate-system-correct).
fn pattern_expr_matches(
    expr: &Expr<PathPattern>,
    task_canonical: &[PathPattern],
    bearing_project_root: Option<&ProjectRoot>,
) -> Result<bool, QueryError> {
    expr.try_eval(|atom_pattern| {
        let atom_canonical = canonicalise_user_atom(atom_pattern, bearing_project_root)?;
        for task_pattern in task_canonical {
            let hit = path::intersects(&atom_canonical, task_pattern)
                .map_err(|source| QueryError::GlobIntersect { source })?;
            if hit {
                return Ok(true);
            }
        }
        Ok(false)
    })
}

/// Canonicalise a user-supplied atom against the bearing
/// project's root. Workspace-absolute atoms pass through.
/// Project-relative atoms without a bearing project are a
/// typed error.
fn canonicalise_user_atom(
    atom: &PathPattern,
    bearing_project_root: Option<&ProjectRoot>,
) -> Result<PathPattern, QueryError> {
    if matches!(
        atom.anchor(),
        haz_domain::path::PathAnchor::WorkspaceAbsolute
    ) {
        return Ok(atom.clone());
    }
    let Some(root) = bearing_project_root else {
        return Err(QueryError::CanonicalisePattern {
            canonical: atom.to_string(),
        });
    };
    canonicalise(atom, root)
}

/// Canonicalise a single pattern against a known project root.
fn canonicalise(
    pattern: &PathPattern,
    project_root: &ProjectRoot,
) -> Result<PathPattern, QueryError> {
    let canonical_string = anchor_to_workspace_absolute(pattern, project_root);
    PathPattern::parse(&canonical_string).map_err(|_| QueryError::CanonicalisePattern {
        canonical: canonical_string,
    })
}