1use std::collections::{HashMap, hash_map::Entry};
2
3use thiserror::Error;
4
5use crate::Check;
6
7pub fn validate_batch(checks: &[Check<'_>], maximum: usize) -> Result<(), BatchError> {
18 if checks.len() > maximum {
19 return Err(BatchError::BatchTooLarge {
20 actual: checks.len(),
21 maximum,
22 });
23 }
24
25 let mut first_indices = HashMap::with_capacity(checks.len());
26 for (duplicate_index, check) in checks.iter().enumerate() {
27 match first_indices.entry(check.counter_key()) {
28 Entry::Vacant(entry) => {
29 entry.insert(duplicate_index);
30 }
31 Entry::Occupied(entry) => {
32 return Err(BatchError::DuplicateKey {
33 first_index: *entry.get(),
34 duplicate_index,
35 });
36 }
37 }
38 }
39
40 Ok(())
41}
42
43#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
45pub enum BatchError {
46 #[error("batch check at index {duplicate_index} duplicates the counter at index {first_index}")]
48 DuplicateKey {
49 first_index: usize,
51 duplicate_index: usize,
53 },
54 #[error("batch has {actual} checks but the configured maximum is {maximum}")]
56 BatchTooLarge {
57 actual: usize,
59 maximum: usize,
61 },
62}
63
64#[cfg(test)]
65mod tests {
66 use std::time::Duration;
67
68 use super::{BatchError, validate_batch};
69 use crate::{Check, FixedWindowPolicy, PolicyId, ScopeId, SubjectKey};
70
71 fn policy(id: &str) -> FixedWindowPolicy {
72 FixedWindowPolicy::new(
73 PolicyId::new(id).unwrap(),
74 ScopeId::new("client").unwrap(),
75 10,
76 Duration::from_secs(60),
77 )
78 .unwrap()
79 }
80
81 fn subject(byte: u8) -> SubjectKey {
82 SubjectKey::from_digest([byte; 32])
83 }
84
85 #[test]
86 fn accepts_empty_and_distinct_batches() {
87 let first_policy = policy("auth.alpha");
88 let second_policy = policy("auth.beta");
89 let checks = [
90 Check::new(&first_policy, subject(1)),
91 Check::new(&second_policy, subject(1)),
92 Check::new(&first_policy, subject(2)),
93 ];
94
95 assert_eq!(validate_batch(&[], 0), Ok(()));
96 assert_eq!(validate_batch(&checks, checks.len()), Ok(()));
97 }
98
99 #[test]
100 fn reports_the_first_duplicate_in_caller_order() {
101 let alpha = policy("auth.alpha");
102 let beta = policy("auth.beta");
103 let checks = [
104 Check::new(&beta, subject(1)),
105 Check::new(&beta, subject(1)),
106 Check::new(&alpha, subject(2)),
107 Check::new(&alpha, subject(2)),
108 ];
109
110 assert_eq!(
111 validate_batch(&checks, checks.len()),
112 Err(BatchError::DuplicateKey {
113 first_index: 0,
114 duplicate_index: 1,
115 })
116 );
117 }
118
119 #[test]
120 fn batch_size_error_takes_precedence_over_duplicate_detection() {
121 let policy = policy("auth.alpha");
122 let checks = [
123 Check::new(&policy, subject(1)),
124 Check::new(&policy, subject(1)),
125 ];
126
127 assert_eq!(
128 validate_batch(&checks, 1),
129 Err(BatchError::BatchTooLarge {
130 actual: 2,
131 maximum: 1,
132 })
133 );
134 }
135}