use std::collections::BTreeSet;
use haz_domain::name::{ProjectName, TagName, TaskName};
use haz_query_lang::expr::RawAtom;
use crate::expr::atom::AtomError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum RelationalAtom {
Name(TaskName),
Project(ProjectName),
Tag(TagName),
}
impl RelationalAtom {
#[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),
}
}
}
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 },
}
}
#[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);
}
#[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:?}"),
}
}
#[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() {
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:?}"),
}
}
#[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() {
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:?}"),
}
}
#[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 } => {
assert_eq!(span, Span { start: 14, end: 25 });
assert_eq!(kind, "bogus");
}
other => panic!("expected UnknownRelationalKind, got {other:?}"),
}
}
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));
}
}