use crate::db::Store;
use crate::error::{Error, Result};
use crate::types::{Target, ValueType};
pub const LOCAL_PREFIX: &str = "local:";
pub const MAIN_DEST: &str = "main";
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterAction {
Exclude,
Route(Vec<String>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FilterRule {
pub action: FilterAction,
pub(crate) pattern: Vec<PatternSegment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PatternSegment {
Literal(String),
Star,
GlobStar,
}
pub fn parse_filter_rules(db: &Store) -> Result<Vec<FilterRule>> {
let mut rules = Vec::new();
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)?);
}
}
}
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)
}
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 })
}
fn parse_pattern(s: &str) -> Vec<PatternSegment> {
s.split(':')
.map(|seg| match seg {
"**" => PatternSegment::GlobStar,
"*" => PatternSegment::Star,
_ => PatternSegment::Literal(seg.to_string()),
})
.collect()
}
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 {
return true;
}
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..])
}
}
}
#[must_use]
pub fn classify_key(key: &str, rules: &[FilterRule]) -> Option<Vec<String>> {
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());
}
}