1use std::{borrow::Cow, collections::HashMap};
29
30use crate::FilterError;
31
32const MAX_KEY_LEN: usize = 64;
38
39const MAX_VALUE_LEN: usize = 256;
41
42#[derive(Clone, Debug, Default)]
62pub struct FilterResultSet {
63 entries: HashMap<Cow<'static, str>, Cow<'static, str>>,
65}
66
67#[derive(Default)]
72pub(crate) struct RetainedFilterResults(pub HashMap<&'static str, FilterResultSet>);
73
74impl FilterResultSet {
75 pub fn new() -> Self {
84 Self::default()
85 }
86
87 pub fn get(&self, key: &str) -> Option<&str> {
98 self.entries.get(key).map(AsRef::as_ref)
99 }
100
101 pub fn is_empty(&self) -> bool {
103 self.entries.is_empty()
104 }
105
106 pub fn matches(&self, key: &str, value: &str) -> bool {
118 self.get(key).is_some_and(|v| v == value)
119 }
120
121 pub fn set(
137 &mut self,
138 key: impl Into<Cow<'static, str>>,
139 value: impl Into<Cow<'static, str>>,
140 ) -> Result<(), FilterError> {
141 let key = key.into();
142 let value = value.into();
143 validate_result_key(&key)?;
144 validate_result_value(&value)?;
145 self.entries.insert(key, value);
146 Ok(())
147 }
148}
149
150pub fn matches_filter_result(
187 results: &HashMap<&str, FilterResultSet>,
188 filter_name: &str,
189 key: &str,
190 value: &str,
191) -> bool {
192 results.get(filter_name).is_some_and(|rs| rs.matches(key, value))
193}
194
195fn validate_result_key(key: &str) -> Result<(), FilterError> {
201 if key.is_empty() || key.len() > MAX_KEY_LEN {
202 let len = key.len();
203 return Err(format!("result key must be 1-{MAX_KEY_LEN} bytes, got {len}").into());
204 }
205 if !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
206 return Err(format!("result key '{key}' must be ASCII alphanumeric, '_', or '-'").into());
207 }
208 Ok(())
209}
210
211fn validate_result_value(value: &str) -> Result<(), FilterError> {
213 if value.len() > MAX_VALUE_LEN {
214 let len = value.len();
215 return Err(format!("result value must not exceed {MAX_VALUE_LEN} bytes, got {len}").into());
216 }
217 if value.bytes().any(|b| (b < 0x20 && b != 0x09) || b == 0x7F) {
218 return Err("result value must not contain control characters".into());
219 }
220 Ok(())
221}
222
223#[cfg(test)]
228#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
229#[allow(
230 clippy::unwrap_used,
231 clippy::expect_used,
232 clippy::indexing_slicing,
233 clippy::panic,
234 reason = "tests"
235)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn new_is_empty() {
241 let rs = FilterResultSet::new();
242 assert!(rs.is_empty(), "new result set should be empty");
243 }
244
245 #[test]
246 fn set_and_get() {
247 let mut rs = FilterResultSet::new();
248 rs.set("status", "hit").unwrap();
249 assert_eq!(rs.get("status"), Some("hit"), "should return set value");
250 assert!(!rs.is_empty(), "should not be empty after set");
251 }
252
253 #[test]
254 fn get_missing_key() {
255 let rs = FilterResultSet::new();
256 assert_eq!(rs.get("missing"), None, "missing key should return None");
257 }
258
259 #[test]
260 fn matches_true() {
261 let mut rs = FilterResultSet::new();
262 rs.set("status", "hit").unwrap();
263 assert!(rs.matches("status", "hit"), "exact match should return true");
264 }
265
266 #[test]
267 fn matches_false_wrong_value() {
268 let mut rs = FilterResultSet::new();
269 rs.set("status", "hit").unwrap();
270 assert!(!rs.matches("status", "miss"), "wrong value should return false");
271 }
272
273 #[test]
274 fn matches_false_missing_key() {
275 let rs = FilterResultSet::new();
276 assert!(!rs.matches("status", "hit"), "missing key should return false");
277 }
278
279 #[test]
280 fn set_overwrites_existing() {
281 let mut rs = FilterResultSet::new();
282 rs.set("status", "hit").unwrap();
283 rs.set("status", "miss").unwrap();
284 assert_eq!(rs.get("status"), Some("miss"), "second set should overwrite");
285 }
286
287 #[test]
288 fn set_multiple_keys() {
289 let mut rs = FilterResultSet::new();
290 rs.set("status", "hit").unwrap();
291 rs.set("tier", "premium").unwrap();
292 assert_eq!(rs.get("status"), Some("hit"), "first key should be retained");
293 assert_eq!(rs.get("tier"), Some("premium"), "second key should be present");
294 }
295
296 #[test]
297 fn reject_empty_key() {
298 let mut rs = FilterResultSet::new();
299 let err = rs.set("", "value").unwrap_err();
300 assert!(
301 err.to_string().contains("1-64 bytes"),
302 "empty key error should mention size constraint: {err}"
303 );
304 }
305
306 #[test]
307 fn reject_key_too_long() {
308 let mut rs = FilterResultSet::new();
309 let long_key = "a".repeat(65);
310 let err = rs.set(long_key, "value").unwrap_err();
311 assert!(
312 err.to_string().contains("1-64 bytes"),
313 "long key error should mention size constraint: {err}"
314 );
315 }
316
317 #[test]
318 fn accept_key_at_max_length() {
319 let mut rs = FilterResultSet::new();
320 let key = "a".repeat(64);
321 assert!(rs.set(key, "value").is_ok(), "64-byte key should be accepted");
322 }
323
324 #[test]
325 fn reject_key_with_spaces() {
326 let mut rs = FilterResultSet::new();
327 let err = rs.set("bad key", "value").unwrap_err();
328 assert!(
329 err.to_string().contains("alphanumeric"),
330 "key with spaces should be rejected: {err}"
331 );
332 }
333
334 #[test]
335 fn reject_key_with_special_chars() {
336 let mut rs = FilterResultSet::new();
337 let err = rs.set("key.dot", "value").unwrap_err();
338 assert!(
339 err.to_string().contains("alphanumeric"),
340 "key with dots should be rejected: {err}"
341 );
342 }
343
344 #[test]
345 fn accept_key_with_underscore_and_hyphen() {
346 let mut rs = FilterResultSet::new();
347 assert!(
348 rs.set("my-key_1", "value").is_ok(),
349 "key with underscore and hyphen should be accepted"
350 );
351 }
352
353 #[test]
354 fn reject_value_too_long() {
355 let mut rs = FilterResultSet::new();
356 let long_value = "x".repeat(257);
357 let err = rs.set("key", long_value).unwrap_err();
358 assert!(
359 err.to_string().contains("256 bytes"),
360 "long value error should mention size constraint: {err}"
361 );
362 }
363
364 #[test]
365 fn accept_value_at_max_length() {
366 let mut rs = FilterResultSet::new();
367 let value = "x".repeat(256);
368 assert!(rs.set("key", value).is_ok(), "256-byte value should be accepted");
369 }
370
371 #[test]
372 fn reject_value_with_control_chars() {
373 let mut rs = FilterResultSet::new();
374 let err = rs.set("key", "line\x00null").unwrap_err();
375 assert!(
376 err.to_string().contains("control characters"),
377 "value with null byte should be rejected: {err}"
378 );
379 }
380
381 #[test]
382 fn reject_value_with_newline() {
383 let mut rs = FilterResultSet::new();
384 let err = rs.set("key", "line\nbreak").unwrap_err();
385 assert!(
386 err.to_string().contains("control characters"),
387 "value with newline should be rejected: {err}"
388 );
389 }
390
391 #[test]
392 fn accept_value_with_tab() {
393 let mut rs = FilterResultSet::new();
394 assert!(rs.set("key", "col1\tcol2").is_ok(), "value with tab should be accepted");
395 }
396
397 #[test]
398 fn reject_value_with_del() {
399 let mut rs = FilterResultSet::new();
400 let err = rs.set("key", "before\x7Fafter").unwrap_err();
401 assert!(
402 err.to_string().contains("control characters"),
403 "value with DEL (0x7F) should be rejected: {err}"
404 );
405 }
406
407 #[test]
408 fn accept_empty_value() {
409 let mut rs = FilterResultSet::new();
410 assert!(rs.set("key", "").is_ok(), "empty value should be accepted");
411 }
412
413 #[test]
414 fn default_is_empty() {
415 let rs = FilterResultSet::default();
416 assert!(rs.is_empty(), "default result set should be empty");
417 }
418
419 #[test]
420 fn clone_preserves_entries() {
421 let mut rs = FilterResultSet::new();
422 rs.set("a", "1").unwrap();
423 let cloned = rs.clone();
424 assert_eq!(cloned.get("a"), Some("1"), "clone should preserve entries");
425 }
426
427 #[test]
428 fn set_with_cow_borrowed() {
429 let mut rs = FilterResultSet::new();
430 rs.set(Cow::Borrowed("static_key"), Cow::Borrowed("static_val"))
431 .unwrap();
432 assert_eq!(rs.get("static_key"), Some("static_val"), "Cow::Borrowed should work");
433 }
434
435 #[test]
436 fn set_with_cow_owned() {
437 let mut rs = FilterResultSet::new();
438 rs.set(
439 Cow::<str>::Owned("owned_key".to_owned()),
440 Cow::<str>::Owned("owned_val".to_owned()),
441 )
442 .unwrap();
443 assert_eq!(rs.get("owned_key"), Some("owned_val"), "Cow::Owned should work");
444 }
445}