1use serde::Deserialize;
34use std::fs;
35use std::path::Path;
36use thiserror::Error;
37
38#[derive(Debug, Clone, Default, Deserialize)]
41pub struct RegistryConfig {
42 #[serde(default)]
43 pub registries: Vec<RegistryEntry>,
44}
45
46#[derive(Debug, Clone, Deserialize)]
52pub struct RegistryEntry {
53 pub name: String,
54 pub host: String,
55 #[serde(default)]
56 pub writable: bool,
57}
58
59#[derive(Debug, Error)]
62pub enum RegistryConfigError {
63 #[error("IO error reading {path}: {source}")]
64 Io {
65 path: String,
66 source: std::io::Error,
67 },
68 #[error("TOML parse error in {path}: {source}")]
69 Parse {
70 path: String,
71 source: toml::de::Error,
72 },
73}
74
75impl RegistryConfig {
76 pub fn load(qed_dir: &Path) -> Result<Self, RegistryConfigError> {
81 let path = qed_dir.join("registries.toml");
82 if !path.exists() {
83 return Ok(Self::default());
84 }
85 let src = fs::read_to_string(&path).map_err(|e| RegistryConfigError::Io {
86 path: path.display().to_string(),
87 source: e,
88 })?;
89 toml::from_str(&src).map_err(|e| RegistryConfigError::Parse {
90 path: path.display().to_string(),
91 source: e,
92 })
93 }
94
95 pub fn is_writable(&self, host: &str) -> bool {
97 self.registries.iter().any(|r| r.writable && r.host == host)
98 }
99}
100
101pub fn extract_registry_host(tag: &str) -> &str {
115 let first_segment = tag.split('/').next().unwrap_or(tag);
116 let has_dot = first_segment.contains('.');
120 let is_localhost = first_segment == "localhost" || first_segment.starts_with("localhost:");
121 let has_port_separator = first_segment.contains(':') && tag.contains('/');
122
123 if has_dot || is_localhost || has_port_separator {
124 first_segment
125 } else {
126 "docker.io"
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use tempfile::TempDir;
134
135 #[test]
136 fn extract_registry_host_recognises_ghcr() {
137 assert_eq!(
138 extract_registry_host("ghcr.io/yah-ai/yah-rust:dev"),
139 "ghcr.io"
140 );
141 }
142
143 #[test]
144 fn extract_registry_host_recognises_localhost_with_port() {
145 assert_eq!(
146 extract_registry_host("localhost:5000/yah-rust:dev"),
147 "localhost:5000",
148 );
149 }
150
151 #[test]
152 fn extract_registry_host_recognises_bare_localhost() {
153 assert_eq!(extract_registry_host("localhost/yah-rust:dev"), "localhost");
154 }
155
156 #[test]
157 fn extract_registry_host_falls_back_to_docker_io_for_bare_name() {
158 assert_eq!(extract_registry_host("nginx"), "docker.io");
159 assert_eq!(extract_registry_host("nginx:latest"), "docker.io");
160 assert_eq!(extract_registry_host("library/nginx:latest"), "docker.io");
161 assert_eq!(extract_registry_host("yah-rust:dev"), "docker.io");
162 }
163
164 #[test]
165 fn registry_config_load_missing_file_is_empty() {
166 let dir = TempDir::new().unwrap();
167 let cfg = RegistryConfig::load(dir.path()).unwrap();
168 assert!(cfg.registries.is_empty());
169 assert!(!cfg.is_writable("ghcr.io"));
170 }
171
172 #[test]
173 fn registry_config_load_parses_writable_entries() {
174 let dir = TempDir::new().unwrap();
175 std::fs::write(
176 dir.path().join("registries.toml"),
177 r#"
178[[registries]]
179name = "ghcr"
180host = "ghcr.io"
181writable = true
182
183[[registries]]
184name = "docker-hub"
185host = "docker.io"
186# writable omitted → defaults to false
187"#,
188 )
189 .unwrap();
190 let cfg = RegistryConfig::load(dir.path()).unwrap();
191 assert_eq!(cfg.registries.len(), 2);
192 assert!(cfg.is_writable("ghcr.io"));
193 assert!(
194 !cfg.is_writable("docker.io"),
195 "writable defaults to false; docker.io entry must not be considered writable"
196 );
197 assert!(!cfg.is_writable("nowhere.example"));
198 }
199
200 #[test]
201 fn registry_config_load_parses_bad_toml_as_error() {
202 let dir = TempDir::new().unwrap();
203 std::fs::write(dir.path().join("registries.toml"), "not = valid toml [[").unwrap();
204 let err = RegistryConfig::load(dir.path()).unwrap_err();
205 match err {
206 RegistryConfigError::Parse { .. } => {}
207 other => panic!("expected Parse, got {other:?}"),
208 }
209 }
210}