1use std::path::PathBuf;
5
6#[derive(Clone, Debug, PartialEq, Eq)]
8pub enum ImageRef {
9 File { path: PathBuf },
11 Hf {
13 user: String,
14 repo: String,
15 tag: Option<String>,
16 },
17 S3 { bucket: String, prefix: String },
19 Ipfs { cid: String },
21 Oci {
23 host: String,
24 port: Option<u16>,
25 repo: String,
26 tag: Option<String>,
27 },
28}
29
30#[derive(Debug, thiserror::Error)]
31pub enum ImageRefError {
32 #[error("unrecognised URL scheme in {0:?} (expected file/hf/s3/ipfs/oci://)")]
33 BadScheme(String),
34 #[error("malformed {scheme} URL: {url:?} ({reason})")]
35 Malformed {
36 scheme: &'static str,
37 url: String,
38 reason: &'static str,
39 },
40}
41
42impl ImageRef {
43 pub fn parse(url: &str) -> Result<Self, ImageRefError> {
45 if let Some(rest) = url.strip_prefix("file://") {
46 return Ok(Self::File {
47 path: PathBuf::from(rest),
48 });
49 }
50 if let Some(rest) = url.strip_prefix("hf://") {
51 let (path, tag) = split_tag(rest);
52 let parts: Vec<&str> = path.splitn(2, '/').collect();
53 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
54 return Err(ImageRefError::Malformed {
55 scheme: "hf",
56 url: url.to_owned(),
57 reason: "expected hf://<user>/<repo>[:<tag>]",
58 });
59 }
60 return Ok(Self::Hf {
61 user: parts[0].to_owned(),
62 repo: parts[1].to_owned(),
63 tag,
64 });
65 }
66 if let Some(rest) = url.strip_prefix("s3://") {
67 let parts: Vec<&str> = rest.splitn(2, '/').collect();
68 if parts.is_empty() || parts[0].is_empty() {
69 return Err(ImageRefError::Malformed {
70 scheme: "s3",
71 url: url.to_owned(),
72 reason: "expected s3://<bucket>/<prefix>",
73 });
74 }
75 return Ok(Self::S3 {
76 bucket: parts[0].to_owned(),
77 prefix: parts.get(1).copied().unwrap_or("").to_owned(),
78 });
79 }
80 if let Some(rest) = url.strip_prefix("ipfs://") {
81 if rest.is_empty() {
82 return Err(ImageRefError::Malformed {
83 scheme: "ipfs",
84 url: url.to_owned(),
85 reason: "expected ipfs://<CID>",
86 });
87 }
88 return Ok(Self::Ipfs {
89 cid: rest.to_owned(),
90 });
91 }
92 if let Some(rest) = url.strip_prefix("oci://") {
93 let (path, tag) = split_tag(rest);
94 let (hostport, repo) = path.split_once('/').ok_or(ImageRefError::Malformed {
96 scheme: "oci",
97 url: url.to_owned(),
98 reason: "expected oci://<host>[:<port>]/<repo>[:<tag>]",
99 })?;
100 let (host, port) = if let Some((h, p)) = hostport.split_once(':') {
101 (
102 h.to_owned(),
103 Some(p.parse().map_err(|_| ImageRefError::Malformed {
104 scheme: "oci",
105 url: url.to_owned(),
106 reason: "port is not a u16",
107 })?),
108 )
109 } else {
110 (hostport.to_owned(), None)
111 };
112 return Ok(Self::Oci {
113 host,
114 port,
115 repo: repo.to_owned(),
116 tag,
117 });
118 }
119 Err(ImageRefError::BadScheme(url.to_owned()))
120 }
121}
122
123fn split_tag(s: &str) -> (&str, Option<String>) {
126 if let Some((path, tag)) = s.rsplit_once(':') {
127 if !tag.contains('/') {
130 return (path, Some(tag.to_owned()));
131 }
132 }
133 (s, None)
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn file_scheme() {
142 assert_eq!(
143 ImageRef::parse("file:///tmp/store").unwrap(),
144 ImageRef::File {
145 path: PathBuf::from("/tmp/store")
146 }
147 );
148 }
149
150 #[test]
151 fn hf_basic() {
152 assert_eq!(
153 ImageRef::parse("hf://alice/refactor-2026-05-05").unwrap(),
154 ImageRef::Hf {
155 user: "alice".into(),
156 repo: "refactor-2026-05-05".into(),
157 tag: None,
158 }
159 );
160 }
161
162 #[test]
163 fn hf_with_tag() {
164 assert_eq!(
165 ImageRef::parse("hf://alice/foo:v1").unwrap(),
166 ImageRef::Hf {
167 user: "alice".into(),
168 repo: "foo".into(),
169 tag: Some("v1".into()),
170 }
171 );
172 }
173
174 #[test]
175 fn s3_with_prefix() {
176 assert_eq!(
177 ImageRef::parse("s3://my-bucket/agents/refactor").unwrap(),
178 ImageRef::S3 {
179 bucket: "my-bucket".into(),
180 prefix: "agents/refactor".into(),
181 }
182 );
183 }
184
185 #[test]
186 fn ipfs_cid() {
187 assert_eq!(
188 ImageRef::parse("ipfs://bafyabc").unwrap(),
189 ImageRef::Ipfs {
190 cid: "bafyabc".into()
191 }
192 );
193 }
194
195 #[test]
196 fn oci_host_port_repo_tag() {
197 assert_eq!(
198 ImageRef::parse("oci://harbor.local:8080/myorg/img:v3").unwrap(),
199 ImageRef::Oci {
200 host: "harbor.local".into(),
201 port: Some(8080),
202 repo: "myorg/img".into(),
203 tag: Some("v3".into()),
204 }
205 );
206 }
207
208 #[test]
209 fn bad_scheme_errors() {
210 assert!(matches!(
211 ImageRef::parse("ftp://x").unwrap_err(),
212 ImageRefError::BadScheme(_)
213 ));
214 }
215
216 #[test]
217 fn hf_missing_repo() {
218 assert!(ImageRef::parse("hf://alice").is_err());
219 }
220}