directiva 0.1.0

A tiny, paste-friendly directive mini-language: ACTION:[<KIND>]NAME[@PATH][=NOTE]
Documentation
//! A ready-made "batteries" pack for the classic lint use case: a concrete [`LintAction`]
//! vocabulary (suppress / de-escalate / escalate / note / set), a [`Severity`] ladder, and a
//! [`fold`] combine policy.
//!
//! This is *one* instantiation of the domain-agnostic engine, not part of it. A tool with a
//! different vocabulary (`allow/deny`, `draft<review<final`, …) defines its own pack beside this.

use crate::core::{Action, Directive, Ladder, Target};

/// The classic lint directive verbs.
///
/// `from_token` normalizes case + strips `-` (so `de-escalate` == `deescalate`) and accepts `set`
/// as an alias for `settings`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum LintAction {
    /// Drop the finding entirely.
    Suppress,
    /// One rung less severe (ERROR→WARNING→INFO).
    Deescalate,
    /// One rung more severe (INFO→WARNING→ERROR).
    Escalate,
    /// Annotate only — attach a note, no severity change.
    Note,
    /// Pipeline configuration (`set:KEY=VALUE`); never a finding filter — see [`extract_settings`].
    Set,
}

impl Action for LintAction {
    fn from_token(token: &str) -> Option<Self> {
        match token.trim().to_ascii_lowercase().replace('-', "").as_str() {
            "suppress" => Some(Self::Suppress),
            "deescalate" => Some(Self::Deescalate),
            "escalate" => Some(Self::Escalate),
            "note" => Some(Self::Note),
            "settings" | "set" => Some(Self::Set),
            _ => None,
        }
    }
}

/// Three-rung severity, most-severe first.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Severity {
    Error,
    Warning,
    Info,
}

impl Severity {
    /// The uppercase report label.
    #[must_use]
    pub fn label(self) -> &'static str {
        match self {
            Self::Error => "ERROR",
            Self::Warning => "WARNING",
            Self::Info => "INFO",
        }
    }
}

/// The `Error < Warning < Info` ladder used by [`fold`].
#[must_use]
pub fn ladder() -> Ladder<Severity> {
    Ladder::new(vec![Severity::Error, Severity::Warning, Severity::Info])
}

/// The result of folding directives onto one target.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Folded {
    /// The (possibly stepped + clamped) severity.
    pub severity: Severity,
    /// `true` if any matching `suppress` applies — the caller drops the finding.
    pub dropped: bool,
    /// Notes from every matching directive, in order (accumulated even from a `suppress`).
    pub notes: Vec<String>,
}

/// Fold the matching directives onto a target, in this order:
/// notes accumulate from every match → severity = `current` stepped by the summed
/// escalate(−1)/de-escalate(+1) (saturating) → `dropped` if any `suppress` matches.
///
/// `Set` directives are config, not filters — they are skipped here (use [`extract_settings`]).
/// No dedup: a duplicated `de-escalate` steps twice.
#[must_use]
pub fn fold(
    current: Severity,
    target: &impl Target,
    directives: &[Directive<LintAction>],
) -> Folded {
    let mut notes = Vec::new();
    let mut step = 0i32;
    let mut dropped = false;

    for d in directives {
        if d.action == LintAction::Set || !d.matches(target) {
            continue;
        }
        if let Some(n) = &d.note {
            notes.push(n.clone());
        }
        match d.action {
            LintAction::Deescalate => step += 1,
            LintAction::Escalate => step -= 1,
            LintAction::Suppress => dropped = true,
            LintAction::Note | LintAction::Set => {}
        }
    }

    Folded {
        severity: ladder().step(&current, step),
        dropped,
        notes,
    }
}

/// Collect `set:KEY=VALUE` directives as raw `(key, value)` pairs, in order. The KEY is the NAME
/// (literal), the VALUE is the NOTE (empty if absent). The caller validates/applies them against
/// its own configuration — `lint` stays domain-agnostic about which keys exist.
#[must_use]
pub fn extract_settings(directives: &[Directive<LintAction>]) -> Vec<(String, String)> {
    directives
        .iter()
        .filter(|d| d.action == LintAction::Set)
        .map(|d| {
            (
                d.name.as_str().to_owned(),
                d.note.clone().unwrap_or_default(),
            )
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::{extract_settings, fold, ladder, LintAction, Severity};
    use crate::core::{parse_as, Action, Directive, Pattern, Target};

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

    fn dirs(specs: &[&str]) -> Vec<Directive<LintAction>> {
        specs
            .iter()
            .map(|s| parse_as::<LintAction>(s).unwrap())
            .collect()
    }

    #[test]
    fn from_token_alias_matrix() {
        for (tok, want) in [
            ("suppress", LintAction::Suppress),
            ("Suppress", LintAction::Suppress),
            ("de-escalate", LintAction::Deescalate),
            ("deescalate", LintAction::Deescalate),
            ("DE-ESCALATE", LintAction::Deescalate),
            ("escalate", LintAction::Escalate),
            ("note", LintAction::Note),
            ("settings", LintAction::Set),
            ("set", LintAction::Set),
        ] {
            assert_eq!(LintAction::from_token(tok), Some(want), "{tok}");
        }
        assert_eq!(LintAction::from_token("supress"), None); // typo → rejected
    }

    #[test]
    fn ladder_directions() {
        let l = ladder();
        assert_eq!(l.step(&Severity::Error, 1), Severity::Warning);
        assert_eq!(l.step(&Severity::Warning, -1), Severity::Error);
    }

    // Parity: notes accumulate (incl. suppress), suppress drops.
    #[test]
    fn notes_accumulate_including_suppress() {
        let t = T { names: vec!["foo"] };
        let f = fold(
            Severity::Error,
            &t,
            &dirs(&["note:foo=keep", "suppress:foo=because"]),
        );
        assert_eq!(f.notes, vec!["keep".to_owned(), "because".to_owned()]);
        assert!(f.dropped);
    }

    // Parity: summed, clamped steps; escalate+de-escalate cancels.
    #[test]
    fn severity_steps_sum_and_clamp() {
        let t = T { names: vec!["foo"] };
        // two de-escalate: ERROR → INFO
        let f = fold(
            Severity::Error,
            &t,
            &dirs(&["de-escalate:foo", "de-escalate:foo"]),
        );
        assert_eq!(f.severity, Severity::Info);
        // escalate + de-escalate = no-op
        let f = fold(
            Severity::Warning,
            &t,
            &dirs(&["escalate:foo", "de-escalate:foo"]),
        );
        assert_eq!(f.severity, Severity::Warning);
    }

    // Set directives are skipped in the fold even when their NAME matches the target.
    #[test]
    fn set_directives_skipped_in_fold() {
        let t = T { names: vec!["foo"] };
        let f = fold(Severity::Error, &t, &dirs(&["set:foo=1"]));
        assert_eq!(f.severity, Severity::Error);
        assert!(!f.dropped);
        assert!(f.notes.is_empty());
    }

    #[test]
    fn extract_settings_pairs() {
        let d = dirs(&["set:max-name-group=256", "suppress:foo", "set:gpu=on"]);
        assert_eq!(
            extract_settings(&d),
            vec![
                ("max-name-group".to_owned(), "256".to_owned()),
                ("gpu".to_owned(), "on".to_owned()),
            ]
        );
    }

    // duplicate compounds (not a no-op).
    #[test]
    fn duplicate_directive_compounds() {
        let t = T { names: vec!["foo"] };
        let single = fold(Severity::Error, &t, &dirs(&["de-escalate:foo"]));
        let double = fold(
            Severity::Error,
            &t,
            &dirs(&["de-escalate:foo", "de-escalate:foo"]),
        );
        assert_eq!(single.severity, Severity::Warning);
        assert_eq!(double.severity, Severity::Info);
    }
}