git-meta-lib 0.1.11

Library for attaching and exchanging structured metadata in Git repositories (serialize/materialize, SQLite store, merge).
Documentation
//! Filter and routing logic for metadata keys.
//!
//! Determines which keys should be serialized and to which destinations,
//! based on user-configured filter rules stored in the database.

use crate::db::Store;
use crate::error::{Error, Result};
use crate::types::{Target, ValueType};

/// Prefix for local-only metadata keys that are never serialized.
pub const LOCAL_PREFIX: &str = "local:";

/// The "main" destination name used for the primary ref.
pub const MAIN_DEST: &str = "main";

/// What to do with a key that matches a filter rule.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterAction {
    /// Exclude the key from serialization entirely.
    Exclude,
    /// Route the key to the specified destinations.
    Route(Vec<String>),
}

/// A filter rule consisting of a pattern and an action.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FilterRule {
    /// The action to take for matching keys.
    pub action: FilterAction,
    /// The segments that form the match pattern.
    pub(crate) pattern: Vec<PatternSegment>,
}

/// A single segment in a filter pattern.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PatternSegment {
    /// Matches exactly this literal segment.
    Literal(String),
    /// Matches one arbitrary segment.
    Star,
    /// Matches zero or more arbitrary segments.
    GlobStar,
}

/// Parse filter rules from the database.
///
/// Reads `local:meta:filter` (higher priority) and `meta:filter` (shared)
/// rules from the project scope, returning them in precedence order.
///
/// # Parameters
///
/// - `db`: the metadata database to read rules from
///
/// # Errors
///
/// Returns an error if a rule is syntactically invalid.
pub fn parse_filter_rules(db: &Store) -> Result<Vec<FilterRule>> {
    let mut rules = Vec::new();

    // local:meta:filter rules first (higher priority)
    if let Some(entry) = db.get(&Target::project(), "local:meta:filter")? {
        if entry.value_type == ValueType::Set {
            let members: Vec<String> = serde_json::from_str(&entry.value)?;
            for member in members {
                rules.push(parse_rule(&member)?);
            }
        }
    }

    // Then meta:filter rules (shared/corporate)
    if let Some(entry) = db.get(&Target::project(), "meta:filter")? {
        if entry.value_type == ValueType::Set {
            let members: Vec<String> = serde_json::from_str(&entry.value)?;
            for member in members {
                rules.push(parse_rule(&member)?);
            }
        }
    }

    Ok(rules)
}

/// Parse a single filter rule string.
///
/// Format: `"<action> <pattern> [destinations]"`
fn parse_rule(s: &str) -> Result<FilterRule> {
    let parts: Vec<&str> = s.split_whitespace().collect();
    if parts.len() < 2 {
        return Err(Error::InvalidFilterRule(format!(
            "invalid filter rule (need at least action and pattern): '{s}'"
        )));
    }

    let action = match parts[0] {
        "exclude" => FilterAction::Exclude,
        "route" => {
            if parts.len() < 3 {
                return Err(Error::InvalidFilterRule(format!(
                    "route rule requires a destination: '{s}'"
                )));
            }
            let destinations: Vec<String> = parts[2]
                .split(',')
                .map(|d| d.trim().to_string())
                .filter(|d| !d.is_empty())
                .collect();
            FilterAction::Route(destinations)
        }
        other => {
            return Err(Error::InvalidFilterRule(format!(
                "unknown filter action '{other}' in rule '{s}'"
            )))
        }
    };

    let pattern = parse_pattern(parts[1]);
    Ok(FilterRule { action, pattern })
}

/// Parse a colon-separated pattern into segments.
fn parse_pattern(s: &str) -> Vec<PatternSegment> {
    s.split(':')
        .map(|seg| match seg {
            "**" => PatternSegment::GlobStar,
            "*" => PatternSegment::Star,
            _ => PatternSegment::Literal(seg.to_string()),
        })
        .collect()
}

/// Check whether a pattern matches a sequence of key segments.
fn pattern_matches(pattern: &[PatternSegment], key_segments: &[&str]) -> bool {
    match (pattern.first(), key_segments.first()) {
        (None, None) => true,
        (None, Some(_)) | (Some(_), None) => false,
        (Some(PatternSegment::GlobStar), _) => {
            if pattern.len() == 1 {
                // trailing ** matches everything remaining
                return true;
            }
            // Try matching ** as zero segments, one segment, two segments, etc.
            for skip in 0..=key_segments.len() {
                if pattern_matches(&pattern[1..], &key_segments[skip..]) {
                    return true;
                }
            }
            false
        }
        (Some(PatternSegment::Star), Some(_)) => pattern_matches(&pattern[1..], &key_segments[1..]),
        (Some(PatternSegment::Literal(lit)), Some(seg)) => {
            lit == seg && pattern_matches(&pattern[1..], &key_segments[1..])
        }
    }
}

/// Determine the destination(s) for a key based on filter rules.
///
/// Returns `None` if the key should be excluded (either because it starts
/// with `local:` or because an `exclude` rule matched). Returns
/// `Some(destinations)` otherwise, defaulting to `["main"]` if no route
/// rule matched.
///
/// # Parameters
///
/// - `key`: the metadata key to classify
/// - `rules`: the filter rules to check against (in precedence order)
#[must_use]
pub fn classify_key(key: &str, rules: &[FilterRule]) -> Option<Vec<String>> {
    // Hard rule: local: keys are never serialized on any target.
    if key.starts_with(LOCAL_PREFIX) {
        return None;
    }

    let segments: Vec<&str> = key.split(':').collect();
    let mut matched_routes: Vec<String> = Vec::new();
    let mut excluded = false;

    for rule in rules {
        if pattern_matches(&rule.pattern, &segments) {
            match &rule.action {
                FilterAction::Exclude => {
                    excluded = true;
                }
                FilterAction::Route(dests) => {
                    for d in dests {
                        if !matched_routes.contains(d) {
                            matched_routes.push(d.clone());
                        }
                    }
                }
            }
        }
    }

    if excluded {
        return None;
    }

    if matched_routes.is_empty() {
        Some(vec![MAIN_DEST.to_string()])
    } else {
        Some(matched_routes)
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    fn rule(raw: &str) -> FilterRule {
        parse_rule(raw).unwrap()
    }

    fn classify(raw_rules: &[&str], key: &str) -> Option<Vec<String>> {
        let rules = raw_rules.iter().map(|raw| rule(raw)).collect::<Vec<_>>();
        classify_key(key, &rules)
    }

    #[test]
    fn defaults_to_main_without_matching_rules() {
        assert_eq!(classify(&[], "review:status"), Some(vec!["main".into()]));
        assert_eq!(
            classify(&["route private:** private"], "review:status"),
            Some(vec!["main".into()])
        );
    }

    #[test]
    fn local_keys_are_never_serialized() {
        assert_eq!(classify(&[], "local:cursor"), None);
        assert_eq!(classify(&[], "local:meta:filter"), None);
        assert_eq!(classify(&["route local:** private"], "local:cursor"), None);
        assert_eq!(
            classify(&["route meta:* private"], "meta:local"),
            Some(vec!["private".into()])
        );
    }

    #[test]
    fn exclude_rules_drop_matching_keys() {
        assert_eq!(classify(&["exclude draft:**"], "draft:title"), None);
        assert_eq!(
            classify(&["exclude draft:**"], "draft:review:comment"),
            None
        );
        assert_eq!(
            classify(&["exclude draft:**"], "published:title"),
            Some(vec!["main".into()])
        );
    }

    #[test]
    fn route_rules_replace_main_destination() {
        assert_eq!(
            classify(&["route private:** private"], "private:secret"),
            Some(vec!["private".into()])
        );
        assert_eq!(
            classify(&["route private:** private"], "private:deep:secret"),
            Some(vec!["private".into()])
        );
    }

    #[test]
    fn route_rules_can_send_to_multiple_destinations() {
        assert_eq!(
            classify(&["route acme:** company,audit"], "acme:scan"),
            Some(vec!["company".into(), "audit".into()])
        );
    }

    #[test]
    fn overlapping_routes_are_deduplicated_in_rule_order() {
        assert_eq!(
            classify(
                &[
                    "route acme:** company,audit",
                    "route acme:secrets:** audit,security",
                ],
                "acme:secrets:token"
            ),
            Some(vec!["company".into(), "audit".into(), "security".into()])
        );
    }

    #[test]
    fn exclude_wins_over_route_regardless_of_order() {
        assert_eq!(
            classify(
                &["route private:** private", "exclude private:secret"],
                "private:secret"
            ),
            None
        );
        assert_eq!(
            classify(
                &["exclude private:secret", "route private:** private"],
                "private:secret"
            ),
            None
        );
    }

    #[test]
    fn star_matches_exactly_one_segment() {
        assert_eq!(
            classify(&["route review:* team"], "review:status"),
            Some(vec!["team".into()])
        );
        assert_eq!(
            classify(&["route review:* team"], "review:thread:status"),
            Some(vec!["main".into()])
        );
        assert_eq!(
            classify(&["route review:* team"], "review"),
            Some(vec!["main".into()])
        );
    }

    #[test]
    fn globstar_matches_zero_or_more_segments() {
        assert_eq!(
            classify(&["route review:**:status team"], "review:status"),
            Some(vec!["team".into()])
        );
        assert_eq!(
            classify(&["route review:**:status team"], "review:thread:status"),
            Some(vec!["team".into()])
        );
        assert_eq!(
            classify(
                &["route review:**:status team"],
                "review:thread:subthread:status"
            ),
            Some(vec!["team".into()])
        );
    }

    #[test]
    fn invalid_rules_are_rejected() {
        assert!(parse_rule("exclude").is_err());
        assert!(parse_rule("route private:**").is_err());
        assert!(parse_rule("copy private:** private").is_err());
    }
}