1use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
28pub struct Scope {
29 pub namespace: String,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub domain: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub workspace_id: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub repo_id: Option<String>,
40}
41
42impl Scope {
43 pub fn new(namespace: impl Into<String>) -> Self {
45 Self {
46 namespace: namespace.into(),
47 domain: None,
48 workspace_id: None,
49 repo_id: None,
50 }
51 }
52
53 pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
55 self.domain = Some(domain.into());
56 self
57 }
58
59 pub fn with_workspace(mut self, id: impl Into<String>) -> Self {
61 self.workspace_id = Some(id.into());
62 self
63 }
64
65 pub fn with_repo(mut self, id: impl Into<String>) -> Self {
67 self.repo_id = Some(id.into());
68 self
69 }
70
71 pub fn key(&self) -> ScopeKey {
73 ScopeKey {
74 namespace: self.namespace.clone(),
75 domain: self.domain.clone(),
76 workspace_id: self.workspace_id.clone(),
77 repo_id: self.repo_id.clone(),
78 }
79 }
80}
81
82#[derive(
96 Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
97)]
98pub struct ScopeKey {
99 pub namespace: String,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub domain: Option<String>,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub workspace_id: Option<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub repo_id: Option<String>,
106}
107
108impl ScopeKey {
109 pub fn namespace_only(ns: impl Into<String>) -> Self {
111 Self {
112 namespace: ns.into(),
113 domain: None,
114 workspace_id: None,
115 repo_id: None,
116 }
117 }
118
119 pub fn from_legacy_namespace(namespace: impl Into<String>) -> Self {
126 Self::namespace_only(namespace)
127 }
128
129 pub fn to_legacy_namespace(&self) -> Result<&str, ScopeError> {
135 if self.is_namespace_only() {
136 Ok(&self.namespace)
137 } else {
138 Err(ScopeError::LossyProjection)
139 }
140 }
141
142 pub fn to_legacy_namespace_lossy(
143 &self,
144 _policy: LossyScopePolicy,
145 ) -> (String, ScopeLossReceipt) {
146 (
147 self.namespace.clone(),
148 ScopeLossReceipt {
149 namespace: self.namespace.clone(),
150 dropped_domain: self.domain.clone(),
151 dropped_workspace_id: self.workspace_id.clone(),
152 dropped_repo_id: self.repo_id.clone(),
153 },
154 )
155 }
156
157 pub fn is_namespace_only(&self) -> bool {
159 self.domain.is_none() && self.workspace_id.is_none() && self.repo_id.is_none()
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum LossyScopePolicy {
165 Authorized,
166}
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct ScopeLossReceipt {
169 pub namespace: String,
170 pub dropped_domain: Option<String>,
171 pub dropped_workspace_id: Option<String>,
172 pub dropped_repo_id: Option<String>,
173}
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum ScopeError {
176 LossyProjection,
177}
178impl std::fmt::Display for ScopeError {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 f.write_str("scope has dimensions not representable by a legacy namespace")
181 }
182}
183impl std::error::Error for ScopeError {}
184
185impl std::fmt::Display for ScopeKey {
186 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187 write!(f, "{}", self.namespace)?;
188 if let Some(d) = &self.domain {
189 write!(f, "/{d}")?;
190 }
191 if let Some(w) = &self.workspace_id {
192 write!(f, "@{w}")?;
193 }
194 if let Some(r) = &self.repo_id {
195 write!(f, "#{r}")?;
196 }
197 Ok(())
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
206#[serde(rename_all = "snake_case")]
207pub enum PhaseStatus {
208 Current,
210 Compatibility,
212 PhaseGated,
214}
215
216impl PhaseStatus {
217 pub fn as_str(&self) -> &'static str {
218 match self {
219 Self::Current => "current",
220 Self::Compatibility => "compatibility",
221 Self::PhaseGated => "phase_gated",
222 }
223 }
224}
225
226impl std::fmt::Display for PhaseStatus {
227 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228 f.write_str(self.as_str())
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn scope_key_equality() {
238 let s1 = Scope::new("ns").with_repo("repo-a");
239 let s2 = Scope::new("ns").with_repo("repo-a");
240 assert_eq!(s1.key(), s2.key());
241 }
242
243 #[test]
244 fn scope_key_inequality_different_repo() {
245 let s1 = Scope::new("ns").with_repo("repo-a");
246 let s2 = Scope::new("ns").with_repo("repo-b");
247 assert_ne!(s1.key(), s2.key());
248 }
249
250 #[test]
251 fn scope_key_display() {
252 let s = Scope::new("prod")
253 .with_domain("code")
254 .with_workspace("ws1")
255 .with_repo("myrepo");
256 assert_eq!(s.key().to_string(), "prod/code@ws1#myrepo");
257 }
258
259 #[test]
260 fn scope_key_display_namespace_only() {
261 let sk = ScopeKey::namespace_only("default");
262 assert_eq!(sk.to_string(), "default");
263 }
264
265 #[test]
266 fn legacy_namespace_roundtrip() {
267 let sk = ScopeKey::from_legacy_namespace("my-namespace");
268 assert_eq!(sk.to_legacy_namespace().unwrap(), "my-namespace");
269 assert!(sk.is_namespace_only());
270 }
271
272 #[test]
273 fn non_namespace_only_scope() {
274 let sk = Scope::new("ns").with_domain("code").key();
275 assert!(!sk.is_namespace_only());
276 }
277
278 #[test]
279 fn scope_key_ordering() {
280 let a = ScopeKey::namespace_only("aaa");
281 let b = ScopeKey::namespace_only("bbb");
282 assert!(a < b);
283 }
284
285 #[test]
286 fn scope_key_serde_roundtrip() {
287 let sk = Scope::new("ns")
288 .with_domain("code")
289 .with_workspace("ws")
290 .key();
291 let json = serde_json::to_string(&sk).unwrap();
292 let back: ScopeKey = serde_json::from_str(&json).unwrap();
293 assert_eq!(back, sk);
294 }
295
296 #[test]
297 fn scope_key_serde_skips_none() {
298 let sk = ScopeKey::namespace_only("ns");
299 let json = serde_json::to_string(&sk).unwrap();
300 assert!(!json.contains("domain"));
301 assert!(!json.contains("workspace_id"));
302 assert!(!json.contains("repo_id"));
303 }
304}