1use std::{borrow::Cow, collections::HashMap};
7
8use crate::FilterError;
9
10const MAX_KEY_LEN: usize = 64;
16
17const MAX_VALUE_LEN: usize = 256;
19
20#[derive(Debug, Clone, Default)]
40pub struct FilterResultSet {
41 entries: HashMap<Cow<'static, str>, Cow<'static, str>>,
43}
44
45impl FilterResultSet {
46 pub fn new() -> Self {
55 Self::default()
56 }
57
58 pub fn get(&self, key: &str) -> Option<&str> {
69 self.entries.get(key).map(AsRef::as_ref)
70 }
71
72 pub fn is_empty(&self) -> bool {
74 self.entries.is_empty()
75 }
76
77 pub fn matches(&self, key: &str, value: &str) -> bool {
89 self.get(key).is_some_and(|v| v == value)
90 }
91
92 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
121fn 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
137fn 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#[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}