sc_observability_types/
validation.rs1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6use crate::{ErrorCode, constants, error_codes};
7
8#[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 #[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 #[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 #[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 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 #[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}