Skip to main content

aurum_core/provider_platform/
id.rs

1//! Validated provider identity (JOE-1933).
2
3use crate::error::{Result, UserError};
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use std::str::FromStr;
7
8/// Maximum length of a provider id (bytes after normalization).
9pub const MAX_PROVIDER_ID_LEN: usize = 32;
10
11/// Stable, validated provider identity used at core boundaries.
12///
13/// Canonical form is lowercase ASCII `[a-z][a-z0-9_]*` with no leading digit,
14/// no control characters, and no path/URL separators.
15///
16/// Serde deserialization always runs [`ProviderId::parse`] (JOE-1979) — never
17/// transparent `String` construction that could bypass reserved-name / syntax checks.
18#[derive(Clone, PartialEq, Eq, Hash, Serialize)]
19pub struct ProviderId(String);
20
21impl ProviderId {
22    /// Parse and normalize a provider id (or known alias).
23    pub fn parse(raw: &str) -> Result<Self> {
24        let trimmed = raw.trim();
25        if trimmed.is_empty() {
26            return Err(invalid("provider id must not be empty"));
27        }
28        if trimmed.len() > MAX_PROVIDER_ID_LEN {
29            return Err(invalid(&format!(
30                "provider id exceeds {MAX_PROVIDER_ID_LEN} characters"
31            )));
32        }
33        if trimmed.chars().any(|c| c.is_control() || c.is_whitespace()) {
34            return Err(invalid(
35                "provider id must not contain whitespace or control characters",
36            ));
37        }
38
39        let lowered = trimmed.to_ascii_lowercase();
40        let canonical = match lowered.as_str() {
41            // Explicit alias → canonical id (not a second implementation).
42            "grok" => "xai".to_string(),
43            other => other.to_string(),
44        };
45
46        if !is_valid_canonical(&canonical) {
47            return Err(invalid(
48                "provider id must match [a-z][a-z0-9_]* (lowercase letters, digits, underscore)",
49            ));
50        }
51
52        // Reserved for future platform surfaces; not user-selectable providers.
53        if matches!(
54            canonical.as_str(),
55            "auto" | "default" | "none" | "null" | "any" | "all" | "system"
56        ) {
57            return Err(invalid(&format!("provider id '{canonical}' is reserved")));
58        }
59
60        Ok(Self(canonical))
61    }
62
63    /// Infallible constructor for compile-time known ids (panics if invalid).
64    pub fn must(raw: &str) -> Self {
65        Self::parse(raw).unwrap_or_else(|e| panic!("invalid ProviderId {raw:?}: {e}"))
66    }
67
68    pub fn as_str(&self) -> &str {
69        &self.0
70    }
71
72    pub fn local() -> Self {
73        Self::must("local")
74    }
75
76    pub fn openrouter() -> Self {
77        Self::must("openrouter")
78    }
79}
80
81fn is_valid_canonical(s: &str) -> bool {
82    let mut chars = s.chars();
83    match chars.next() {
84        Some(c) if c.is_ascii_lowercase() => {}
85        _ => return false,
86    }
87    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
88}
89
90fn invalid(reason: &str) -> crate::error::TranscriptionError {
91    UserError::InvalidConfig {
92        reason: reason.into(),
93    }
94    .into()
95}
96
97impl fmt::Debug for ProviderId {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.debug_tuple("ProviderId").field(&self.0).finish()
100    }
101}
102
103impl fmt::Display for ProviderId {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        f.write_str(&self.0)
106    }
107}
108
109impl AsRef<str> for ProviderId {
110    fn as_ref(&self) -> &str {
111        self.as_str()
112    }
113}
114
115impl FromStr for ProviderId {
116    type Err = crate::error::TranscriptionError;
117
118    fn from_str(s: &str) -> Result<Self> {
119        Self::parse(s)
120    }
121}
122
123impl<'de> Deserialize<'de> for ProviderId {
124    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
125    where
126        D: serde::Deserializer<'de>,
127    {
128        let s = String::deserialize(deserializer)?;
129        ProviderId::parse(&s).map_err(serde::de::Error::custom)
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn accepts_canonical_ids() {
139        assert_eq!(ProviderId::parse("local").unwrap().as_str(), "local");
140        assert_eq!(
141            ProviderId::parse("OpenRouter").unwrap().as_str(),
142            "openrouter"
143        );
144        assert_eq!(ProviderId::parse("xai").unwrap().as_str(), "xai");
145        assert_eq!(
146            ProviderId::parse("elevenlabs").unwrap().as_str(),
147            "elevenlabs"
148        );
149    }
150
151    #[test]
152    fn alias_grok_maps_to_xai() {
153        assert_eq!(ProviderId::parse("grok").unwrap().as_str(), "xai");
154        assert_eq!(ProviderId::parse("Grok").unwrap().as_str(), "xai");
155    }
156
157    #[test]
158    fn rejects_empty_and_controls() {
159        assert!(ProviderId::parse("").is_err());
160        assert!(ProviderId::parse("   ").is_err());
161        assert!(ProviderId::parse("loc al").is_err());
162        assert!(ProviderId::parse("loc\nal").is_err());
163        // Leading/trailing whitespace is trimmed; internal newlines remain invalid.
164        assert_eq!(ProviderId::parse("  local\n").unwrap().as_str(), "local");
165        assert!(ProviderId::parse("a/b").is_err());
166        assert!(ProviderId::parse("../x").is_err());
167        assert!(ProviderId::parse("1bad").is_err());
168        assert!(ProviderId::parse("-dash").is_err());
169    }
170
171    #[test]
172    fn rejects_reserved() {
173        assert!(ProviderId::parse("auto").is_err());
174        assert!(ProviderId::parse("default").is_err());
175        assert!(ProviderId::parse("none").is_err());
176    }
177
178    #[test]
179    fn rejects_oversized() {
180        let s = "a".repeat(MAX_PROVIDER_ID_LEN + 1);
181        assert!(ProviderId::parse(&s).is_err());
182    }
183
184    #[test]
185    fn serde_deserialize_uses_parse() {
186        let id: ProviderId = serde_json::from_str("\"OpenRouter\"").unwrap();
187        assert_eq!(id.as_str(), "openrouter");
188        let id: ProviderId = serde_json::from_str("\"grok\"").unwrap();
189        assert_eq!(id.as_str(), "xai");
190        assert!(serde_json::from_str::<ProviderId>("\"auto\"").is_err());
191        assert!(serde_json::from_str::<ProviderId>("\"a/b\"").is_err());
192        assert!(serde_json::from_str::<ProviderId>("\"1bad\"").is_err());
193        assert!(serde_json::from_str::<ProviderId>("\"loc al\"").is_err());
194    }
195}