haz-query 0.1.0

Query evaluator over haz task DAGs.
Documentation
//! Lifters that convert raw atoms into typed identifier values.
//!
//! Per `QRY-003`, the per-attribute filters `--tags`,
//! `--projects`, and `--tasks` each constrain their atoms to a
//! specific identifier domain. Each function in this module is a
//! pure [`RawAtom`] -> [`Result`] conversion designed to plug
//! into [`haz_query_lang::expr::Expr::try_map`].

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

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

/// Parse a raw atom as a [`TagName`] per `QRY-003`.
///
/// Designed for use with
/// [`haz_query_lang::expr::Expr::try_map`] to lift an entire
/// parsed expression to `Expr<TagName>` in one step.
///
/// # Errors
///
/// Returns [`AtomError::InvalidTagName`] when the atom text
/// violates `ID-001..ID-005`.
pub fn parse_tag_atom(atom: RawAtom) -> Result<TagName, AtomError> {
    let RawAtom { text, span } = atom;
    TagName::try_new(text).map_err(|source| AtomError::InvalidTagName { span, source })
}

/// Parse a raw atom as a [`ProjectName`] per `QRY-003`.
///
/// # Errors
///
/// Returns [`AtomError::InvalidProjectName`] when the atom text
/// violates `ID-001..ID-005`.
pub fn parse_project_atom(atom: RawAtom) -> Result<ProjectName, AtomError> {
    let RawAtom { text, span } = atom;
    ProjectName::try_new(text).map_err(|source| AtomError::InvalidProjectName { span, source })
}

/// Parse a raw atom as a [`TaskName`] per `QRY-003`.
///
/// # Errors
///
/// Returns [`AtomError::InvalidTaskName`] when the atom text
/// violates `ID-001..ID-005`.
pub fn parse_task_atom(atom: RawAtom) -> Result<TaskName, AtomError> {
    let RawAtom { text, span } = atom;
    TaskName::try_new(text).map_err(|source| AtomError::InvalidTaskName { span, source })
}

#[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 },
        }
    }

    // --- TagName atom parser ----------------------------------

    #[test]
    fn qry_003_parses_valid_tag_atom() {
        let tag = parse_tag_atom(raw("backend", 0, 7)).unwrap();
        assert_eq!(tag.to_string(), "backend");
    }

    #[test]
    fn qry_003_rejects_invalid_tag_atom_and_records_span() {
        let err = parse_tag_atom(raw("with space", 4, 14)).unwrap_err();
        match err {
            AtomError::InvalidTagName { span, source } => {
                assert_eq!(span, Span { start: 4, end: 14 });
                assert!(matches!(source, NameError::InvalidChar { c: ' ' }));
            }
            other => panic!("expected InvalidTagName, got {other:?}"),
        }
    }

    #[test]
    fn qry_003_rejects_empty_tag_atom() {
        let err = parse_tag_atom(raw("", 7, 7)).unwrap_err();
        assert!(matches!(
            err,
            AtomError::InvalidTagName {
                source: NameError::Empty,
                ..
            }
        ));
    }

    // --- ProjectName atom parser ------------------------------

    #[test]
    fn qry_003_parses_valid_project_atom() {
        let project = parse_project_atom(raw("lib_core", 0, 8)).unwrap();
        assert_eq!(project.to_string(), "lib_core");
    }

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

    // --- TaskName atom parser ---------------------------------

    #[test]
    fn qry_003_parses_valid_task_atom() {
        let task = parse_task_atom(raw("compile", 0, 7)).unwrap();
        assert_eq!(task.to_string(), "compile");
    }

    #[test]
    fn qry_003_rejects_invalid_task_atom_and_records_span() {
        // `path/segment` contains `/`, banned by ID-001.
        let err = parse_task_atom(raw("path/segment", 1, 13)).unwrap_err();
        match err {
            AtomError::InvalidTaskName { span, source } => {
                assert_eq!(span, Span { start: 1, end: 13 });
                assert!(matches!(source, NameError::InvalidChar { c: '/' }));
            }
            other => panic!("expected InvalidTaskName, got {other:?}"),
        }
    }

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

    #[test]
    fn qry_003_lifts_parsed_expression_to_typed_tag_expression() {
        let expr = parse("backend & !legacy | infra").unwrap();
        let typed = expr.try_map(parse_tag_atom).unwrap();

        // Expected shape, post-precedence:
        // ((backend & !legacy) | infra)
        let backend = TagName::try_new("backend").unwrap();
        let legacy = TagName::try_new("legacy").unwrap();
        let infra = TagName::try_new("infra").unwrap();
        let expected = Expr::Or(
            Box::new(Expr::And(
                Box::new(Expr::Atom(backend)),
                Box::new(Expr::Not(Box::new(Expr::Atom(legacy)))),
            )),
            Box::new(Expr::Atom(infra)),
        );
        assert_eq!(typed, expected);
    }

    #[test]
    fn qry_003_lifts_parsed_expression_to_typed_project_expression() {
        let expr = parse("lib_core | web-frontend").unwrap();
        let typed = expr.try_map(parse_project_atom).unwrap();
        let lib_core = ProjectName::try_new("lib_core").unwrap();
        let web_frontend = ProjectName::try_new("web-frontend").unwrap();
        let expected = Expr::Or(
            Box::new(Expr::Atom(lib_core)),
            Box::new(Expr::Atom(web_frontend)),
        );
        assert_eq!(typed, expected);
    }

    #[test]
    fn qry_003_lifts_parsed_expression_to_typed_task_expression() {
        let expr = parse("!(test_unit & test_integration)").unwrap();
        let typed = expr.try_map(parse_task_atom).unwrap();
        let unit = TaskName::try_new("test_unit").unwrap();
        let integration = TaskName::try_new("test_integration").unwrap();
        let expected = Expr::Not(Box::new(Expr::And(
            Box::new(Expr::Atom(unit)),
            Box::new(Expr::Atom(integration)),
        )));
        assert_eq!(typed, expected);
    }

    #[test]
    fn qry_003_lift_propagates_first_invalid_atom_with_correct_span() {
        let expr = parse("good | foo.bar | other").unwrap();
        let err = expr.try_map(parse_tag_atom).unwrap_err();
        match err {
            AtomError::InvalidTagName { span, source } => {
                assert_eq!(span, Span { start: 7, end: 14 });
                assert!(matches!(source, NameError::InvalidChar { c: '.' }));
            }
            other => panic!("expected InvalidTagName, got {other:?}"),
        }
    }

    // --- AtomError display ------------------------------------

    #[test]
    fn atom_error_display_includes_span_and_underlying_message() {
        let err = parse_tag_atom(raw("foo.bar", 2, 9)).unwrap_err();
        let rendered = err.to_string();
        assert!(
            rendered.contains("bytes 2..9"),
            "missing span in: {rendered}"
        );
        assert!(
            rendered.contains('.'),
            "missing source detail in: {rendered}"
        );
    }
}