Skip to main content

compose_lens/model/
image.rs

1//! Tolerant Compose image references.
2
3/// A parsed digest suffix that remains tolerant of implementation extensions.
4#[derive(Debug, Clone, PartialEq, Eq, Hash)]
5pub struct ImageDigest {
6    algorithm: Option<String>,
7    value: String,
8}
9
10impl ImageDigest {
11    /// Returns the digest algorithm when the suffix contains `algorithm:value`.
12    #[must_use]
13    pub fn algorithm(&self) -> Option<&str> {
14        self.algorithm.as_deref()
15    }
16
17    /// Returns the digest payload without validating an OCI grammar.
18    #[must_use]
19    pub fn value(&self) -> &str {
20        &self.value
21    }
22}
23
24/// A loss-aware, tolerant image reference.
25///
26/// `ComposeLens` deliberately accepts combined tags and digests such as
27/// `registry.example/app:1.2@sha256:...` and does not reject implementation-supported values
28/// merely because a stricter external grammar would.
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub struct ImageReference {
31    raw: String,
32    name: String,
33    tag: Option<String>,
34    digest: Option<ImageDigest>,
35}
36
37impl ImageReference {
38    pub(crate) fn parse(raw: String) -> Self {
39        let (name_and_tag, digest) = raw.split_once('@').map_or((raw.as_str(), None), |(left, right)| {
40            let (algorithm, value) = right
41                .split_once(':')
42                .map_or((None, right.to_owned()), |(algorithm, value)| {
43                    (Some(algorithm.to_owned()), value.to_owned())
44                });
45            (left, Some(ImageDigest { algorithm, value }))
46        });
47
48        let last_slash = name_and_tag.rfind('/');
49        let tag_separator = name_and_tag
50            .rfind(':')
51            .filter(|index| last_slash.is_none_or(|slash| *index > slash));
52        let (name, tag) = tag_separator.map_or_else(
53            || (name_and_tag.to_owned(), None),
54            |index| {
55                (
56                    name_and_tag[..index].to_owned(),
57                    Some(name_and_tag[index + 1..].to_owned()),
58                )
59            },
60        );
61
62        Self { raw, name, tag, digest }
63    }
64
65    /// Returns the complete unmodified semantic reference.
66    #[must_use]
67    pub fn raw(&self) -> &str {
68        &self.raw
69    }
70
71    /// Returns the repository/name portion before a tag or digest.
72    #[must_use]
73    pub fn name(&self) -> &str {
74        &self.name
75    }
76
77    /// Returns the tag without the leading colon.
78    #[must_use]
79    pub fn tag(&self) -> Option<&str> {
80        self.tag.as_deref()
81    }
82
83    /// Returns the digest suffix without the leading at-sign.
84    #[must_use]
85    pub const fn digest(&self) -> Option<&ImageDigest> {
86        self.digest.as_ref()
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::ImageReference;
93
94    #[test]
95    fn accepts_registry_ports_tags_and_digests_together() {
96        let reference = ImageReference::parse("registry.example:5000/team/app:1.2@sha256:abcdef".to_owned());
97
98        assert_eq!(reference.name(), "registry.example:5000/team/app");
99        assert_eq!(reference.tag(), Some("1.2"));
100        assert_eq!(
101            reference.digest().and_then(super::ImageDigest::algorithm),
102            Some("sha256")
103        );
104        assert_eq!(reference.digest().map(super::ImageDigest::value), Some("abcdef"));
105    }
106}