Skip to main content

sc_observability_types/
validation.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6use crate::{ErrorCode, constants, error_codes};
7
8/// Validation error returned when a public value type rejects an input.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
10#[error("{message}")]
11pub struct ValueValidationError {
12    code: ErrorCode,
13    message: String,
14}
15
16impl ValueValidationError {
17    /// Creates a validation error using the default shared validation code.
18    #[must_use]
19    pub fn new(message: impl Into<String>) -> Self {
20        Self {
21            code: error_codes::VALUE_VALIDATION_FAILED,
22            message: message.into(),
23        }
24    }
25
26    /// Creates a validation error with an explicit stable error code.
27    #[must_use]
28    pub fn with_code(code: ErrorCode, message: impl Into<String>) -> Self {
29        Self {
30            code,
31            message: message.into(),
32        }
33    }
34
35    /// Returns the stable error code associated with the validation failure.
36    #[must_use]
37    pub fn code(&self) -> &ErrorCode {
38        &self.code
39    }
40}
41
42macro_rules! validated_name_type {
43    ($name:ident, $doc:literal, $validator:expr) => {
44        #[doc = $doc]
45        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
46        pub struct $name(String);
47
48        impl $name {
49            /// Creates a validated value from caller-provided string data.
50            ///
51            /// # Errors
52            ///
53            /// Returns [`ValueValidationError`] when the supplied string does
54            /// not satisfy the documented validation rules for this type.
55            pub fn new(value: impl Into<String>) -> Result<Self, ValueValidationError> {
56                let value = value.into();
57                $validator(&value)?;
58                Ok(Self(value))
59            }
60
61            /// Returns the underlying validated string value.
62            #[must_use]
63            pub fn as_str(&self) -> &str {
64                &self.0
65            }
66        }
67
68        impl fmt::Display for $name {
69            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70                f.write_str(&self.0)
71            }
72        }
73
74        impl AsRef<str> for $name {
75            fn as_ref(&self) -> &str {
76                self.as_str()
77            }
78        }
79
80        impl TryFrom<String> for $name {
81            type Error = ValueValidationError;
82
83            fn try_from(value: String) -> Result<Self, Self::Error> {
84                Self::new(value)
85            }
86        }
87    };
88}
89
90pub(crate) fn validate_identifier(value: &str) -> Result<(), ValueValidationError> {
91    if value.is_empty() {
92        return Err(ValueValidationError::new("identifier must not be empty"));
93    }
94    if value
95        .chars()
96        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
97    {
98        Ok(())
99    } else {
100        Err(ValueValidationError::new(
101            "identifier must match [A-Za-z0-9._-]+",
102        ))
103    }
104}
105
106pub(crate) fn validate_env_prefix(value: &str) -> Result<(), ValueValidationError> {
107    if value.is_empty() {
108        return Err(ValueValidationError::new("env prefix must not be empty"));
109    }
110    if value.ends_with(constants::DEFAULT_ENV_PREFIX_SEPARATOR) {
111        return Err(ValueValidationError::new(
112            "env prefix must not end with underscore",
113        ));
114    }
115    if value
116        .chars()
117        .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
118    {
119        Ok(())
120    } else {
121        Err(ValueValidationError::new(
122            "env prefix must match [A-Z0-9_]+",
123        ))
124    }
125}
126
127pub(crate) fn validate_metric_name(value: &str) -> Result<(), ValueValidationError> {
128    if value.is_empty() {
129        return Err(ValueValidationError::new("metric name must not be empty"));
130    }
131    if value
132        .chars()
133        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | '/'))
134    {
135        Ok(())
136    } else {
137        Err(ValueValidationError::new(
138            "metric name must match [A-Za-z0-9._\\-/]+",
139        ))
140    }
141}
142
143pub(crate) fn validate_metric_unit(value: &str) -> Result<(), ValueValidationError> {
144    if value.is_empty() {
145        return Err(ValueValidationError::new("metric unit must not be empty"));
146    }
147    if value
148        .chars()
149        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | '/' | '%'))
150    {
151        Ok(())
152    } else {
153        Err(ValueValidationError::new(
154            "metric unit must match [A-Za-z0-9._\\-/%]+",
155        ))
156    }
157}
158
159validated_name_type!(
160    ToolName,
161    "Validated tool identity used for top-level configuration.",
162    validate_identifier
163);
164validated_name_type!(
165    EnvPrefix,
166    "Validated environment prefix used for config loading namespaces.",
167    validate_env_prefix
168);
169validated_name_type!(
170    ServiceName,
171    "Validated service name carried in logs and telemetry.",
172    validate_identifier
173);
174validated_name_type!(
175    TargetCategory,
176    "Validated stable target category for log events.",
177    validate_identifier
178);
179validated_name_type!(
180    ActionName,
181    "Validated stable action name for log and span events.",
182    validate_identifier
183);
184validated_name_type!(
185    MetricName,
186    "Validated metric identity using [A-Za-z0-9._\\-/]+.",
187    validate_metric_name
188);
189validated_name_type!(
190    MetricUnit,
191    "Validated metric unit using [A-Za-z0-9._\\-/%]+.",
192    validate_metric_unit
193);
194validated_name_type!(
195    StateName,
196    "Validated stable state name for state-transition payloads.",
197    validate_identifier
198);
199validated_name_type!(
200    CorrelationId,
201    "Validated request/correlation identifier used for cross-record joins.",
202    validate_identifier
203);
204validated_name_type!(
205    OutcomeLabel,
206    "Validated stable outcome label for event results.",
207    validate_identifier
208);
209validated_name_type!(
210    SinkName,
211    "Validated stable name for a logging sink or telemetry exporter.",
212    validate_identifier
213);
214validated_name_type!(
215    SchemaVersion,
216    "Validated schema version label for shared envelopes and log records.",
217    validate_identifier
218);
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn validated_name_newtypes_accept_expected_values() {
226        assert_eq!(
227            ToolName::new("codex-cli")
228                .expect("valid tool name")
229                .as_str(),
230            "codex-cli"
231        );
232        assert_eq!(
233            ToolName::new("codex-cli")
234                .expect("valid tool name")
235                .to_string(),
236            "codex-cli"
237        );
238        assert_eq!(
239            EnvPrefix::new("SC_OBSERVABILITY")
240                .expect("valid env prefix")
241                .as_str(),
242            "SC_OBSERVABILITY"
243        );
244        assert_eq!(
245            ServiceName::new("service.core")
246                .expect("valid service name")
247                .as_str(),
248            "service.core"
249        );
250        assert_eq!(
251            TargetCategory::new("pipeline-ingest")
252                .expect("valid target category")
253                .as_str(),
254            "pipeline-ingest"
255        );
256        assert_eq!(
257            ActionName::new("observation.received")
258                .expect("valid action name")
259                .as_str(),
260            "observation.received"
261        );
262        assert_eq!(
263            MetricName::new("obs/events_total")
264                .expect("valid metric name")
265                .as_str(),
266            "obs/events_total"
267        );
268        assert_eq!(
269            MetricUnit::new("ms").expect("valid metric unit").as_str(),
270            "ms"
271        );
272        assert_eq!(
273            StateName::new("running")
274                .expect("valid state name")
275                .as_ref(),
276            "running"
277        );
278        assert_eq!(
279            CorrelationId::try_from("req-1".to_string())
280                .expect("valid correlation id")
281                .to_string(),
282            "req-1"
283        );
284        assert_eq!(
285            OutcomeLabel::new("success")
286                .expect("valid outcome label")
287                .as_str(),
288            "success"
289        );
290        assert_eq!(
291            SinkName::new("jsonl").expect("valid sink name").as_str(),
292            "jsonl"
293        );
294        assert_eq!(
295            SchemaVersion::new("v1")
296                .expect("valid schema version")
297                .as_str(),
298            "v1"
299        );
300    }
301
302    #[test]
303    fn validated_name_newtypes_reject_invalid_values() {
304        assert!(ToolName::new("").is_err());
305        assert!(EnvPrefix::new("sc_observability").is_err());
306        assert!(EnvPrefix::new("SC_OBSERVABILITY_").is_err());
307        assert!(ServiceName::new("service core").is_err());
308        assert!(TargetCategory::new("category/invalid").is_err());
309        assert!(ActionName::new("action invalid").is_err());
310        assert!(MetricName::new("metric name").is_err());
311        assert!(MetricUnit::new("metric unit").is_err());
312        assert!(StateName::new("state invalid").is_err());
313        assert!(CorrelationId::new("corr invalid").is_err());
314        assert!(OutcomeLabel::new("outcome invalid").is_err());
315        assert!(SinkName::new("sink invalid").is_err());
316        assert!(SchemaVersion::new("schema invalid").is_err());
317    }
318}