clnrm_core/validation/
count_validator.rs1use crate::error::{CleanroomError, Result};
7use crate::validation::span_validator::SpanData;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct CountBound {
14 #[serde(skip_serializing_if = "Option::is_none")]
16 pub gte: Option<usize>,
17 #[serde(skip_serializing_if = "Option::is_none")]
19 pub lte: Option<usize>,
20 #[serde(skip_serializing_if = "Option::is_none")]
22 pub eq: Option<usize>,
23}
24
25impl CountBound {
26 pub fn gte(value: usize) -> Self {
28 Self {
29 gte: Some(value),
30 lte: None,
31 eq: None,
32 }
33 }
34
35 pub fn lte(value: usize) -> Self {
37 Self {
38 gte: None,
39 lte: Some(value),
40 eq: None,
41 }
42 }
43
44 pub fn eq(value: usize) -> Self {
46 Self {
47 gte: None,
48 lte: None,
49 eq: Some(value),
50 }
51 }
52
53 pub fn range(min: usize, max: usize) -> Result<Self> {
55 if min > max {
56 return Err(CleanroomError::validation_error(format!(
57 "Invalid range: min ({}) > max ({})",
58 min, max
59 )));
60 }
61 Ok(Self {
62 gte: Some(min),
63 lte: Some(max),
64 eq: None,
65 })
66 }
67
68 pub fn validate(&self, actual: usize, context: &str) -> Result<()> {
70 if let Some(expected) = self.eq {
72 if actual != expected {
73 return Err(CleanroomError::validation_error(format!(
74 "{}: expected exactly {} items, found {}",
75 context, expected, actual
76 )));
77 }
78 return Ok(());
79 }
80
81 if let Some(min) = self.gte {
83 if actual < min {
84 return Err(CleanroomError::validation_error(format!(
85 "{}: expected at least {} items, found {}",
86 context, min, actual
87 )));
88 }
89 }
90
91 if let Some(max) = self.lte {
93 if actual > max {
94 return Err(CleanroomError::validation_error(format!(
95 "{}: expected at most {} items, found {}",
96 context, max, actual
97 )));
98 }
99 }
100
101 Ok(())
102 }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, Default)]
107pub struct CountExpectation {
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub spans_total: Option<CountBound>,
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub events_total: Option<CountBound>,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub errors_total: Option<CountBound>,
117 #[serde(skip_serializing_if = "Option::is_none")]
119 pub by_name: Option<HashMap<String, CountBound>>,
120}
121
122impl CountExpectation {
123 pub fn new() -> Self {
125 Self::default()
126 }
127
128 pub fn with_spans_total(mut self, bound: CountBound) -> Self {
130 self.spans_total = Some(bound);
131 self
132 }
133
134 pub fn with_events_total(mut self, bound: CountBound) -> Self {
136 self.events_total = Some(bound);
137 self
138 }
139
140 pub fn with_errors_total(mut self, bound: CountBound) -> Self {
142 self.errors_total = Some(bound);
143 self
144 }
145
146 pub fn with_name_count(mut self, name: String, bound: CountBound) -> Self {
148 self.by_name
149 .get_or_insert_with(HashMap::new)
150 .insert(name, bound);
151 self
152 }
153
154 pub fn validate(&self, spans: &[SpanData]) -> Result<()> {
166 if let Some(bound) = &self.spans_total {
168 bound.validate(spans.len(), "Total span count")?;
169 }
170
171 if let Some(bound) = &self.events_total {
173 let total_events = Self::count_total_events(spans);
174 bound.validate(total_events, "Total event count")?;
175 }
176
177 if let Some(bound) = &self.errors_total {
179 let total_errors = Self::count_error_spans(spans);
180 bound.validate(total_errors, "Total error count")?;
181 }
182
183 if let Some(by_name) = &self.by_name {
185 for (name, bound) in by_name {
186 let count = Self::count_spans_by_name(spans, name);
187 bound.validate(count, &format!("Count for span name '{}'", name))?;
188 }
189 }
190
191 Ok(())
192 }
193
194 fn count_total_events(spans: &[SpanData]) -> usize {
196 spans
197 .iter()
198 .map(|span| {
199 span.attributes
201 .get("event.count")
202 .and_then(|v| v.as_u64())
203 .unwrap_or(0) as usize
204 })
205 .sum()
206 }
207
208 fn count_error_spans(spans: &[SpanData]) -> usize {
210 spans
211 .iter()
212 .filter(|span| {
213 span.attributes
216 .get("otel.status_code")
217 .and_then(|v| v.as_str())
218 .map(|s| s == "ERROR")
219 .unwrap_or(false)
220 || span
222 .attributes
223 .get("error")
224 .and_then(|v| v.as_bool())
225 .unwrap_or(false)
226 })
227 .count()
228 }
229
230 fn count_spans_by_name(spans: &[SpanData], name: &str) -> usize {
232 spans.iter().filter(|span| span.name == name).count()
233 }
234}