use std::{borrow::Cow, collections::HashMap};
use praxis_core::config::ResultMatch;
use crate::FilterError;
const MAX_KEY_LEN: usize = 64;
const MAX_VALUE_LEN: usize = 256;
const MAX_ENTRIES: usize = 128;
#[derive(Clone, Debug, Default)]
pub struct FilterResultSet {
entries: HashMap<Cow<'static, str>, Cow<'static, str>>,
}
#[derive(Default)]
pub(crate) struct RetainedFilterResults(pub HashMap<&'static str, FilterResultSet>);
impl FilterResultSet {
pub fn new() -> Self {
Self::default()
}
pub fn get(&self, key: &str) -> Option<&str> {
self.entries.get(key).map(AsRef::as_ref)
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn matches(&self, key: &str, value: &str) -> bool {
self.get(key).is_some_and(|v| v == value)
}
pub fn matches_with(&self, key: &str, matcher: &ResultMatch) -> bool {
result_matches(self.get(key), matcher)
}
pub fn set(
&mut self,
key: impl Into<Cow<'static, str>>,
value: impl Into<Cow<'static, str>>,
) -> Result<(), FilterError> {
let key = key.into();
let value = value.into();
validate_result_key(&key)?;
validate_result_value(&value)?;
if !self.entries.contains_key(&key) && self.entries.len() >= MAX_ENTRIES {
return Err(format!("result set is full (max {MAX_ENTRIES} entries)").into());
}
self.entries.insert(key, value);
Ok(())
}
}
pub fn matches_filter_result(
results: &HashMap<&str, FilterResultSet>,
filter_name: &str,
key: &str,
matcher: &ResultMatch,
) -> bool {
result_matches(results.get(filter_name).and_then(|rs| rs.get(key)), matcher)
}
fn result_matches(actual: Option<&str>, matcher: &ResultMatch) -> bool {
match matcher {
ResultMatch::Exact(expected) => actual == Some(expected.as_str()),
ResultMatch::AnyOf { any_of } => actual.is_some_and(|value| any_of.iter().any(|candidate| candidate == value)),
ResultMatch::Contains { contains } => actual.is_some_and(|value| value.contains(contains.as_str())),
ResultMatch::Not { not } => actual != Some(not.as_str()),
}
}
fn validate_result_key(key: &str) -> Result<(), FilterError> {
if key.is_empty() || key.len() > MAX_KEY_LEN {
let len = key.len();
return Err(format!("result key must be 1-{MAX_KEY_LEN} bytes, got {len}").into());
}
if !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
return Err(format!("result key '{key}' must be ASCII alphanumeric, '_', or '-'").into());
}
Ok(())
}
fn validate_result_value(value: &str) -> Result<(), FilterError> {
if value.len() > MAX_VALUE_LEN {
let len = value.len();
return Err(format!("result value must not exceed {MAX_VALUE_LEN} bytes, got {len}").into());
}
if value.bytes().any(|b| (b < 0x20 && b != 0x09) || b == 0x7F) {
return Err("result value must not contain control characters".into());
}
Ok(())
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
reason = "tests"
)]
mod tests {
use super::*;
#[test]
fn new_is_empty() {
let rs = FilterResultSet::new();
assert!(rs.is_empty(), "new result set should be empty");
}
#[test]
fn set_and_get() {
let mut rs = FilterResultSet::new();
rs.set("status", "hit").unwrap();
assert_eq!(rs.get("status"), Some("hit"), "should return set value");
assert!(!rs.is_empty(), "should not be empty after set");
}
#[test]
fn get_missing_key() {
let rs = FilterResultSet::new();
assert_eq!(rs.get("missing"), None, "missing key should return None");
}
#[test]
fn matches_true() {
let mut rs = FilterResultSet::new();
rs.set("status", "hit").unwrap();
assert!(rs.matches("status", "hit"), "exact match should return true");
}
#[test]
fn matches_false_wrong_value() {
let mut rs = FilterResultSet::new();
rs.set("status", "hit").unwrap();
assert!(!rs.matches("status", "miss"), "wrong value should return false");
}
#[test]
fn matches_false_missing_key() {
let rs = FilterResultSet::new();
assert!(!rs.matches("status", "hit"), "missing key should return false");
}
#[test]
fn matches_with_exact_needs_an_equal_value() {
let rs = verdict("unsafe03");
let exact = ResultMatch::Exact("unsafe03".to_owned());
assert!(rs.matches_with("verdict", &exact), "equal value should match");
assert!(
!rs.matches_with("verdict", &ResultMatch::Exact("unsafe".to_owned())),
"a prefix is not an exact match"
);
assert!(!rs.matches_with("missing", &exact), "missing key should not match");
}
#[test]
fn matches_with_contains_needs_the_substring() {
let needle = ResultMatch::Contains {
contains: "unsafe".to_owned(),
};
assert!(
verdict("unsafe03").matches_with("verdict", &needle),
"prefix should match"
);
assert!(
verdict("very-unsafe").matches_with("verdict", &needle),
"suffix should match"
);
assert!(
!verdict("safe").matches_with("verdict", &needle),
"missing substring should not match"
);
assert!(
!verdict("UNSAFE").matches_with("verdict", &needle),
"contains is case-sensitive"
);
assert!(
!verdict("unsafe").matches_with("missing", &needle),
"missing key should not match"
);
}
#[test]
fn matches_with_not_matches_any_other_value_or_none() {
let not_safe = ResultMatch::Not { not: "safe".to_owned() };
assert!(
verdict("unsafe03").matches_with("verdict", ¬_safe),
"different value should match"
);
assert!(
!verdict("safe").matches_with("verdict", ¬_safe),
"the rejected value should not match"
);
assert!(
verdict("safe").matches_with("missing", ¬_safe),
"missing key should match"
);
assert!(
verdict("").matches_with("verdict", ¬_safe),
"an empty value is not safe"
);
}
#[test]
fn matches_with_any_of_needs_a_listed_value() {
let codes = ResultMatch::AnyOf {
any_of: vec!["2".to_owned(), "4".to_owned(), "6".to_owned()],
};
assert!(verdict("2").matches_with("verdict", &codes), "first entry should match");
assert!(verdict("6").matches_with("verdict", &codes), "last entry should match");
assert!(
!verdict("0").matches_with("verdict", &codes),
"unlisted value should not match"
);
assert!(
!verdict("24").matches_with("verdict", &codes),
"entries match whole values only"
);
assert!(
!verdict("2").matches_with("missing", &codes),
"missing key should not match"
);
}
#[test]
fn matches_filter_result_treats_a_silent_filter_as_a_missing_key() {
let mut results = HashMap::new();
results.insert("guard", verdict("unsafe03"));
let not_safe = ResultMatch::Not { not: "safe".to_owned() };
let contains = ResultMatch::Contains {
contains: "unsafe".to_owned(),
};
assert!(
matches_filter_result(&results, "guard", "verdict", &contains),
"the named filter's result should be checked"
);
assert!(
!matches_filter_result(&results, "other", "verdict", &contains),
"a filter with no results has no value to contain anything"
);
assert!(
matches_filter_result(&results, "other", "verdict", ¬_safe),
"not should match a filter that wrote no results"
);
}
#[test]
fn set_overwrites_existing() {
let mut rs = FilterResultSet::new();
rs.set("status", "hit").unwrap();
rs.set("status", "miss").unwrap();
assert_eq!(rs.get("status"), Some("miss"), "second set should overwrite");
}
#[test]
fn set_multiple_keys() {
let mut rs = FilterResultSet::new();
rs.set("status", "hit").unwrap();
rs.set("tier", "premium").unwrap();
assert_eq!(rs.get("status"), Some("hit"), "first key should be retained");
assert_eq!(rs.get("tier"), Some("premium"), "second key should be present");
}
#[test]
fn set_rejects_a_new_key_beyond_max_entries() {
let mut rs = FilterResultSet::new();
for i in 0..MAX_ENTRIES {
rs.set(format!("key-{i}"), "v").unwrap();
}
let err = rs.set("overflow", "v").unwrap_err();
assert!(
err.to_string().contains("full"),
"new key beyond the cap must be rejected: {err}"
);
rs.set("key-0", "updated")
.expect("overwriting an existing key must still succeed at the cap");
assert_eq!(rs.get("key-0"), Some("updated"), "overwrite should take effect");
}
#[test]
fn reject_empty_key() {
let mut rs = FilterResultSet::new();
let err = rs.set("", "value").unwrap_err();
assert!(
err.to_string().contains("1-64 bytes"),
"empty key error should mention size constraint: {err}"
);
}
#[test]
fn reject_key_too_long() {
let mut rs = FilterResultSet::new();
let long_key = "a".repeat(65);
let err = rs.set(long_key, "value").unwrap_err();
assert!(
err.to_string().contains("1-64 bytes"),
"long key error should mention size constraint: {err}"
);
}
#[test]
fn accept_key_at_max_length() {
let mut rs = FilterResultSet::new();
let key = "a".repeat(64);
assert!(rs.set(key, "value").is_ok(), "64-byte key should be accepted");
}
#[test]
fn reject_key_with_spaces() {
let mut rs = FilterResultSet::new();
let err = rs.set("bad key", "value").unwrap_err();
assert!(
err.to_string().contains("alphanumeric"),
"key with spaces should be rejected: {err}"
);
}
#[test]
fn reject_key_with_special_chars() {
let mut rs = FilterResultSet::new();
let err = rs.set("key.dot", "value").unwrap_err();
assert!(
err.to_string().contains("alphanumeric"),
"key with dots should be rejected: {err}"
);
}
#[test]
fn accept_key_with_underscore_and_hyphen() {
let mut rs = FilterResultSet::new();
assert!(
rs.set("my-key_1", "value").is_ok(),
"key with underscore and hyphen should be accepted"
);
}
#[test]
fn reject_value_too_long() {
let mut rs = FilterResultSet::new();
let long_value = "x".repeat(257);
let err = rs.set("key", long_value).unwrap_err();
assert!(
err.to_string().contains("256 bytes"),
"long value error should mention size constraint: {err}"
);
}
#[test]
fn accept_value_at_max_length() {
let mut rs = FilterResultSet::new();
let value = "x".repeat(256);
assert!(rs.set("key", value).is_ok(), "256-byte value should be accepted");
}
#[test]
fn reject_value_with_control_chars() {
let mut rs = FilterResultSet::new();
let err = rs.set("key", "line\x00null").unwrap_err();
assert!(
err.to_string().contains("control characters"),
"value with null byte should be rejected: {err}"
);
}
#[test]
fn reject_value_with_newline() {
let mut rs = FilterResultSet::new();
let err = rs.set("key", "line\nbreak").unwrap_err();
assert!(
err.to_string().contains("control characters"),
"value with newline should be rejected: {err}"
);
}
#[test]
fn accept_value_with_tab() {
let mut rs = FilterResultSet::new();
assert!(rs.set("key", "col1\tcol2").is_ok(), "value with tab should be accepted");
}
#[test]
fn reject_value_with_del() {
let mut rs = FilterResultSet::new();
let err = rs.set("key", "before\x7Fafter").unwrap_err();
assert!(
err.to_string().contains("control characters"),
"value with DEL (0x7F) should be rejected: {err}"
);
}
#[test]
fn accept_empty_value() {
let mut rs = FilterResultSet::new();
assert!(rs.set("key", "").is_ok(), "empty value should be accepted");
}
#[test]
fn default_is_empty() {
let rs = FilterResultSet::default();
assert!(rs.is_empty(), "default result set should be empty");
}
#[test]
fn clone_preserves_entries() {
let mut rs = FilterResultSet::new();
rs.set("a", "1").unwrap();
let cloned = rs.clone();
assert_eq!(cloned.get("a"), Some("1"), "clone should preserve entries");
}
#[test]
fn set_with_cow_borrowed() {
let mut rs = FilterResultSet::new();
rs.set(Cow::Borrowed("static_key"), Cow::Borrowed("static_val"))
.unwrap();
assert_eq!(rs.get("static_key"), Some("static_val"), "Cow::Borrowed should work");
}
#[test]
fn set_with_cow_owned() {
let mut rs = FilterResultSet::new();
rs.set(
Cow::<str>::Owned("owned_key".to_owned()),
Cow::<str>::Owned("owned_val".to_owned()),
)
.unwrap();
assert_eq!(rs.get("owned_key"), Some("owned_val"), "Cow::Owned should work");
}
fn verdict(value: &'static str) -> FilterResultSet {
let mut rs = FilterResultSet::new();
rs.set("verdict", value).unwrap();
rs
}
}