Skip to main content

praxis_filter/
results.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Filter result feedback for branch chain evaluation.
5
6use std::{borrow::Cow, collections::HashMap};
7
8use crate::FilterError;
9
10// ---------------------------------------------------------------------------
11// Constants
12// ---------------------------------------------------------------------------
13
14/// Maximum length of a result key in bytes.
15const MAX_KEY_LEN: usize = 64;
16
17/// Maximum length of a result value in bytes.
18const MAX_VALUE_LEN: usize = 256;
19
20// ---------------------------------------------------------------------------
21// FilterResultSet
22// ---------------------------------------------------------------------------
23
24/// Result feedback from a single filter execution.
25///
26/// Filters populate this to communicate outcomes
27/// (e.g. cache hit/miss, auth success/failure)
28/// without knowing about branching.
29///
30/// ```
31/// use praxis_filter::FilterResultSet;
32///
33/// let mut results = FilterResultSet::new();
34/// results.set("status", "hit").unwrap();
35/// assert_eq!(results.get("status"), Some("hit"));
36/// assert!(results.matches("status", "hit"));
37/// assert!(!results.matches("status", "miss"));
38/// ```
39#[derive(Debug, Clone, Default)]
40pub struct FilterResultSet {
41    /// Key-value result entries.
42    entries: HashMap<Cow<'static, str>, Cow<'static, str>>,
43}
44
45impl FilterResultSet {
46    /// Create an empty result set.
47    ///
48    /// ```
49    /// use praxis_filter::FilterResultSet;
50    ///
51    /// let rs = FilterResultSet::new();
52    /// assert!(rs.is_empty());
53    /// ```
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Get a result value by key.
59    ///
60    /// ```
61    /// use praxis_filter::FilterResultSet;
62    ///
63    /// let mut rs = FilterResultSet::new();
64    /// rs.set("action", "allow").unwrap();
65    /// assert_eq!(rs.get("action"), Some("allow"));
66    /// assert_eq!(rs.get("missing"), None);
67    /// ```
68    pub fn get(&self, key: &str) -> Option<&str> {
69        self.entries.get(key).map(AsRef::as_ref)
70    }
71
72    /// Whether the result set has no entries.
73    pub fn is_empty(&self) -> bool {
74        self.entries.is_empty()
75    }
76
77    /// Whether a key-value pair matches.
78    ///
79    /// ```
80    /// use praxis_filter::FilterResultSet;
81    ///
82    /// let mut rs = FilterResultSet::new();
83    /// rs.set("tier", "premium").unwrap();
84    /// assert!(rs.matches("tier", "premium"));
85    /// assert!(!rs.matches("tier", "free"));
86    /// assert!(!rs.matches("missing", "x"));
87    /// ```
88    pub fn matches(&self, key: &str, value: &str) -> bool {
89        self.get(key).is_some_and(|v| v == value)
90    }
91
92    /// Set a result key-value pair.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`FilterError`] if:
97    /// - `key` is empty, exceeds 64 bytes, or contains non-ASCII-alphanumeric characters (besides `_` and `-`)
98    /// - `value` exceeds 256 bytes or contains control characters (0x00-0x1F except 0x09/tab)
99    ///
100    /// ```
101    /// use praxis_filter::FilterResultSet;
102    ///
103    /// let mut rs = FilterResultSet::new();
104    /// assert!(rs.set("valid-key_1", "value").is_ok());
105    /// assert!(rs.set("", "value").is_err());
106    /// ```
107    pub fn set(
108        &mut self,
109        key: impl Into<Cow<'static, str>>,
110        value: impl Into<Cow<'static, str>>,
111    ) -> Result<(), FilterError> {
112        let key = key.into();
113        let value = value.into();
114        validate_result_key(&key)?;
115        validate_result_value(&value)?;
116        self.entries.insert(key, value);
117        Ok(())
118    }
119}
120
121// ---------------------------------------------------------------------------
122// Validation
123// ---------------------------------------------------------------------------
124
125/// Validate a result key.
126fn validate_result_key(key: &str) -> Result<(), FilterError> {
127    if key.is_empty() || key.len() > MAX_KEY_LEN {
128        let len = key.len();
129        return Err(format!("result key must be 1-{MAX_KEY_LEN} bytes, got {len}").into());
130    }
131    if !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
132        return Err(format!("result key '{key}' must be ASCII alphanumeric, '_', or '-'").into());
133    }
134    Ok(())
135}
136
137/// Validate a result value.
138fn validate_result_value(value: &str) -> Result<(), FilterError> {
139    if value.len() > MAX_VALUE_LEN {
140        let len = value.len();
141        return Err(format!("result value must not exceed {MAX_VALUE_LEN} bytes, got {len}").into());
142    }
143    if value.bytes().any(|b| (b < 0x20 && b != 0x09) || b == 0x7F) {
144        return Err("result value must not contain control characters".into());
145    }
146    Ok(())
147}
148
149// ---------------------------------------------------------------------------
150// Tests
151// ---------------------------------------------------------------------------
152
153#[cfg(test)]
154#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
155#[allow(
156    clippy::unwrap_used,
157    clippy::expect_used,
158    clippy::indexing_slicing,
159    clippy::panic,
160    reason = "tests"
161)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn new_is_empty() {
167        let rs = FilterResultSet::new();
168        assert!(rs.is_empty(), "new result set should be empty");
169    }
170
171    #[test]
172    fn set_and_get() {
173        let mut rs = FilterResultSet::new();
174        rs.set("status", "hit").unwrap();
175        assert_eq!(rs.get("status"), Some("hit"), "should return set value");
176        assert!(!rs.is_empty(), "should not be empty after set");
177    }
178
179    #[test]
180    fn get_missing_key() {
181        let rs = FilterResultSet::new();
182        assert_eq!(rs.get("missing"), None, "missing key should return None");
183    }
184
185    #[test]
186    fn matches_true() {
187        let mut rs = FilterResultSet::new();
188        rs.set("status", "hit").unwrap();
189        assert!(rs.matches("status", "hit"), "exact match should return true");
190    }
191
192    #[test]
193    fn matches_false_wrong_value() {
194        let mut rs = FilterResultSet::new();
195        rs.set("status", "hit").unwrap();
196        assert!(!rs.matches("status", "miss"), "wrong value should return false");
197    }
198
199    #[test]
200    fn matches_false_missing_key() {
201        let rs = FilterResultSet::new();
202        assert!(!rs.matches("status", "hit"), "missing key should return false");
203    }
204
205    #[test]
206    fn set_overwrites_existing() {
207        let mut rs = FilterResultSet::new();
208        rs.set("status", "hit").unwrap();
209        rs.set("status", "miss").unwrap();
210        assert_eq!(rs.get("status"), Some("miss"), "second set should overwrite");
211    }
212
213    #[test]
214    fn set_multiple_keys() {
215        let mut rs = FilterResultSet::new();
216        rs.set("status", "hit").unwrap();
217        rs.set("tier", "premium").unwrap();
218        assert_eq!(rs.get("status"), Some("hit"), "first key should be retained");
219        assert_eq!(rs.get("tier"), Some("premium"), "second key should be present");
220    }
221
222    #[test]
223    fn reject_empty_key() {
224        let mut rs = FilterResultSet::new();
225        let err = rs.set("", "value").unwrap_err();
226        assert!(
227            err.to_string().contains("1-64 bytes"),
228            "empty key error should mention size constraint: {err}"
229        );
230    }
231
232    #[test]
233    fn reject_key_too_long() {
234        let mut rs = FilterResultSet::new();
235        let long_key = "a".repeat(65);
236        let err = rs.set(long_key, "value").unwrap_err();
237        assert!(
238            err.to_string().contains("1-64 bytes"),
239            "long key error should mention size constraint: {err}"
240        );
241    }
242
243    #[test]
244    fn accept_key_at_max_length() {
245        let mut rs = FilterResultSet::new();
246        let key = "a".repeat(64);
247        assert!(rs.set(key, "value").is_ok(), "64-byte key should be accepted");
248    }
249
250    #[test]
251    fn reject_key_with_spaces() {
252        let mut rs = FilterResultSet::new();
253        let err = rs.set("bad key", "value").unwrap_err();
254        assert!(
255            err.to_string().contains("alphanumeric"),
256            "key with spaces should be rejected: {err}"
257        );
258    }
259
260    #[test]
261    fn reject_key_with_special_chars() {
262        let mut rs = FilterResultSet::new();
263        let err = rs.set("key.dot", "value").unwrap_err();
264        assert!(
265            err.to_string().contains("alphanumeric"),
266            "key with dots should be rejected: {err}"
267        );
268    }
269
270    #[test]
271    fn accept_key_with_underscore_and_hyphen() {
272        let mut rs = FilterResultSet::new();
273        assert!(
274            rs.set("my-key_1", "value").is_ok(),
275            "key with underscore and hyphen should be accepted"
276        );
277    }
278
279    #[test]
280    fn reject_value_too_long() {
281        let mut rs = FilterResultSet::new();
282        let long_value = "x".repeat(257);
283        let err = rs.set("key", long_value).unwrap_err();
284        assert!(
285            err.to_string().contains("256 bytes"),
286            "long value error should mention size constraint: {err}"
287        );
288    }
289
290    #[test]
291    fn accept_value_at_max_length() {
292        let mut rs = FilterResultSet::new();
293        let value = "x".repeat(256);
294        assert!(rs.set("key", value).is_ok(), "256-byte value should be accepted");
295    }
296
297    #[test]
298    fn reject_value_with_control_chars() {
299        let mut rs = FilterResultSet::new();
300        let err = rs.set("key", "line\x00null").unwrap_err();
301        assert!(
302            err.to_string().contains("control characters"),
303            "value with null byte should be rejected: {err}"
304        );
305    }
306
307    #[test]
308    fn reject_value_with_newline() {
309        let mut rs = FilterResultSet::new();
310        let err = rs.set("key", "line\nbreak").unwrap_err();
311        assert!(
312            err.to_string().contains("control characters"),
313            "value with newline should be rejected: {err}"
314        );
315    }
316
317    #[test]
318    fn accept_value_with_tab() {
319        let mut rs = FilterResultSet::new();
320        assert!(rs.set("key", "col1\tcol2").is_ok(), "value with tab should be accepted");
321    }
322
323    #[test]
324    fn reject_value_with_del() {
325        let mut rs = FilterResultSet::new();
326        let err = rs.set("key", "before\x7Fafter").unwrap_err();
327        assert!(
328            err.to_string().contains("control characters"),
329            "value with DEL (0x7F) should be rejected: {err}"
330        );
331    }
332
333    #[test]
334    fn accept_empty_value() {
335        let mut rs = FilterResultSet::new();
336        assert!(rs.set("key", "").is_ok(), "empty value should be accepted");
337    }
338
339    #[test]
340    fn default_is_empty() {
341        let rs = FilterResultSet::default();
342        assert!(rs.is_empty(), "default result set should be empty");
343    }
344
345    #[test]
346    fn clone_preserves_entries() {
347        let mut rs = FilterResultSet::new();
348        rs.set("a", "1").unwrap();
349        let cloned = rs.clone();
350        assert_eq!(cloned.get("a"), Some("1"), "clone should preserve entries");
351    }
352
353    #[test]
354    fn set_with_cow_borrowed() {
355        let mut rs = FilterResultSet::new();
356        rs.set(Cow::Borrowed("static_key"), Cow::Borrowed("static_val"))
357            .unwrap();
358        assert_eq!(rs.get("static_key"), Some("static_val"), "Cow::Borrowed should work");
359    }
360
361    #[test]
362    fn set_with_cow_owned() {
363        let mut rs = FilterResultSet::new();
364        rs.set(
365            Cow::<str>::Owned("owned_key".to_owned()),
366            Cow::<str>::Owned("owned_val".to_owned()),
367        )
368        .unwrap();
369        assert_eq!(rs.get("owned_key"), Some("owned_val"), "Cow::Owned should work");
370    }
371}