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}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use serde_json::json;
240
241    fn create_test_span(name: &str, is_error: bool) -> SpanData {
242        let mut attributes = HashMap::new();
243        if is_error {
244            attributes.insert("otel.status_code".to_string(), json!("ERROR"));
245        }
246
247        SpanData {
248            name: name.to_string(),
249            attributes,
250            trace_id: "test_trace".to_string(),
251            span_id: format!("span_{}", name),
252            parent_span_id: None,
253            start_time_unix_nano: Some(1000000),
254            end_time_unix_nano: Some(2000000),
255            kind: None,
256            events: None,
257            resource_attributes: HashMap::new(),
258        }
259    }
260
261    #[test]
262    fn test_count_bound_eq_valid() {
263        // Arrange
264        let bound = CountBound::eq(5);
265
266        // Act
267        let result = bound.validate(5, "Test count");
268
269        // Assert
270        assert!(result.is_ok());
271    }
272
273    #[test]
274    fn test_count_bound_eq_invalid() {
275        // Arrange
276        let bound = CountBound::eq(5);
277
278        // Act
279        let result = bound.validate(3, "Test count");
280
281        // Assert
282        assert!(result.is_err());
283        let err_msg = result.unwrap_err().to_string();
284        assert!(err_msg.contains("expected exactly 5"));
285        assert!(err_msg.contains("found 3"));
286    }
287
288    #[test]
289    fn test_count_bound_gte_valid() {
290        // Arrange
291        let bound = CountBound::gte(5);
292
293        // Act & Assert
294        assert!(bound.validate(5, "Test count").is_ok());
295        assert!(bound.validate(10, "Test count").is_ok());
296    }
297
298    #[test]
299    fn test_count_bound_gte_invalid() {
300        // Arrange
301        let bound = CountBound::gte(5);
302
303        // Act
304        let result = bound.validate(3, "Test count");
305
306        // Assert
307        assert!(result.is_err());
308        let err_msg = result.unwrap_err().to_string();
309        assert!(err_msg.contains("expected at least 5"));
310        assert!(err_msg.contains("found 3"));
311    }
312
313    #[test]
314    fn test_count_bound_lte_valid() {
315        // Arrange
316        let bound = CountBound::lte(5);
317
318        // Act & Assert
319        assert!(bound.validate(5, "Test count").is_ok());
320        assert!(bound.validate(3, "Test count").is_ok());
321        assert!(bound.validate(0, "Test count").is_ok());
322    }
323
324    #[test]
325    fn test_count_bound_lte_invalid() {
326        // Arrange
327        let bound = CountBound::lte(5);
328
329        // Act
330        let result = bound.validate(10, "Test count");
331
332        // Assert
333        assert!(result.is_err());
334        let err_msg = result.unwrap_err().to_string();
335        assert!(err_msg.contains("expected at most 5"));
336        assert!(err_msg.contains("found 10"));
337    }
338
339    #[test]
340    fn test_count_bound_range_valid() {
341        // Arrange
342        let bound = CountBound::range(5, 10).unwrap();
343
344        // Act & Assert
345        assert!(bound.validate(5, "Test count").is_ok());
346        assert!(bound.validate(7, "Test count").is_ok());
347        assert!(bound.validate(10, "Test count").is_ok());
348    }
349
350    #[test]
351    fn test_count_bound_range_invalid_below() {
352        // Arrange
353        let bound = CountBound::range(5, 10).unwrap();
354
355        // Act
356        let result = bound.validate(3, "Test count");
357
358        // Assert
359        assert!(result.is_err());
360        let err_msg = result.unwrap_err().to_string();
361        assert!(err_msg.contains("expected at least 5"));
362    }
363
364    #[test]
365    fn test_count_bound_range_invalid_above() {
366        // Arrange
367        let bound = CountBound::range(5, 10).unwrap();
368
369        // Act
370        let result = bound.validate(15, "Test count");
371
372        // Assert
373        assert!(result.is_err());
374        let err_msg = result.unwrap_err().to_string();
375        assert!(err_msg.contains("expected at most 10"));
376    }
377
378    #[test]
379    fn test_count_bound_range_invalid_creation() {
380        // Arrange & Act
381        let result = CountBound::range(10, 5);
382
383        // Assert
384        assert!(result.is_err());
385        let err_msg = result.unwrap_err().to_string();
386        assert!(err_msg.contains("Invalid range"));
387    }
388
389    #[test]
390    fn test_count_bound_eq_takes_precedence() {
391        // Arrange - eq should take precedence over gte/lte
392        let bound = CountBound {
393            gte: Some(1),
394            lte: Some(10),
395            eq: Some(5),
396        };
397
398        // Act & Assert
399        assert!(bound.validate(5, "Test count").is_ok());
400        assert!(bound.validate(7, "Test count").is_err()); // Would be valid for range, but eq=5
401    }
402
403    #[test]
404    fn test_count_expectation_spans_total() {
405        // Arrange
406        let spans = vec![
407            create_test_span("span1", false),
408            create_test_span("span2", false),
409            create_test_span("span3", false),
410        ];
411        let expectation = CountExpectation::new().with_spans_total(CountBound::eq(3));
412
413        // Act
414        let result = expectation.validate(&spans);
415
416        // Assert
417        assert!(result.is_ok());
418    }
419
420    #[test]
421    fn test_count_expectation_spans_total_invalid() {
422        // Arrange
423        let spans = vec![
424            create_test_span("span1", false),
425            create_test_span("span2", false),
426        ];
427        let expectation = CountExpectation::new().with_spans_total(CountBound::eq(3));
428
429        // Act
430        let result = expectation.validate(&spans);
431
432        // Assert
433        assert!(result.is_err());
434        let err_msg = result.unwrap_err().to_string();
435        assert!(err_msg.contains("Total span count"));
436    }
437
438    #[test]
439    fn test_count_expectation_errors_total() {
440        // Arrange
441        let spans = vec![
442            create_test_span("span1", false),
443            create_test_span("span2", true),
444            create_test_span("span3", true),
445        ];
446        let expectation = CountExpectation::new().with_errors_total(CountBound::eq(2));
447
448        // Act
449        let result = expectation.validate(&spans);
450
451        // Assert
452        assert!(result.is_ok());
453    }
454
455    #[test]
456    fn test_count_expectation_errors_total_zero() {
457        // Arrange
458        let spans = vec![
459            create_test_span("span1", false),
460            create_test_span("span2", false),
461        ];
462        let expectation = CountExpectation::new().with_errors_total(CountBound::eq(0));
463
464        // Act
465        let result = expectation.validate(&spans);
466
467        // Assert
468        assert!(result.is_ok());
469    }
470
471    #[test]
472    fn test_count_expectation_errors_total_invalid() {
473        // Arrange
474        let spans = vec![
475            create_test_span("span1", false),
476            create_test_span("span2", true),
477        ];
478        let expectation = CountExpectation::new().with_errors_total(CountBound::eq(0));
479
480        // Act
481        let result = expectation.validate(&spans);
482
483        // Assert
484        assert!(result.is_err());
485        let err_msg = result.unwrap_err().to_string();
486        assert!(err_msg.contains("Total error count"));
487        assert!(err_msg.contains("expected exactly 0"));
488        assert!(err_msg.contains("found 1"));
489    }
490
491    #[test]
492    fn test_count_expectation_by_name() {
493        // Arrange
494        let spans = vec![
495            create_test_span("clnrm.run", false),
496            create_test_span("clnrm.test", false),
497            create_test_span("clnrm.test", false),
498            create_test_span("clnrm.test", false),
499        ];
500        let expectation = CountExpectation::new()
501            .with_name_count("clnrm.run".to_string(), CountBound::eq(1))
502            .with_name_count("clnrm.test".to_string(), CountBound::eq(3));
503
504        // Act
505        let result = expectation.validate(&spans);
506
507        // Assert
508        assert!(result.is_ok());
509    }
510
511    #[test]
512    fn test_count_expectation_by_name_invalid() {
513        // Arrange
514        let spans = vec![
515            create_test_span("clnrm.run", false),
516            create_test_span("clnrm.test", false),
517        ];
518        let expectation =
519            CountExpectation::new().with_name_count("clnrm.test".to_string(), CountBound::eq(3));
520
521        // Act
522        let result = expectation.validate(&spans);
523
524        // Assert
525        assert!(result.is_err());
526        let err_msg = result.unwrap_err().to_string();
527        assert!(err_msg.contains("Count for span name 'clnrm.test'"));
528        assert!(err_msg.contains("expected exactly 3"));
529        assert!(err_msg.contains("found 1"));
530    }
531
532    #[test]
533    fn test_count_expectation_by_name_missing() {
534        // Arrange
535        let spans = vec![create_test_span("clnrm.run", false)];
536        let expectation = CountExpectation::new()
537            .with_name_count("clnrm.missing".to_string(), CountBound::gte(1));
538
539        // Act
540        let result = expectation.validate(&spans);
541
542        // Assert
543        assert!(result.is_err());
544        let err_msg = result.unwrap_err().to_string();
545        assert!(err_msg.contains("Count for span name 'clnrm.missing'"));
546        assert!(err_msg.contains("expected at least 1"));
547        assert!(err_msg.contains("found 0"));
548    }
549
550    #[test]
551    fn test_count_expectation_multiple_constraints() {
552        // Arrange
553        let spans = vec![
554            create_test_span("clnrm.run", false),
555            create_test_span("clnrm.test", false),
556            create_test_span("clnrm.test", false),
557        ];
558        let expectation = CountExpectation::new()
559            .with_spans_total(CountBound::range(2, 5).unwrap())
560            .with_errors_total(CountBound::eq(0))
561            .with_name_count("clnrm.run".to_string(), CountBound::gte(1))
562            .with_name_count("clnrm.test".to_string(), CountBound::lte(3));
563
564        // Act
565        let result = expectation.validate(&spans);
566
567        // Assert
568        assert!(result.is_ok());
569    }
570
571    #[test]
572    fn test_count_expectation_events_total() {
573        // Arrange
574        let mut span1 = create_test_span("span1", false);
575        span1.attributes.insert("event.count".to_string(), json!(2));
576        let mut span2 = create_test_span("span2", false);
577        span2.attributes.insert("event.count".to_string(), json!(3));
578        let spans = vec![span1, span2];
579        let expectation = CountExpectation::new().with_events_total(CountBound::eq(5));
580
581        // Act
582        let result = expectation.validate(&spans);
583
584        // Assert
585        assert!(result.is_ok());
586    }
587
588    #[test]
589    fn test_count_expectation_empty_spans() {
590        // Arrange
591        let spans: Vec<SpanData> = vec![];
592        let expectation = CountExpectation::new()
593            .with_spans_total(CountBound::eq(0))
594            .with_errors_total(CountBound::eq(0));
595
596        // Act
597        let result = expectation.validate(&spans);
598
599        // Assert
600        assert!(result.is_ok());
601    }
602
603    #[test]
604    fn test_count_expectation_no_constraints() {
605        // Arrange
606        let spans = vec![create_test_span("span1", false)];
607        let expectation = CountExpectation::new();
608
609        // Act
610        let result = expectation.validate(&spans);
611
612        // Assert - should pass with no constraints
613        assert!(result.is_ok());
614    }
615
616    #[test]
617    fn test_serde_count_bound() {
618        // Arrange
619        let bound = CountBound::range(5, 10).unwrap();
620
621        // Act
622        let json = serde_json::to_string(&bound).unwrap();
623        let deserialized: CountBound = serde_json::from_str(&json).unwrap();
624
625        // Assert
626        assert_eq!(bound, deserialized);
627    }
628
629    #[test]
630    fn test_serde_count_expectation() {
631        // Arrange
632        let expectation = CountExpectation::new()
633            .with_spans_total(CountBound::eq(5))
634            .with_name_count("test".to_string(), CountBound::gte(1));
635
636        // Act
637        let json = serde_json::to_string(&expectation).unwrap();
638        let deserialized: CountExpectation = serde_json::from_str(&json).unwrap();
639
640        // Assert
641        assert!(deserialized.spans_total.is_some());
642        assert!(deserialized.by_name.is_some());
643    }
644
645    #[test]
646    fn test_error_detection_via_error_attribute() {
647        // Arrange
648        let mut span = create_test_span("span1", false);
649        span.attributes.insert("error".to_string(), json!(true));
650        let spans = vec![span];
651        let expectation = CountExpectation::new().with_errors_total(CountBound::eq(1));
652
653        // Act
654        let result = expectation.validate(&spans);
655
656        // Assert
657        assert!(result.is_ok());
658    }
659}