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}