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) -> &str {
135 &self.namespace
136 }
137
138 pub fn is_namespace_only(&self) -> bool {
140 self.domain.is_none() && self.workspace_id.is_none() && self.repo_id.is_none()
141 }
142}
143
144impl std::fmt::Display for ScopeKey {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 write!(f, "{}", self.namespace)?;
147 if let Some(d) = &self.domain {
148 write!(f, "/{d}")?;
149 }
150 if let Some(w) = &self.workspace_id {
151 write!(f, "@{w}")?;
152 }
153 if let Some(r) = &self.repo_id {
154 write!(f, "#{r}")?;
155 }
156 Ok(())
157 }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
165#[serde(rename_all = "snake_case")]
166pub enum PhaseStatus {
167 Current,
169 Compatibility,
171 PhaseGated,
173}
174
175impl PhaseStatus {
176 pub fn as_str(&self) -> &'static str {
177 match self {
178 Self::Current => "current",
179 Self::Compatibility => "compatibility",
180 Self::PhaseGated => "phase_gated",
181 }
182 }
183}
184
185impl std::fmt::Display for PhaseStatus {
186 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187 f.write_str(self.as_str())
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn scope_key_equality() {
197 let s1 = Scope::new("ns").with_repo("repo-a");
198 let s2 = Scope::new("ns").with_repo("repo-a");
199 assert_eq!(s1.key(), s2.key());
200 }
201
202 #[test]
203 fn scope_key_inequality_different_repo() {
204 let s1 = Scope::new("ns").with_repo("repo-a");
205 let s2 = Scope::new("ns").with_repo("repo-b");
206 assert_ne!(s1.key(), s2.key());
207 }
208
209 #[test]
210 fn scope_key_display() {
211 let s = Scope::new("prod")
212 .with_domain("code")
213 .with_workspace("ws1")
214 .with_repo("myrepo");
215 assert_eq!(s.key().to_string(), "prod/code@ws1#myrepo");
216 }
217
218 #[test]
219 fn scope_key_display_namespace_only() {
220 let sk = ScopeKey::namespace_only("default");
221 assert_eq!(sk.to_string(), "default");
222 }
223
224 #[test]
225 fn legacy_namespace_roundtrip() {
226 let sk = ScopeKey::from_legacy_namespace("my-namespace");
227 assert_eq!(sk.to_legacy_namespace(), "my-namespace");
228 assert!(sk.is_namespace_only());
229 }
230
231 #[test]
232 fn non_namespace_only_scope() {
233 let sk = Scope::new("ns").with_domain("code").key();
234 assert!(!sk.is_namespace_only());
235 }
236
237 #[test]
238 fn scope_key_ordering() {
239 let a = ScopeKey::namespace_only("aaa");
240 let b = ScopeKey::namespace_only("bbb");
241 assert!(a < b);
242 }
243
244 #[test]
245 fn scope_key_serde_roundtrip() {
246 let sk = Scope::new("ns")
247 .with_domain("code")
248 .with_workspace("ws")
249 .key();
250 let json = serde_json::to_string(&sk).unwrap();
251 let back: ScopeKey = serde_json::from_str(&json).unwrap();
252 assert_eq!(back, sk);
253 }
254
255 #[test]
256 fn scope_key_serde_skips_none() {
257 let sk = ScopeKey::namespace_only("ns");
258 let json = serde_json::to_string(&sk).unwrap();
259 assert!(!json.contains("domain"));
260 assert!(!json.contains("workspace_id"));
261 assert!(!json.contains("repo_id"));
262 }
263}