Skip to main content

provide_telemetry/
schema.rs

1// SPDX-FileCopyrightText: Copyright (C) 2026 provide.io llc
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-Comment: Part of provide-telemetry.
4//
5
6use regex::Regex;
7use std::sync::{
8    atomic::{AtomicBool, Ordering},
9    OnceLock,
10};
11
12use crate::errors::EventSchemaError;
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct Event {
16    pub event: String,
17    pub domain: String,
18    pub action: String,
19    pub resource: Option<String>,
20    pub status: String,
21}
22
23static STRICT_SCHEMA: AtomicBool = AtomicBool::new(false);
24
25/// Enable or disable strict segment-format validation for `event()`. Thread-safe.
26pub fn set_strict_schema(enabled: bool) {
27    STRICT_SCHEMA.store(enabled, Ordering::Relaxed);
28}
29
30/// Return the current strict-schema flag value.
31pub fn get_strict_schema() -> bool {
32    STRICT_SCHEMA.load(Ordering::Relaxed)
33}
34
35/// Reset strict-schema flag to the default (false) for test isolation.
36pub fn _reset_schema_for_tests() {
37    STRICT_SCHEMA.store(false, Ordering::Relaxed);
38}
39
40fn segment_re() -> &'static Regex {
41    static SEGMENT_RE: OnceLock<Regex> = OnceLock::new();
42    SEGMENT_RE.get_or_init(|| Regex::new(r"^[a-z][a-z0-9_]*$").expect("valid regex"))
43}
44
45pub fn event(segments: &[&str]) -> Result<Event, EventSchemaError> {
46    if !(3..=4).contains(&segments.len()) {
47        return Err(EventSchemaError::new(format!(
48            "event() requires 3 or 4 segments (DA[R]S), got {}",
49            segments.len()
50        )));
51    }
52
53    if get_strict_schema() {
54        for (idx, segment) in segments.iter().enumerate() {
55            if !segment_re().is_match(segment) {
56                return Err(EventSchemaError::new(format!(
57                    "invalid event segment: segment[{idx}]={segment}"
58                )));
59            }
60        }
61    }
62
63    let event = segments.join(".");
64    Ok(Event {
65        event,
66        domain: segments[0].to_string(),
67        action: segments[1].to_string(),
68        resource: if segments.len() == 4 {
69            Some(segments[2].to_string())
70        } else {
71            None
72        },
73        status: segments[segments.len() - 1].to_string(),
74    })
75}
76
77pub fn event_name(segments: &[&str]) -> Result<String, EventSchemaError> {
78    let strict = get_strict_schema();
79    if strict {
80        if !(3..=5).contains(&segments.len()) {
81            return Err(EventSchemaError::new(format!(
82                "expected 3-5 segments, got {}",
83                segments.len()
84            )));
85        }
86        for (idx, segment) in segments.iter().enumerate() {
87            if !segment_re().is_match(segment) {
88                return Err(EventSchemaError::new(format!(
89                    "invalid event segment: segment[{idx}]={segment}"
90                )));
91            }
92        }
93    } else if segments.is_empty() {
94        return Err(EventSchemaError::new(
95            "event_name requires at least 1 segment",
96        ));
97    }
98    Ok(segments.join("."))
99}
100
101pub fn validate_required_keys(
102    data: &std::collections::BTreeMap<String, serde_json::Value>,
103    required_keys: &[String],
104) -> Result<(), EventSchemaError> {
105    let mut missing: Vec<&str> = required_keys
106        .iter()
107        .map(String::as_str)
108        .filter(|key| !data.contains_key(*key))
109        .collect();
110    if missing.is_empty() {
111        return Ok(());
112    }
113    missing.sort_unstable();
114    Err(EventSchemaError::new(format!(
115        "missing required keys: {}",
116        missing.join(", ")
117    )))
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::testing::acquire_test_state_lock;
124
125    #[test]
126    fn schema_test_event_name_returns_exact_joined_value() {
127        let _guard = acquire_test_state_lock();
128        set_strict_schema(false);
129        assert_eq!(
130            event_name(&["auth", "login", "ok"]).expect("name should build"),
131            "auth.login.ok"
132        );
133        set_strict_schema(true);
134        assert_eq!(
135            event_name(&["a", "b", "c", "d", "e"]).expect("strict name should build"),
136            "a.b.c.d.e"
137        );
138        set_strict_schema(false);
139    }
140
141    #[test]
142    fn schema_test_event_strict_gates_segment_format_validation() {
143        let _guard = acquire_test_state_lock();
144        // Non-strict: invalid segment format is accepted by event()
145        set_strict_schema(false);
146        let ev = event(&["not-valid", "b", "c"]).expect("non-strict should accept invalid segment");
147        assert_eq!(ev.event, "not-valid.b.c");
148
149        // Strict: invalid segment format is rejected
150        set_strict_schema(true);
151        let err =
152            event(&["not-valid", "b", "c"]).expect_err("strict should reject invalid segment");
153        assert!(
154            err.message.contains("invalid event segment"),
155            "unexpected error: {}",
156            err.message
157        );
158        set_strict_schema(false);
159    }
160
161    #[test]
162    fn schema_test_event_name_validates_empty_and_invalid_strict_inputs() {
163        let _guard = acquire_test_state_lock();
164        set_strict_schema(false);
165        let err = event_name(&[]).expect_err("empty non-strict name should fail");
166        assert_eq!(err.message, "event_name requires at least 1 segment");
167
168        set_strict_schema(true);
169        let err = event_name(&["a", "b"]).expect_err("strict arity should fail");
170        assert_eq!(err.message, "expected 3-5 segments, got 2");
171
172        let err = event_name(&["valid", "not-valid", "ok"]).expect_err("strict syntax should fail");
173        assert_eq!(err.message, "invalid event segment: segment[1]=not-valid");
174        set_strict_schema(false);
175    }
176
177    #[test]
178    fn schema_test_validate_required_keys_matches_python_contract() {
179        let mut data = std::collections::BTreeMap::new();
180        data.insert(
181            "domain".to_string(),
182            serde_json::Value::String("auth".to_string()),
183        );
184        validate_required_keys(&data, &["domain".to_string()]).expect("key should exist");
185
186        let err = validate_required_keys(&data, &["domain".to_string(), "action".to_string()])
187            .expect_err("missing key should fail");
188        assert_eq!(err.message, "missing required keys: action");
189    }
190}