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}