use serde::Deserialize;
use std::fs;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RegistryConfig {
#[serde(default)]
pub registries: Vec<RegistryEntry>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RegistryEntry {
pub name: String,
pub host: String,
#[serde(default)]
pub writable: bool,
}
#[derive(Debug, Error)]
pub enum RegistryConfigError {
#[error("IO error reading {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("TOML parse error in {path}: {source}")]
Parse {
path: String,
source: toml::de::Error,
},
}
impl RegistryConfig {
pub fn load(qed_dir: &Path) -> Result<Self, RegistryConfigError> {
let path = qed_dir.join("registries.toml");
if !path.exists() {
return Ok(Self::default());
}
let src = fs::read_to_string(&path).map_err(|e| RegistryConfigError::Io {
path: path.display().to_string(),
source: e,
})?;
toml::from_str(&src).map_err(|e| RegistryConfigError::Parse {
path: path.display().to_string(),
source: e,
})
}
pub fn is_writable(&self, host: &str) -> bool {
self.registries.iter().any(|r| r.writable && r.host == host)
}
}
pub fn extract_registry_host(tag: &str) -> &str {
let first_segment = tag.split('/').next().unwrap_or(tag);
let has_dot = first_segment.contains('.');
let is_localhost = first_segment == "localhost" || first_segment.starts_with("localhost:");
let has_port_separator = first_segment.contains(':') && tag.contains('/');
if has_dot || is_localhost || has_port_separator {
first_segment
} else {
"docker.io"
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn extract_registry_host_recognises_ghcr() {
assert_eq!(
extract_registry_host("ghcr.io/yah-ai/yah-rust:dev"),
"ghcr.io"
);
}
#[test]
fn extract_registry_host_recognises_localhost_with_port() {
assert_eq!(
extract_registry_host("localhost:5000/yah-rust:dev"),
"localhost:5000",
);
}
#[test]
fn extract_registry_host_recognises_bare_localhost() {
assert_eq!(extract_registry_host("localhost/yah-rust:dev"), "localhost");
}
#[test]
fn extract_registry_host_falls_back_to_docker_io_for_bare_name() {
assert_eq!(extract_registry_host("nginx"), "docker.io");
assert_eq!(extract_registry_host("nginx:latest"), "docker.io");
assert_eq!(extract_registry_host("library/nginx:latest"), "docker.io");
assert_eq!(extract_registry_host("yah-rust:dev"), "docker.io");
}
#[test]
fn registry_config_load_missing_file_is_empty() {
let dir = TempDir::new().unwrap();
let cfg = RegistryConfig::load(dir.path()).unwrap();
assert!(cfg.registries.is_empty());
assert!(!cfg.is_writable("ghcr.io"));
}
#[test]
fn registry_config_load_parses_writable_entries() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join("registries.toml"),
r#"
[[registries]]
name = "ghcr"
host = "ghcr.io"
writable = true
[[registries]]
name = "docker-hub"
host = "docker.io"
# writable omitted → defaults to false
"#,
)
.unwrap();
let cfg = RegistryConfig::load(dir.path()).unwrap();
assert_eq!(cfg.registries.len(), 2);
assert!(cfg.is_writable("ghcr.io"));
assert!(
!cfg.is_writable("docker.io"),
"writable defaults to false; docker.io entry must not be considered writable"
);
assert!(!cfg.is_writable("nowhere.example"));
}
#[test]
fn registry_config_load_parses_bad_toml_as_error() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("registries.toml"), "not = valid toml [[").unwrap();
let err = RegistryConfig::load(dir.path()).unwrap_err();
match err {
RegistryConfigError::Parse { .. } => {}
other => panic!("expected Parse, got {other:?}"),
}
}
}