clnrm_core/validation/
status_validator.rs

1//! Span status code validation with glob pattern support
2//!
3//! Validates OTEL span status codes (OK/ERROR/UNSET) with support for
4//! glob patterns to match span names flexibly.
5
6use crate::error::{CleanroomError, Result};
7use crate::validation::span_validator::SpanData;
8use glob::Pattern;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Status code enum matching OTEL span status
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "UPPERCASE")]
15pub enum StatusCode {
16    /// Status was not set (default)
17    Unset,
18    /// Operation completed successfully
19    Ok,
20    /// Operation encountered an error
21    Error,
22}
23
24impl StatusCode {
25    /// Parse a status code from string
26    ///
27    /// Note: This is a custom parser that returns `Result<Self, CleanroomError>`
28    /// rather than implementing `std::str::FromStr` which requires `Result<Self, Self::Err>`.
29    ///
30    /// # Arguments
31    /// * `s` - String representation (case-insensitive: "UNSET", "OK", "ERROR")
32    ///
33    /// # Returns
34    /// * `Result<Self>` - Parsed status code or validation error
35    ///
36    /// # Errors
37    /// * Returns `CleanroomError::validation_error` if the string is not a valid status code
38    #[allow(clippy::should_implement_trait)]
39    pub fn parse(s: &str) -> Result<Self> {
40        match s.to_uppercase().as_str() {
41            "UNSET" => Ok(StatusCode::Unset),
42            "OK" => Ok(StatusCode::Ok),
43            "ERROR" => Ok(StatusCode::Error),
44            _ => Err(CleanroomError::validation_error(format!(
45                "Invalid status code: '{}'. Must be UNSET, OK, or ERROR",
46                s
47            ))),
48        }
49    }
50
51    /// Get string representation
52    pub fn as_str(&self) -> &'static str {
53        match self {
54            StatusCode::Unset => "UNSET",
55            StatusCode::Ok => "OK",
56            StatusCode::Error => "ERROR",
57        }
58    }
59}
60
61/// Status expectations with glob pattern support
62///
63/// Allows validating span status codes either globally (all spans) or
64/// by name pattern using glob patterns (*, ?, []).
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct StatusExpectation {
67    /// Expected status for all spans
68    pub all: Option<StatusCode>,
69    /// Expected status by name pattern (glob -> status)
70    pub by_name: HashMap<String, StatusCode>,
71}
72
73impl StatusExpectation {
74    /// Create a new empty status expectation
75    pub fn new() -> Self {
76        Self {
77            all: None,
78            by_name: HashMap::new(),
79        }
80    }
81
82    /// Set expected status for all spans
83    ///
84    /// # Arguments
85    /// * `status` - Status code that all spans must have
86    ///
87    /// # Returns
88    /// * `Self` - Builder pattern for chaining
89    pub fn with_all(mut self, status: StatusCode) -> Self {
90        self.all = Some(status);
91        self
92    }
93
94    /// Add expected status for spans matching a name pattern
95    ///
96    /// # Arguments
97    /// * `pattern` - Glob pattern (e.g., "clnrm.*", "test_*")
98    /// * `status` - Expected status code for matching spans
99    ///
100    /// # Returns
101    /// * `Self` - Builder pattern for chaining
102    pub fn with_name_pattern(mut self, pattern: String, status: StatusCode) -> Self {
103        self.by_name.insert(pattern, status);
104        self
105    }
106
107    /// Validate status expectations against spans
108    ///
109    /// # Arguments
110    /// * `spans` - Slice of span data to validate
111    ///
112    /// # Returns
113    /// * `Result<()>` - Success or validation error
114    ///
115    /// # Errors
116    /// * Invalid glob pattern
117    /// * No spans match a pattern
118    /// * Span status doesn't match expectation
119    pub fn validate(&self, spans: &[SpanData]) -> Result<()> {
120        // Validate all spans if "all" is set
121        if let Some(expected_all) = self.all {
122            for span in spans {
123                let actual = self.get_span_status(span)?;
124                if actual != expected_all {
125                    return Err(CleanroomError::validation_error(format!(
126                        "Status validation failed: span '{}' has status {} but expected {}",
127                        span.name,
128                        actual.as_str(),
129                        expected_all.as_str()
130                    )));
131                }
132            }
133        }
134
135        // Validate by_name patterns
136        for (pattern, expected_status) in &self.by_name {
137            let glob_pattern = Pattern::new(pattern).map_err(|e| {
138                CleanroomError::validation_error(format!(
139                    "Invalid glob pattern '{}': {}",
140                    pattern, e
141                ))
142            })?;
143
144            // Find matching spans
145            let matching_spans: Vec<_> = spans
146                .iter()
147                .filter(|s| glob_pattern.matches(&s.name))
148                .collect();
149
150            if matching_spans.is_empty() {
151                return Err(CleanroomError::validation_error(format!(
152                    "Status validation failed: no spans match pattern '{}'",
153                    pattern
154                )));
155            }
156
157            // Validate each matching span
158            for span in matching_spans {
159                let actual = self.get_span_status(span)?;
160                if actual != *expected_status {
161                    return Err(CleanroomError::validation_error(format!(
162                        "Status validation failed: span '{}' matching pattern '{}' has status {} but expected {}",
163                        span.name, pattern, actual.as_str(), expected_status.as_str()
164                    )));
165                }
166            }
167        }
168
169        Ok(())
170    }
171
172    /// Extract span status from span data
173    ///
174    /// Checks multiple attribute keys for status code:
175    /// 1. "otel.status_code" (standard OTEL attribute)
176    /// 2. "status" (alternative attribute)
177    /// 3. Defaults to UNSET if no status attribute found
178    ///
179    /// # Arguments
180    /// * `span` - Span data to extract status from
181    ///
182    /// # Returns
183    /// * `Result<StatusCode>` - Extracted status code or error
184    ///
185    /// # Errors
186    /// * Invalid status code string
187    fn get_span_status(&self, span: &SpanData) -> Result<StatusCode> {
188        // Check otel.status_code attribute
189        if let Some(status_val) = span.attributes.get("otel.status_code") {
190            if let Some(status_str) = status_val.as_str() {
191                return StatusCode::parse(status_str);
192            }
193        }
194
195        // Check status attribute (alternative)
196        if let Some(status_val) = span.attributes.get("status") {
197            if let Some(status_str) = status_val.as_str() {
198                return StatusCode::parse(status_str);
199            }
200        }
201
202        // Default to UNSET if no status attribute
203        Ok(StatusCode::Unset)
204    }
205}
206
207impl Default for StatusExpectation {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use serde_json::json;
217
218    fn create_span_with_status(name: &str, status: &str) -> SpanData {
219        let mut attrs = HashMap::new();
220        attrs.insert("otel.status_code".to_string(), json!(status));
221
222        SpanData {
223            name: name.to_string(),
224            trace_id: "test_trace".to_string(),
225            span_id: format!("span_{}", name),
226            parent_span_id: None,
227            start_time_unix_nano: Some(1000),
228            end_time_unix_nano: Some(2000),
229            attributes: attrs,
230            kind: None,
231            events: None,
232            resource_attributes: HashMap::new(),
233        }
234    }
235
236    fn create_span_without_status(name: &str) -> SpanData {
237        SpanData {
238            name: name.to_string(),
239            trace_id: "test_trace".to_string(),
240            span_id: format!("span_{}", name),
241            parent_span_id: None,
242            start_time_unix_nano: Some(1000),
243            end_time_unix_nano: Some(2000),
244            attributes: HashMap::new(),
245            kind: None,
246            events: None,
247            resource_attributes: HashMap::new(),
248        }
249    }
250
251    #[test]
252    fn test_status_code_parse_valid() -> Result<()> {
253        // Arrange & Act
254        let unset = StatusCode::parse("UNSET")?;
255        let ok = StatusCode::parse("OK")?;
256        let error = StatusCode::parse("ERROR")?;
257
258        // Assert
259        assert_eq!(unset, StatusCode::Unset);
260        assert_eq!(ok, StatusCode::Ok);
261        assert_eq!(error, StatusCode::Error);
262        Ok(())
263    }
264
265    #[test]
266    fn test_status_code_parse_case_insensitive() -> Result<()> {
267        // Arrange & Act
268        let ok_lower = StatusCode::parse("ok")?;
269        let ok_mixed = StatusCode::parse("Ok")?;
270
271        // Assert
272        assert_eq!(ok_lower, StatusCode::Ok);
273        assert_eq!(ok_mixed, StatusCode::Ok);
274        Ok(())
275    }
276
277    #[test]
278    fn test_status_code_parse_invalid() {
279        // Arrange & Act
280        let result = StatusCode::parse("INVALID");
281
282        // Assert
283        assert!(result.is_err());
284        assert!(result
285            .unwrap_err()
286            .to_string()
287            .contains("Invalid status code"));
288    }
289
290    #[test]
291    fn test_all_status_ok() -> Result<()> {
292        // Arrange
293        let spans = vec![
294            create_span_with_status("span1", "OK"),
295            create_span_with_status("span2", "OK"),
296        ];
297
298        let expectation = StatusExpectation::new().with_all(StatusCode::Ok);
299
300        // Act
301        let result = expectation.validate(&spans);
302
303        // Assert
304        assert!(result.is_ok());
305        Ok(())
306    }
307
308    #[test]
309    fn test_all_status_fails() {
310        // Arrange
311        let spans = vec![
312            create_span_with_status("span1", "OK"),
313            create_span_with_status("span2", "ERROR"),
314        ];
315
316        let expectation = StatusExpectation::new().with_all(StatusCode::Ok);
317
318        // Act
319        let result = expectation.validate(&spans);
320
321        // Assert
322        assert!(result.is_err());
323        let err_msg = result.unwrap_err().to_string();
324        assert!(err_msg.contains("span2"));
325        assert!(err_msg.contains("ERROR"));
326        assert!(err_msg.contains("expected OK"));
327    }
328
329    #[test]
330    fn test_glob_pattern_match() -> Result<()> {
331        // Arrange
332        let spans = vec![
333            create_span_with_status("clnrm.test1", "OK"),
334            create_span_with_status("clnrm.test2", "OK"),
335            create_span_with_status("other", "ERROR"),
336        ];
337
338        let expectation =
339            StatusExpectation::new().with_name_pattern("clnrm.*".to_string(), StatusCode::Ok);
340
341        // Act
342        let result = expectation.validate(&spans);
343
344        // Assert
345        assert!(result.is_ok());
346        Ok(())
347    }
348
349    #[test]
350    fn test_glob_pattern_mismatch() {
351        // Arrange
352        let spans = vec![
353            create_span_with_status("clnrm.test1", "OK"),
354            create_span_with_status("clnrm.test2", "ERROR"),
355        ];
356
357        let expectation =
358            StatusExpectation::new().with_name_pattern("clnrm.*".to_string(), StatusCode::Ok);
359
360        // Act
361        let result = expectation.validate(&spans);
362
363        // Assert
364        assert!(result.is_err());
365        let err_msg = result.unwrap_err().to_string();
366        assert!(err_msg.contains("clnrm.test2"));
367        assert!(err_msg.contains("clnrm.*"));
368    }
369
370    #[test]
371    fn test_glob_pattern_no_matches() {
372        // Arrange
373        let spans = vec![
374            create_span_with_status("span1", "OK"),
375            create_span_with_status("span2", "OK"),
376        ];
377
378        let expectation =
379            StatusExpectation::new().with_name_pattern("clnrm.*".to_string(), StatusCode::Ok);
380
381        // Act
382        let result = expectation.validate(&spans);
383
384        // Assert
385        assert!(result.is_err());
386        let err_msg = result.unwrap_err().to_string();
387        assert!(err_msg.contains("no spans match pattern"));
388        assert!(err_msg.contains("clnrm.*"));
389    }
390
391    #[test]
392    fn test_invalid_glob_pattern() {
393        // Arrange
394        let spans = vec![create_span_with_status("span1", "OK")];
395
396        let expectation =
397            StatusExpectation::new().with_name_pattern("[invalid".to_string(), StatusCode::Ok);
398
399        // Act
400        let result = expectation.validate(&spans);
401
402        // Assert
403        assert!(result.is_err());
404        let err_msg = result.unwrap_err().to_string();
405        assert!(err_msg.contains("Invalid glob pattern"));
406    }
407
408    #[test]
409    fn test_multiple_patterns() -> Result<()> {
410        // Arrange
411        let spans = vec![
412            create_span_with_status("clnrm.run", "OK"),
413            create_span_with_status("clnrm.test", "OK"),
414            create_span_with_status("http.request", "ERROR"),
415        ];
416
417        let expectation = StatusExpectation::new()
418            .with_name_pattern("clnrm.*".to_string(), StatusCode::Ok)
419            .with_name_pattern("http.*".to_string(), StatusCode::Error);
420
421        // Act
422        let result = expectation.validate(&spans);
423
424        // Assert
425        assert!(result.is_ok());
426        Ok(())
427    }
428
429    #[test]
430    fn test_wildcard_patterns() -> Result<()> {
431        // Arrange
432        let spans = vec![
433            create_span_with_status("test_1", "OK"),
434            create_span_with_status("test_2", "OK"),
435            create_span_with_status("test_3", "OK"),
436        ];
437
438        let expectation =
439            StatusExpectation::new().with_name_pattern("test_?".to_string(), StatusCode::Ok);
440
441        // Act
442        let result = expectation.validate(&spans);
443
444        // Assert
445        assert!(result.is_ok());
446        Ok(())
447    }
448
449    #[test]
450    fn test_default_unset_status() -> Result<()> {
451        // Arrange
452        let spans = vec![create_span_without_status("span1")];
453
454        let expectation = StatusExpectation::new().with_all(StatusCode::Unset);
455
456        // Act
457        let result = expectation.validate(&spans);
458
459        // Assert
460        assert!(result.is_ok());
461        Ok(())
462    }
463
464    #[test]
465    fn test_alternative_status_attribute() -> Result<()> {
466        // Arrange
467        let mut attrs = HashMap::new();
468        attrs.insert("status".to_string(), json!("OK"));
469
470        let span = SpanData {
471            name: "span1".to_string(),
472            trace_id: "test_trace".to_string(),
473            span_id: "span1".to_string(),
474            parent_span_id: None,
475            start_time_unix_nano: Some(1000),
476            end_time_unix_nano: Some(2000),
477            attributes: attrs,
478            kind: None,
479            events: None,
480            resource_attributes: HashMap::new(),
481        };
482
483        let expectation = StatusExpectation::new().with_all(StatusCode::Ok);
484
485        // Act
486        let result = expectation.validate(&[span]);
487
488        // Assert
489        assert!(result.is_ok());
490        Ok(())
491    }
492
493    #[test]
494    fn test_combining_all_and_pattern() -> Result<()> {
495        // Arrange - "all" should validate all spans, and patterns should also validate
496        let spans = vec![
497            create_span_with_status("clnrm.test", "OK"),
498            create_span_with_status("other", "OK"),
499        ];
500
501        let expectation = StatusExpectation::new()
502            .with_all(StatusCode::Ok)
503            .with_name_pattern("clnrm.*".to_string(), StatusCode::Ok);
504
505        // Act
506        let result = expectation.validate(&spans);
507
508        // Assert
509        assert!(result.is_ok());
510        Ok(())
511    }
512
513    #[test]
514    fn test_status_code_as_str() {
515        // Arrange & Act & Assert
516        assert_eq!(StatusCode::Unset.as_str(), "UNSET");
517        assert_eq!(StatusCode::Ok.as_str(), "OK");
518        assert_eq!(StatusCode::Error.as_str(), "ERROR");
519    }
520}