1use serde::{Deserialize, Serialize};
2
3use crate::error::{OciError, Result};
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
7pub struct OciRef {
8 pub registry: String,
9 pub repository: String,
10 pub tag: Option<String>,
11 pub digest: Option<String>,
12}
13
14impl OciRef {
15 pub fn reference(&self) -> String {
16 let base = format!("{}/{}", self.registry, self.repository);
17 if let Some(digest) = &self.digest {
18 format!("{base}@{digest}")
19 } else if let Some(tag) = &self.tag {
20 format!("{base}:{tag}")
21 } else {
22 format!("{base}:latest")
23 }
24 }
25
26 pub fn repository_path(&self) -> String {
27 format!("{}/{}", self.registry, self.repository)
28 }
29
30 pub fn with_tag(&self, tag: &str) -> Self {
31 Self {
32 registry: self.registry.clone(),
33 repository: self.repository.clone(),
34 tag: Some(tag.to_string()),
35 digest: None,
36 }
37 }
38
39 pub fn with_digest(&self, digest: &str) -> Self {
40 Self {
41 registry: self.registry.clone(),
42 repository: self.repository.clone(),
43 tag: None,
44 digest: Some(digest.to_string()),
45 }
46 }
47}
48
49impl std::fmt::Display for OciRef {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 f.write_str(&self.reference())
52 }
53}
54
55pub fn parse_image_ref(input: &str) -> Result<OciRef> {
57 let input = input.trim();
58 if input.is_empty() {
59 return Err(OciError::InvalidRef("empty image reference".into()));
60 }
61
62 let (without_digest, digest) = if let Some((left, dig)) = input.split_once('@') {
63 let dig = dig.trim();
64 if dig.is_empty() {
65 return Err(OciError::InvalidRef("digest after @ is empty".into()));
66 }
67 (left, Some(dig.to_string()))
68 } else {
69 (input, None)
70 };
71
72 let (path, tag) = if digest.is_some() {
73 (without_digest, None)
74 } else if let Some((left, tag)) = without_digest.rsplit_once(':') {
75 if tag.contains('/') {
76 (without_digest, None)
77 } else {
78 (left, Some(tag.to_string()))
79 }
80 } else {
81 (without_digest, None)
82 };
83
84 let path = path.trim_start_matches('/').trim();
85 if path.is_empty() {
86 return Err(OciError::InvalidRef("empty repository path".into()));
87 }
88
89 let (registry, repository) = split_registry_repo(path)?;
90 Ok(OciRef {
91 registry,
92 repository,
93 tag: tag.filter(|t| !t.is_empty()),
94 digest,
95 })
96}
97
98fn split_registry_repo(path: &str) -> Result<(String, String)> {
99 let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
100 if parts.is_empty() {
101 return Err(OciError::InvalidRef("empty repository path".into()));
102 }
103 if parts.len() == 1 {
104 return Ok((
105 "docker.io".to_string(),
106 format!("library/{}", parts[0]),
107 ));
108 }
109 let first = parts[0];
110 let looks_like_registry = first.contains('.') || first.contains(':') || first == "localhost";
111 if looks_like_registry {
112 let repository = parts[1..].join("/");
113 if repository.is_empty() {
114 return Err(OciError::InvalidRef("empty repository".into()));
115 }
116 return Ok((first.to_string(), repository));
117 }
118 Ok(("docker.io".to_string(), parts.join("/")))
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn parses_ghcr_tag() {
127 let r = parse_image_ref("ghcr.io/xylex-group/athena:4.0.1").unwrap();
128 assert_eq!(r.registry, "ghcr.io");
129 assert_eq!(r.repository, "xylex-group/athena");
130 assert_eq!(r.tag.as_deref(), Some("4.0.1"));
131 }
132
133 #[test]
134 fn parses_digest() {
135 let r = parse_image_ref("ghcr.io/xylex-group/athena@sha256:abc").unwrap();
136 assert_eq!(r.digest.as_deref(), Some("sha256:abc"));
137 }
138
139 #[test]
140 fn parses_registry_port() {
141 let r = parse_image_ref("localhost:5000/my/app:dev").unwrap();
142 assert_eq!(r.registry, "localhost:5000");
143 assert_eq!(r.repository, "my/app");
144 }
145}