greentic_secrets_spec/
uri.rs

1use crate::error::{Error, Result};
2use crate::types::{Scope, validate_component, validate_version};
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use std::str::FromStr;
7
8const SCHEME: &str = "secrets://";
9const TEAM_PLACEHOLDER: &str = "_";
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub struct SecretUri {
13    scope: Scope,
14    category: String,
15    name: String,
16    version: Option<String>,
17}
18
19impl SecretUri {
20    pub fn new(scope: Scope, category: impl Into<String>, name: impl Into<String>) -> Result<Self> {
21        let category = category.into();
22        let name = name.into();
23
24        validate_component(&category, "category")?;
25        validate_component(&name, "name")?;
26
27        Ok(Self {
28            scope,
29            category,
30            name,
31            version: None,
32        })
33    }
34
35    pub fn scope(&self) -> &Scope {
36        &self.scope
37    }
38
39    pub fn category(&self) -> &str {
40        &self.category
41    }
42
43    pub fn name(&self) -> &str {
44        &self.name
45    }
46
47    pub fn version(&self) -> Option<&str> {
48        self.version.as_deref()
49    }
50
51    pub fn with_version(mut self, version: Option<&str>) -> Result<Self> {
52        if let Some(value) = version {
53            validate_version(value)?;
54            self.version = Some(value.to_string());
55        } else {
56            self.version = None;
57        }
58        Ok(self)
59    }
60
61    pub fn parse(input: &str) -> Result<Self> {
62        let raw = input.trim();
63        if !raw.starts_with(SCHEME) {
64            return Err(Error::InvalidScheme);
65        }
66
67        let path = &raw[SCHEME.len()..];
68        let mut segments = path.split('/');
69
70        let env = segments.next().ok_or(Error::MissingSegment {
71            field: "environment",
72        })?;
73        let tenant = segments
74            .next()
75            .ok_or(Error::MissingSegment { field: "tenant" })?;
76        let team_segment = segments
77            .next()
78            .ok_or(Error::MissingSegment { field: "team" })?;
79        let category = segments
80            .next()
81            .ok_or(Error::MissingSegment { field: "category" })?;
82        let name_segment = segments
83            .next()
84            .ok_or(Error::MissingSegment { field: "name" })?;
85
86        if segments.next().is_some() {
87            return Err(Error::ExtraSegments);
88        }
89
90        let team = if team_segment == TEAM_PLACEHOLDER {
91            None
92        } else {
93            Some(team_segment.to_string())
94        };
95
96        let (name, version) = split_name_version(name_segment)?;
97
98        let scope = Scope::new(env.to_string(), tenant.to_string(), team)?;
99        let mut uri = SecretUri::new(scope, category, name)?;
100        if let Some(version) = version {
101            uri = uri.with_version(Some(&version))?;
102        }
103
104        Ok(uri)
105    }
106
107    fn format_team(team: Option<&str>) -> &str {
108        team.unwrap_or(TEAM_PLACEHOLDER)
109    }
110}
111
112fn split_name_version(segment: &str) -> Result<(&str, Option<String>)> {
113    let mut parts = segment.split('@');
114    let name = parts.next().unwrap_or_default();
115    let version = parts.next();
116
117    if parts.next().is_some() {
118        return Err(Error::InvalidVersion {
119            value: segment.to_string(),
120        });
121    }
122
123    if let Some(v) = version {
124        validate_version(v)?;
125        Ok((name, Some(v.to_string())))
126    } else {
127        Ok((name, None))
128    }
129}
130
131impl fmt::Display for SecretUri {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        write!(
134            f,
135            "{SCHEME}{}/{}/{}/{}/{}",
136            self.scope.env(),
137            self.scope.tenant(),
138            Self::format_team(self.scope.team()),
139            self.category,
140            self.name
141        )?;
142
143        if let Some(version) = &self.version {
144            write!(f, "@{version}")?;
145        }
146        Ok(())
147    }
148}
149
150impl FromStr for SecretUri {
151    type Err = Error;
152
153    fn from_str(s: &str) -> Result<Self> {
154        SecretUri::parse(s)
155    }
156}
157
158impl SecretUri {
159    pub fn into_string(self) -> String {
160        self.to_string()
161    }
162}
163
164impl TryFrom<&str> for SecretUri {
165    type Error = Error;
166
167    fn try_from(value: &str) -> Result<Self> {
168        SecretUri::parse(value)
169    }
170}
171
172impl TryFrom<String> for SecretUri {
173    type Error = Error;
174
175    fn try_from(value: String) -> Result<Self> {
176        SecretUri::parse(&value)
177    }
178}
179
180#[cfg(feature = "serde")]
181impl Serialize for SecretUri {
182    fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
183    where
184        S: serde::Serializer,
185    {
186        serializer.serialize_str(&self.to_string())
187    }
188}
189
190#[cfg(feature = "serde")]
191impl<'de> Deserialize<'de> for SecretUri {
192    fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
193    where
194        D: serde::Deserializer<'de>,
195    {
196        let value = String::deserialize(deserializer)?;
197        SecretUri::parse(&value).map_err(serde::de::Error::custom)
198    }
199}
200
201// No schema integration in this crate; downstream can wrap as needed.