1use std::path::PathBuf;
11
12use crate::error::{RegistryError, RegistryResult};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum PackRef {
17 Local(PathBuf),
19
20 Bundled(String),
22
23 Registry {
25 name: String,
26 version: String,
27 pinned_digest: Option<String>,
29 },
30
31 Byos(String),
33}
34
35impl PackRef {
36 pub fn parse(reference: &str) -> RegistryResult<Self> {
66 let reference = reference.trim();
67
68 if reference.is_empty() {
69 return Err(RegistryError::InvalidReference {
70 reference: reference.to_string(),
71 reason: "empty reference".to_string(),
72 });
73 }
74
75 if reference.starts_with("s3://")
77 || reference.starts_with("gs://")
78 || reference.starts_with("azure://")
79 || reference.starts_with("https://")
80 || reference.starts_with("http://")
81 {
82 return Ok(Self::Byos(reference.to_string()));
83 }
84
85 if reference.starts_with("./")
87 || reference.starts_with("../")
88 || reference.starts_with('/')
89 || reference.ends_with(".yaml")
90 || reference.ends_with(".yml")
91 {
92 return Ok(Self::Local(PathBuf::from(reference)));
93 }
94
95 if reference.len() >= 2 && reference.chars().nth(1) == Some(':') {
97 return Ok(Self::Local(PathBuf::from(reference)));
98 }
99
100 if let Some(at_pos) = reference.find('@') {
102 let name = &reference[..at_pos];
103 let rest = &reference[at_pos + 1..];
104
105 let (version, pinned_digest) = if let Some(hash_pos) = rest.find('#') {
107 let version = &rest[..hash_pos];
108 let digest = &rest[hash_pos + 1..];
109
110 if !digest.starts_with("sha256:") {
112 return Err(RegistryError::InvalidReference {
113 reference: reference.to_string(),
114 reason: "pinned digest must start with 'sha256:'".to_string(),
115 });
116 }
117
118 (version.to_string(), Some(digest.to_string()))
119 } else {
120 (rest.to_string(), None)
121 };
122
123 validate_pack_name(name)?;
125
126 if version.is_empty() {
128 return Err(RegistryError::InvalidReference {
129 reference: reference.to_string(),
130 reason: "version is required for registry packs".to_string(),
131 });
132 }
133
134 return Ok(Self::Registry {
135 name: name.to_string(),
136 version,
137 pinned_digest,
138 });
139 }
140
141 validate_pack_name(reference)?;
143 Ok(Self::Bundled(reference.to_string()))
144 }
145
146 pub fn is_local(&self) -> bool {
148 matches!(self, Self::Local(_))
149 }
150
151 pub fn is_bundled(&self) -> bool {
153 matches!(self, Self::Bundled(_))
154 }
155
156 pub fn is_registry(&self) -> bool {
158 matches!(self, Self::Registry { .. })
159 }
160
161 pub fn is_byos(&self) -> bool {
163 matches!(self, Self::Byos(_))
164 }
165
166 pub fn name(&self) -> Option<&str> {
168 match self {
169 Self::Bundled(name) => Some(name),
170 Self::Registry { name, .. } => Some(name),
171 _ => None,
172 }
173 }
174
175 pub fn version(&self) -> Option<&str> {
177 match self {
178 Self::Registry { version, .. } => Some(version),
179 _ => None,
180 }
181 }
182
183 pub fn pinned_digest(&self) -> Option<&str> {
185 match self {
186 Self::Registry { pinned_digest, .. } => pinned_digest.as_deref(),
187 _ => None,
188 }
189 }
190}
191
192impl std::fmt::Display for PackRef {
193 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194 match self {
195 Self::Local(path) => write!(f, "{}", path.display()),
196 Self::Bundled(name) => write!(f, "{}", name),
197 Self::Registry {
198 name,
199 version,
200 pinned_digest: None,
201 } => write!(f, "{}@{}", name, version),
202 Self::Registry {
203 name,
204 version,
205 pinned_digest: Some(digest),
206 } => write!(f, "{}@{}#{}", name, version, digest),
207 Self::Byos(url) => write!(f, "{}", url),
208 }
209 }
210}
211
212impl std::str::FromStr for PackRef {
213 type Err = RegistryError;
214
215 fn from_str(s: &str) -> Result<Self, Self::Err> {
216 Self::parse(s)
217 }
218}
219
220fn validate_pack_name(name: &str) -> RegistryResult<()> {
222 if name.is_empty() {
223 return Err(RegistryError::InvalidReference {
224 reference: name.to_string(),
225 reason: "pack name cannot be empty".to_string(),
226 });
227 }
228
229 if !name
231 .chars()
232 .next()
233 .map(|c| c.is_ascii_lowercase())
234 .unwrap_or(false)
235 {
236 return Err(RegistryError::InvalidReference {
237 reference: name.to_string(),
238 reason: "pack name must start with a lowercase letter".to_string(),
239 });
240 }
241
242 if !name
244 .chars()
245 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
246 {
247 return Err(RegistryError::InvalidReference {
248 reference: name.to_string(),
249 reason: "pack name may only contain lowercase letters, digits, and hyphens".to_string(),
250 });
251 }
252
253 if name.ends_with('-') {
255 return Err(RegistryError::InvalidReference {
256 reference: name.to_string(),
257 reason: "pack name cannot end with a hyphen".to_string(),
258 });
259 }
260
261 if name.contains("--") {
263 return Err(RegistryError::InvalidReference {
264 reference: name.to_string(),
265 reason: "pack name cannot have consecutive hyphens".to_string(),
266 });
267 }
268
269 Ok(())
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn test_parse_local_relative() {
278 let pack_ref = PackRef::parse("./custom.yaml").unwrap();
279 assert!(
280 matches!(pack_ref, PackRef::Local(p) if p.as_path() == std::path::Path::new("./custom.yaml"))
281 );
282 }
283
284 #[test]
285 fn test_parse_local_parent() {
286 let pack_ref = PackRef::parse("../packs/custom.yaml").unwrap();
287 assert!(matches!(pack_ref, PackRef::Local(_)));
288 }
289
290 #[test]
291 fn test_parse_local_absolute() {
292 let pack_ref = PackRef::parse("/home/user/packs/custom.yaml").unwrap();
293 assert!(matches!(pack_ref, PackRef::Local(_)));
294 }
295
296 #[test]
297 fn test_parse_local_by_extension() {
298 let pack_ref = PackRef::parse("custom.yaml").unwrap();
299 assert!(matches!(pack_ref, PackRef::Local(_)));
300 }
301
302 #[test]
303 fn test_parse_bundled() {
304 let pack_ref = PackRef::parse("eu-ai-act-baseline").unwrap();
305 assert_eq!(pack_ref, PackRef::Bundled("eu-ai-act-baseline".to_string()));
306 }
307
308 #[test]
309 fn test_parse_registry() {
310 let pack_ref = PackRef::parse("eu-ai-act-pro@1.2.0").unwrap();
311 assert_eq!(
312 pack_ref,
313 PackRef::Registry {
314 name: "eu-ai-act-pro".to_string(),
315 version: "1.2.0".to_string(),
316 pinned_digest: None,
317 }
318 );
319 }
320
321 #[test]
322 fn test_parse_registry_with_digest() {
323 let pack_ref = PackRef::parse("eu-ai-act-pro@1.2.0#sha256:abc123").unwrap();
324 assert_eq!(
325 pack_ref,
326 PackRef::Registry {
327 name: "eu-ai-act-pro".to_string(),
328 version: "1.2.0".to_string(),
329 pinned_digest: Some("sha256:abc123".to_string()),
330 }
331 );
332 }
333
334 #[test]
335 fn test_parse_byos_s3() {
336 let pack_ref = PackRef::parse("s3://bucket/path/pack.yaml").unwrap();
337 assert_eq!(
338 pack_ref,
339 PackRef::Byos("s3://bucket/path/pack.yaml".to_string())
340 );
341 }
342
343 #[test]
344 fn test_parse_byos_https() {
345 let pack_ref = PackRef::parse("https://example.com/packs/custom.yaml").unwrap();
346 assert_eq!(
347 pack_ref,
348 PackRef::Byos("https://example.com/packs/custom.yaml".to_string())
349 );
350 }
351
352 #[test]
353 fn test_parse_empty() {
354 let result = PackRef::parse("");
355 assert!(matches!(
356 result,
357 Err(RegistryError::InvalidReference { .. })
358 ));
359 }
360
361 #[test]
362 fn test_parse_invalid_digest() {
363 let result = PackRef::parse("pack@1.0.0#md5:abc123");
364 assert!(matches!(
365 result,
366 Err(RegistryError::InvalidReference { .. })
367 ));
368 }
369
370 #[test]
371 fn test_parse_missing_version() {
372 let result = PackRef::parse("pack@");
373 assert!(matches!(
374 result,
375 Err(RegistryError::InvalidReference { .. })
376 ));
377 }
378
379 #[test]
380 fn test_validate_name_uppercase() {
381 let result = validate_pack_name("MyPack");
382 assert!(matches!(
383 result,
384 Err(RegistryError::InvalidReference { .. })
385 ));
386 }
387
388 #[test]
389 fn test_validate_name_starts_with_digit() {
390 let result = validate_pack_name("123-pack");
391 assert!(matches!(
392 result,
393 Err(RegistryError::InvalidReference { .. })
394 ));
395 }
396
397 #[test]
398 fn test_validate_name_ends_with_hyphen() {
399 let result = validate_pack_name("pack-");
400 assert!(matches!(
401 result,
402 Err(RegistryError::InvalidReference { .. })
403 ));
404 }
405
406 #[test]
407 fn test_validate_name_consecutive_hyphens() {
408 let result = validate_pack_name("pack--name");
409 assert!(matches!(
410 result,
411 Err(RegistryError::InvalidReference { .. })
412 ));
413 }
414
415 #[test]
416 fn test_display() {
417 assert_eq!(
418 PackRef::Local(PathBuf::from("./custom.yaml")).to_string(),
419 "./custom.yaml"
420 );
421 assert_eq!(
422 PackRef::Bundled("my-pack".to_string()).to_string(),
423 "my-pack"
424 );
425 assert_eq!(
426 PackRef::Registry {
427 name: "pack".to_string(),
428 version: "1.0.0".to_string(),
429 pinned_digest: None
430 }
431 .to_string(),
432 "pack@1.0.0"
433 );
434 assert_eq!(
435 PackRef::Registry {
436 name: "pack".to_string(),
437 version: "1.0.0".to_string(),
438 pinned_digest: Some("sha256:abc".to_string())
439 }
440 .to_string(),
441 "pack@1.0.0#sha256:abc"
442 );
443 }
444
445 #[test]
446 fn test_accessors() {
447 let registry_ref = PackRef::Registry {
448 name: "my-pack".to_string(),
449 version: "1.0.0".to_string(),
450 pinned_digest: Some("sha256:abc".to_string()),
451 };
452
453 assert!(registry_ref.is_registry());
454 assert!(!registry_ref.is_local());
455 assert!(!registry_ref.is_bundled());
456 assert!(!registry_ref.is_byos());
457 assert_eq!(registry_ref.name(), Some("my-pack"));
458 assert_eq!(registry_ref.version(), Some("1.0.0"));
459 assert_eq!(registry_ref.pinned_digest(), Some("sha256:abc"));
460 }
461
462 #[test]
463 fn test_from_str() {
464 let pack_ref: PackRef = "eu-ai-act-pro@1.2.0".parse().unwrap();
465 assert!(matches!(pack_ref, PackRef::Registry { .. }));
466 }
467}