clnrm_core/validation/
count_validator.rs

1//! Count validator for OTEL cardinality validation
2//!
3//! Validates that span counts match expected bounds (gte, lte, eq) for total counts,
4//! error counts, event counts, and per-name counts.
5
6use crate::error::{CleanroomError, Result};
7use crate::validation::span_validator::SpanData;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Count bound specification supporting gte/lte/eq constraints
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct CountBound {
14    /// Greater than or equal to (minimum count)
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub gte: Option<usize>,
17    /// Less than or equal to (maximum count)
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub lte: Option<usize>,
20    /// Exactly equal to (exact count)
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub eq: Option<usize>,
23}
24
25impl CountBound {
26    /// Create a new CountBound with only gte constraint
27    pub fn gte(value: usize) -> Self {
28        Self {
29            gte: Some(value),
30            lte: None,
31            eq: None,
32        }
33    }
34
35    /// Create a new CountBound with only lte constraint
36    pub fn lte(value: usize) -> Self {
37        Self {
38            gte: None,
39            lte: Some(value),
40            eq: None,
41        }
42    }
43
44    /// Create a new CountBound with only eq constraint
45    pub fn eq(value: usize) -> Self {
46        Self {
47            gte: None,
48            lte: None,
49            eq: Some(value),
50        }
51    }
52
53    /// Create a new CountBound with range (gte and lte)
54    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    /// Validate that a count satisfies this bound
69    pub fn validate(&self, actual: usize, context: &str) -> Result<()> {
70        // Check eq first (most specific)
71        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        // Check gte
82        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        // Check lte
92        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/// Count expectation for span cardinality validation
106#[derive(Debug, Clone, Serialize, Deserialize, Default)]
107pub struct CountExpectation {
108    /// Total span count bounds
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub spans_total: Option<CountBound>,
111    /// Total event count bounds (events across all spans)
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub events_total: Option<CountBound>,
114    /// Total error count bounds (spans with error status)
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub errors_total: Option<CountBound>,
117    /// Per-name count bounds
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub by_name: Option<HashMap<String, CountBound>>,
120}
121
122impl CountExpectation {
123    /// Create a new empty CountExpectation
124    pub fn new() -> Self {
125        Self::default()
126    }
127
128    /// Set total spans count bound
129    pub fn with_spans_total(mut self, bound: CountBound) -> Self {
130        self.spans_total = Some(bound);
131        self
132    }
133
134    /// Set total events count bound
135    pub fn with_events_total(mut self, bound: CountBound) -> Self {
136        self.events_total = Some(bound);
137        self
138    }
139
140    /// Set total errors count bound
141    pub fn with_errors_total(mut self, bound: CountBound) -> Self {
142        self.errors_total = Some(bound);
143        self
144    }
145
146    /// Add a per-name count bound
147    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    /// Validate all count expectations against actual spans
155    ///
156    /// # Arguments
157    /// * `spans` - Slice of SpanData to validate against
158    ///
159    /// # Returns
160    /// * `Result<()>` - Ok if all counts match expectations, Err with detailed message otherwise
161    ///
162    /// # Errors
163    /// * Count bounds not satisfied
164    /// * Error count validation failures
165    pub fn validate(&self, spans: &[SpanData]) -> Result<()> {
166        // Validate total span count
167        if let Some(bound) = &self.spans_total {
168            bound.validate(spans.len(), "Total span count")?;
169        }
170
171        // Validate total events count
172        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        // Validate total errors count
178        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        // Validate per-name counts
184        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    /// Count total events across all spans
195    fn count_total_events(spans: &[SpanData]) -> usize {
196        spans
197            .iter()
198            .map(|span| {
199                // SAFE: unwrap_or with safe default (0) - spans without event.count are treated as having 0 events
200                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    /// Count spans with error status
209    fn count_error_spans(spans: &[SpanData]) -> usize {
210        spans
211            .iter()
212            .filter(|span| {
213                // Check for error status in attributes
214                // SAFE: unwrap_or with safe default (false) - missing status_code means no error
215                span.attributes
216                    .get("otel.status_code")
217                    .and_then(|v| v.as_str())
218                    .map(|s| s == "ERROR")
219                    .unwrap_or(false)
220                    // SAFE: unwrap_or with safe default (false) - missing error attribute means no error
221                    || span
222                        .attributes
223                        .get("error")
224                        .and_then(|v| v.as_bool())
225                        .unwrap_or(false)
226            })
227            .count()
228    }
229
230    /// Count spans with specific name
231    fn count_spans_by_name(spans: &[SpanData], name: &str) -> usize {
232        spans.iter().filter(|span| span.name == name).count()
233    }
234}