directiva 0.2.0

A tiny, paste-friendly directive mini-language: ACTION:[<KIND>]NAME[@PATH][=NOTE]
Documentation
//! The [`Directive`] type, the caller-owned [`Action`] vocabulary, and directive↔target matching.

use crate::core::glob::Pattern;
use crate::core::target::Target;

/// A caller-defined action vocabulary.
///
/// The crate ships **no** built-in actions: the ACTION token is opaque, and you map it to your own
/// type. Return `None` for an unrecognized token to make the parser reject it (closed sets catch
/// typos); the blanket [`String`] impl accepts every token (an open set, e.g. plain markup).
pub trait Action: Sized {
    /// Resolve a raw, un-normalized action token (already whitespace-trimmed) into `Self`.
    fn from_token(token: &str) -> Option<Self>;
}

/// Open-set default: every token is accepted verbatim.
impl Action for String {
    fn from_token(token: &str) -> Option<Self> {
        Some(token.to_owned())
    }
}

/// One parsed directive: `ACTION:[<KIND>]NAME[@PATH][=NOTE]`.
///
/// Generic over the action vocabulary `A`, defaulting to [`String`]. Matching ([`Directive::matches`])
/// is independent of `A`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Directive<A = String> {
    /// The resolved action.
    pub action: A,
    /// Optional KIND filter (`<*>`/omitted ⇒ `None` ⇒ any). Stored un-escaped; matched exactly.
    pub kind: Option<String>,
    /// The (required) NAME glob.
    pub name: Pattern,
    /// The optional `@PATH` glob.
    pub path: Option<Pattern>,
    /// The optional `=NOTE` — free text, taken raw.
    pub note: Option<String>,
}

impl<A> Directive<A> {
    /// Does this directive apply to `t`?
    ///
    /// - KIND (if set) must equal `t.qualifier()` exactly;
    /// - NAME must match (the target decides over which of its names, via [`Target::matches_name`]);
    /// - PATH (if set) must match one of the target's scopes ([`Target::matches_scope`]).
    ///
    /// Action-independent: a caller that wants to ignore some actions (e.g. a `set` config
    /// directive) filters those out *before* matching.
    pub fn matches(&self, t: &impl Target) -> bool {
        if let Some(k) = &self.kind {
            if t.qualifier() != Some(k.as_str()) {
                return false;
            }
        }
        if !t.matches_name(&self.name) {
            return false;
        }
        if let Some(p) = &self.path {
            if !t.matches_scope(p) {
                return false;
            }
        }
        true
    }
}

/// Best-effort round-trip. Reproduces the canonical form; fields are emitted as stored, so a KIND
/// containing `>`/`=` won't re-escape (KINDs in practice are plain identifiers).
impl<A: std::fmt::Display> std::fmt::Display for Directive<A> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}:", self.action)?;
        if let Some(k) = &self.kind {
            write!(f, "<{k}>")?;
        }
        write!(f, "{}", self.name)?;
        if let Some(p) = &self.path {
            write!(f, "@{p}")?;
        }
        if let Some(n) = &self.note {
            write!(f, "={n}")?;
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use crate::core::glob::Pattern;
    use crate::core::parse::parse;
    use crate::core::target::Target;

    struct T {
        q: Option<&'static str>,
        names: Vec<&'static str>,
        scopes: Vec<&'static str>,
    }
    impl Target for T {
        fn qualifier(&self) -> Option<&str> {
            self.q
        }
        fn matches_name(&self, p: &Pattern) -> bool {
            self.names.iter().any(|n| p.matches(n))
        }
        fn matches_scope(&self, p: &Pattern) -> bool {
            self.scopes.iter().any(|s| p.matches(s))
        }
    }

    // any-of-names
    #[test]
    fn matches_any_alias() {
        let t = T {
            q: None,
            names: vec!["Foo.bar", "Baz.bar"],
            scopes: vec![],
        };
        assert!(parse("suppress:*.bar").unwrap().matches(&t));
        assert!(!parse("suppress:*.qux").unwrap().matches(&t));
    }

    // KIND exact mismatch
    #[test]
    fn kind_must_match_exactly() {
        let t = T {
            q: Some("function"),
            names: vec!["foo"],
            scopes: vec![],
        };
        assert!(!parse("suppress:<method>foo").unwrap().matches(&t));
        assert!(parse("suppress:<function>foo").unwrap().matches(&t));
    }

    // any-of-scopes
    #[test]
    fn path_matches_any_scope() {
        let t = T {
            q: None,
            names: vec!["foo"],
            scopes: vec!["src/a.rs", "src/tests/b.rs", "src/c.rs"],
        };
        assert!(parse("suppress:foo@*/tests/*").unwrap().matches(&t));
        assert!(!parse("suppress:foo@*/bench/*").unwrap().matches(&t));
    }

    // name alone, any qualifier/scope
    #[test]
    fn name_only_ignores_kind_and_path() {
        let t = T {
            q: Some("anything"),
            names: vec!["foo"],
            scopes: vec!["wherever"],
        };
        assert!(parse("suppress:foo").unwrap().matches(&t));
    }

    // crate hardcodes no '/' join; the target may expose the joined form itself
    #[test]
    fn joined_alias_form_is_targets_choice() {
        let t = T {
            q: None,
            names: vec!["Foo.bar", "Baz.bar", "Foo.bar/Baz.bar"],
            scopes: vec![],
        };
        assert!(parse("suppress:Foo.bar/Baz.bar").unwrap().matches(&t));
    }
}