haz-query 0.1.0

Query evaluator over haz task DAGs.
Documentation
//! Relational atoms for `--child-of`, `--parent-of`,
//! `--depends-on`, and `--ancestor-of`.
//!
//! Per `QRY-004`, each atom takes the textual form
//! `<KIND>:<VALUE>` where `KIND` is one of the case-sensitive
//! keywords `name`, `project`, `tag`, and `VALUE` is the
//! matching identifier kind.
//!
//! Atoms compose under `|`, `&`, `!`, and `()` per `QRY-002`.
//! Each atom is a per-task predicate identifying *target tasks*
//! in the workspace; the relational filter flag (`--child-of`
//! etc.) then matches every task whose relation to that target
//! set is non-empty.

use std::collections::BTreeSet;

use haz_domain::name::{ProjectName, TagName, TaskName};
use haz_query_lang::expr::RawAtom;

use crate::expr::atom::AtomError;

/// A typed relational atom per `QRY-004`.
///
/// Each variant carries the validated identifier that the atom's
/// predicate compares against. The relational filter flag
/// (`--child-of` etc.) decides whether the relation walked is
/// direct or transitive.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum RelationalAtom {
    /// `name:<TaskName>`: the target task's name equals the
    /// given identifier.
    Name(TaskName),
    /// `project:<ProjectName>`: the target task's owning project
    /// name equals the given identifier.
    Project(ProjectName),
    /// `tag:<TagName>`: the target task's owning project carries
    /// the given tag.
    Tag(TagName),
}

impl RelationalAtom {
    /// Evaluate this atom against a candidate target task per
    /// `QRY-004`.
    ///
    /// `task_name` is the task's own name; `project_name` is the
    /// task's owning project; `project_tags` is the project's
    /// `tags:` set. Returns `true` iff the atom's predicate
    /// matches the task.
    #[must_use]
    pub fn matches(
        &self,
        project_name: &ProjectName,
        task_name: &TaskName,
        project_tags: &BTreeSet<TagName>,
    ) -> bool {
        match self {
            Self::Name(n) => task_name == n,
            Self::Project(p) => project_name == p,
            Self::Tag(t) => project_tags.contains(t),
        }
    }
}

/// Parse a raw atom as a [`RelationalAtom`] per `QRY-004`.
///
/// Designed for use with
/// [`haz_query_lang::expr::Expr::try_map`] to lift an entire
/// parsed expression to `Expr<RelationalAtom>` in one step.
///
/// # Errors
///
/// Returns:
///
/// - [`AtomError::MissingRelationalColon`] when the atom has
///   no `:` separator.
/// - [`AtomError::UnknownRelationalKind`] when the kind
///   keyword is not exactly one of `name`, `project`, `tag`
///   (case-sensitive).
/// - [`AtomError::InvalidTaskName`] /
///   [`AtomError::InvalidProjectName`] /
///   [`AtomError::InvalidTagName`] when the value violates
///   `ID-001..ID-005` for the corresponding kind.
pub fn parse_relational_atom(atom: RawAtom) -> Result<RelationalAtom, AtomError> {
    let RawAtom { text, span } = atom;
    let Some((kind, value)) = text.split_once(':') else {
        return Err(AtomError::MissingRelationalColon { span });
    };
    match kind {
        "name" => TaskName::try_new(value)
            .map(RelationalAtom::Name)
            .map_err(|source| AtomError::InvalidTaskName { span, source }),
        "project" => ProjectName::try_new(value)
            .map(RelationalAtom::Project)
            .map_err(|source| AtomError::InvalidProjectName { span, source }),
        "tag" => TagName::try_new(value)
            .map(RelationalAtom::Tag)
            .map_err(|source| AtomError::InvalidTagName { span, source }),
        unknown => Err(AtomError::UnknownRelationalKind {
            span,
            kind: unknown.to_owned(),
        }),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use haz_domain::name::NameError;
    use haz_query_lang::expr::Expr;
    use haz_query_lang::parser::parse;
    use haz_query_lang::span::Span;

    fn raw(text: &str, start: usize, end: usize) -> RawAtom {
        RawAtom {
            text: text.to_owned(),
            span: Span { start, end },
        }
    }

    // --- Happy paths per QRY-004 ------------------------------

    #[test]
    fn qry_004_parses_name_kind_atom() {
        let atom = parse_relational_atom(raw("name:compile", 0, 12)).unwrap();
        let expected = RelationalAtom::Name(TaskName::try_new("compile").unwrap());
        assert_eq!(atom, expected);
    }

    #[test]
    fn qry_004_parses_project_kind_atom() {
        let atom = parse_relational_atom(raw("project:lib_core", 0, 16)).unwrap();
        let expected = RelationalAtom::Project(ProjectName::try_new("lib_core").unwrap());
        assert_eq!(atom, expected);
    }

    #[test]
    fn qry_004_parses_tag_kind_atom() {
        let atom = parse_relational_atom(raw("tag:backend", 0, 11)).unwrap();
        let expected = RelationalAtom::Tag(TagName::try_new("backend").unwrap());
        assert_eq!(atom, expected);
    }

    // --- Rejection: missing colon -----------------------------

    #[test]
    fn qry_004_rejects_atom_without_colon() {
        let err = parse_relational_atom(raw("name", 4, 8)).unwrap_err();
        match err {
            AtomError::MissingRelationalColon { span } => {
                assert_eq!(span, Span { start: 4, end: 8 });
            }
            other => panic!("expected MissingRelationalColon, got {other:?}"),
        }
    }

    // --- Rejection: unknown kind ------------------------------

    #[test]
    fn qry_004_rejects_unknown_kind() {
        let err = parse_relational_atom(raw("foo:bar", 0, 7)).unwrap_err();
        match err {
            AtomError::UnknownRelationalKind { span, kind } => {
                assert_eq!(span, Span { start: 0, end: 7 });
                assert_eq!(kind, "foo");
            }
            other => panic!("expected UnknownRelationalKind, got {other:?}"),
        }
    }

    #[test]
    fn qry_004_rejects_uppercase_kind_keyword() {
        // Spec: "the kind keyword MUST be exactly one of the
        // three listed (case-sensitive)".
        let err = parse_relational_atom(raw("Name:compile", 0, 12)).unwrap_err();
        match err {
            AtomError::UnknownRelationalKind { kind, .. } => {
                assert_eq!(kind, "Name");
            }
            other => panic!("expected UnknownRelationalKind, got {other:?}"),
        }
    }

    #[test]
    fn qry_004_rejects_empty_kind_keyword() {
        let err = parse_relational_atom(raw(":compile", 5, 13)).unwrap_err();
        match err {
            AtomError::UnknownRelationalKind { kind, .. } => {
                assert_eq!(kind, "");
            }
            other => panic!("expected UnknownRelationalKind, got {other:?}"),
        }
    }

    // --- Rejection: invalid identifier value ------------------

    #[test]
    fn qry_004_rejects_invalid_task_name_value() {
        let err = parse_relational_atom(raw("name:foo.bar", 0, 12)).unwrap_err();
        match err {
            AtomError::InvalidTaskName { span, source } => {
                assert_eq!(span, Span { start: 0, end: 12 });
                assert!(matches!(source, NameError::InvalidChar { c: '.' }));
            }
            other => panic!("expected InvalidTaskName, got {other:?}"),
        }
    }

    #[test]
    fn qry_004_rejects_invalid_project_name_value() {
        let err = parse_relational_atom(raw("project:-leading", 2, 18)).unwrap_err();
        match err {
            AtomError::InvalidProjectName { span, source } => {
                assert_eq!(span, Span { start: 2, end: 18 });
                assert!(matches!(source, NameError::InvalidLeadingChar { c: '-' }));
            }
            other => panic!("expected InvalidProjectName, got {other:?}"),
        }
    }

    #[test]
    fn qry_004_rejects_invalid_tag_name_value() {
        // `tag:` followed by the empty string violates ID-003.
        let err = parse_relational_atom(raw("tag:", 1, 5)).unwrap_err();
        match err {
            AtomError::InvalidTagName { span, source } => {
                assert_eq!(span, Span { start: 1, end: 5 });
                assert!(matches!(source, NameError::Empty));
            }
            other => panic!("expected InvalidTagName, got {other:?}"),
        }
    }

    // --- End-to-end lift via Expr::try_map --------------------

    #[test]
    fn qry_004_lifts_parsed_expression_to_typed_relational_expression() {
        let expr = parse("name:compile & (project:lib_core | tag:backend)").unwrap();
        let typed = expr.try_map(parse_relational_atom).unwrap();

        let compile = TaskName::try_new("compile").unwrap();
        let lib_core = ProjectName::try_new("lib_core").unwrap();
        let backend = TagName::try_new("backend").unwrap();
        let expected = Expr::And(
            Box::new(Expr::Atom(RelationalAtom::Name(compile))),
            Box::new(Expr::Or(
                Box::new(Expr::Atom(RelationalAtom::Project(lib_core))),
                Box::new(Expr::Atom(RelationalAtom::Tag(backend))),
            )),
        );
        assert_eq!(typed, expected);
    }

    #[test]
    fn qry_004_lift_propagates_first_invalid_atom_with_correct_span() {
        let expr = parse("tag:backend | bogus:thing | name:foo").unwrap();
        let err = expr.try_map(parse_relational_atom).unwrap_err();
        match err {
            AtomError::UnknownRelationalKind { span, kind } => {
                // `bogus:thing` starts at byte 14.
                assert_eq!(span, Span { start: 14, end: 25 });
                assert_eq!(kind, "bogus");
            }
            other => panic!("expected UnknownRelationalKind, got {other:?}"),
        }
    }

    // --- QRY-004 matches() ---------------------------------------

    fn ctx() -> (ProjectName, TaskName, BTreeSet<TagName>) {
        let project = ProjectName::try_new("lib_core").unwrap();
        let task = TaskName::try_new("compile").unwrap();
        let mut tags = BTreeSet::new();
        tags.insert(TagName::try_new("backend").unwrap());
        tags.insert(TagName::try_new("rust").unwrap());
        (project, task, tags)
    }

    #[test]
    fn qry_004_name_atom_matches_task_name_exactly() {
        let (p, t, tags) = ctx();
        let atom = RelationalAtom::Name(TaskName::try_new("compile").unwrap());
        assert!(atom.matches(&p, &t, &tags));
    }

    #[test]
    fn qry_004_name_atom_rejects_mismatching_task() {
        let (p, t, tags) = ctx();
        let atom = RelationalAtom::Name(TaskName::try_new("test").unwrap());
        assert!(!atom.matches(&p, &t, &tags));
    }

    #[test]
    fn qry_004_project_atom_matches_owning_project_exactly() {
        let (p, t, tags) = ctx();
        let atom = RelationalAtom::Project(ProjectName::try_new("lib_core").unwrap());
        assert!(atom.matches(&p, &t, &tags));
    }

    #[test]
    fn qry_004_project_atom_rejects_other_project() {
        let (p, t, tags) = ctx();
        let atom = RelationalAtom::Project(ProjectName::try_new("web").unwrap());
        assert!(!atom.matches(&p, &t, &tags));
    }

    #[test]
    fn qry_004_tag_atom_matches_when_project_carries_tag() {
        let (p, t, tags) = ctx();
        let atom = RelationalAtom::Tag(TagName::try_new("backend").unwrap());
        assert!(atom.matches(&p, &t, &tags));
    }

    #[test]
    fn qry_004_tag_atom_rejects_when_project_lacks_tag() {
        let (p, t, tags) = ctx();
        let atom = RelationalAtom::Tag(TagName::try_new("frontend").unwrap());
        assert!(!atom.matches(&p, &t, &tags));
    }
}