Skip to main content

assay_registry/
reference.rs

1//! Pack reference parsing.
2//!
3//! Supports various reference formats:
4//! - `./custom.yaml` → local file
5//! - `eu-ai-act-baseline` → bundled pack
6//! - `eu-ai-act-pro@1.2.0` → registry pack
7//! - `eu-ai-act-pro@1.2.0#sha256:abc...` → registry pack with pinned digest
8//! - `s3://bucket/pack.yaml` → BYOS (Bring Your Own Storage)
9
10use std::path::PathBuf;
11
12use crate::error::{RegistryError, RegistryResult};
13
14/// A parsed pack reference.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum PackRef {
17    /// Local file path (relative or absolute).
18    Local(PathBuf),
19
20    /// Bundled pack (name only, no version).
21    Bundled(String),
22
23    /// Registry pack with version.
24    Registry {
25        name: String,
26        version: String,
27        /// Optional pinned digest (sha256:...).
28        pinned_digest: Option<String>,
29    },
30
31    /// BYOS (Bring Your Own Storage) URL.
32    Byos(String),
33}
34
35impl PackRef {
36    /// Parse a pack reference string.
37    ///
38    /// # Examples
39    ///
40    /// ```
41    /// use assay_registry::PackRef;
42    ///
43    /// // Local file
44    /// let local = PackRef::parse("./custom.yaml").unwrap();
45    /// assert!(matches!(local, PackRef::Local(_)));
46    ///
47    /// // Bundled pack
48    /// let bundled = PackRef::parse("eu-ai-act-baseline").unwrap();
49    /// assert!(matches!(bundled, PackRef::Bundled(_)));
50    ///
51    /// // Registry pack with version
52    /// let registry = PackRef::parse("eu-ai-act-pro@1.2.0").unwrap();
53    /// assert!(matches!(registry, PackRef::Registry { .. }));
54    ///
55    /// // Registry pack with pinned digest
56    /// let pinned = PackRef::parse("eu-ai-act-pro@1.2.0#sha256:abc123").unwrap();
57    /// if let PackRef::Registry { pinned_digest, .. } = pinned {
58    ///     assert!(pinned_digest.is_some());
59    /// }
60    ///
61    /// // BYOS
62    /// let byos = PackRef::parse("s3://bucket/pack.yaml").unwrap();
63    /// assert!(matches!(byos, PackRef::Byos(_)));
64    /// ```
65    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        // Check for BYOS URLs first (s3://, gs://, azure://, https://)
76        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        // Check for local file paths
86        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        // Check for Windows absolute paths
96        if reference.len() >= 2 && reference.chars().nth(1) == Some(':') {
97            return Ok(Self::Local(PathBuf::from(reference)));
98        }
99
100        // Check for registry reference (name@version#digest)
101        if let Some(at_pos) = reference.find('@') {
102            let name = &reference[..at_pos];
103            let rest = &reference[at_pos + 1..];
104
105            // Check for pinned digest
106            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                // Validate digest format
111                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 name
124            validate_pack_name(name)?;
125
126            // Validate version is not empty
127            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        // Assume bundled pack (name only)
142        validate_pack_name(reference)?;
143        Ok(Self::Bundled(reference.to_string()))
144    }
145
146    /// Check if this is a local file reference.
147    pub fn is_local(&self) -> bool {
148        matches!(self, Self::Local(_))
149    }
150
151    /// Check if this is a bundled pack reference.
152    pub fn is_bundled(&self) -> bool {
153        matches!(self, Self::Bundled(_))
154    }
155
156    /// Check if this is a registry pack reference.
157    pub fn is_registry(&self) -> bool {
158        matches!(self, Self::Registry { .. })
159    }
160
161    /// Check if this is a BYOS reference.
162    pub fn is_byos(&self) -> bool {
163        matches!(self, Self::Byos(_))
164    }
165
166    /// Get the pack name (for bundled and registry refs).
167    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    /// Get the version (for registry refs).
176    pub fn version(&self) -> Option<&str> {
177        match self {
178            Self::Registry { version, .. } => Some(version),
179            _ => None,
180        }
181    }
182
183    /// Get the pinned digest (for registry refs).
184    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
220/// Validate a pack name.
221fn 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    // Must start with lowercase letter
230    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    // Must only contain lowercase letters, digits, and hyphens
243    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    // Cannot end with hyphen
254    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    // Cannot have consecutive hyphens
262    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}