use serde::{Deserialize, Serialize};
use crate::error::{OciError, Result};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct OciRef {
pub registry: String,
pub repository: String,
pub tag: Option<String>,
pub digest: Option<String>,
}
impl OciRef {
pub fn reference(&self) -> String {
let base = format!("{}/{}", self.registry, self.repository);
if let Some(digest) = &self.digest {
format!("{base}@{digest}")
} else if let Some(tag) = &self.tag {
format!("{base}:{tag}")
} else {
format!("{base}:latest")
}
}
pub fn repository_path(&self) -> String {
format!("{}/{}", self.registry, self.repository)
}
pub fn with_tag(&self, tag: &str) -> Self {
Self {
registry: self.registry.clone(),
repository: self.repository.clone(),
tag: Some(tag.to_string()),
digest: None,
}
}
pub fn with_digest(&self, digest: &str) -> Self {
Self {
registry: self.registry.clone(),
repository: self.repository.clone(),
tag: None,
digest: Some(digest.to_string()),
}
}
}
impl std::fmt::Display for OciRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.reference())
}
}
pub fn parse_image_ref(input: &str) -> Result<OciRef> {
let input = input.trim();
if input.is_empty() {
return Err(OciError::InvalidRef("empty image reference".into()));
}
let (without_digest, digest) = if let Some((left, dig)) = input.split_once('@') {
let dig = dig.trim();
if dig.is_empty() {
return Err(OciError::InvalidRef("digest after @ is empty".into()));
}
(left, Some(dig.to_string()))
} else {
(input, None)
};
let (path, tag) = if digest.is_some() {
(without_digest, None)
} else if let Some((left, tag)) = without_digest.rsplit_once(':') {
if tag.contains('/') {
(without_digest, None)
} else {
(left, Some(tag.to_string()))
}
} else {
(without_digest, None)
};
let path = path.trim_start_matches('/').trim();
if path.is_empty() {
return Err(OciError::InvalidRef("empty repository path".into()));
}
let (registry, repository) = split_registry_repo(path)?;
Ok(OciRef {
registry,
repository,
tag: tag.filter(|t| !t.is_empty()),
digest,
})
}
fn split_registry_repo(path: &str) -> Result<(String, String)> {
let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
if parts.is_empty() {
return Err(OciError::InvalidRef("empty repository path".into()));
}
if parts.len() == 1 {
return Ok((
"docker.io".to_string(),
format!("library/{}", parts[0]),
));
}
let first = parts[0];
let looks_like_registry = first.contains('.') || first.contains(':') || first == "localhost";
if looks_like_registry {
let repository = parts[1..].join("/");
if repository.is_empty() {
return Err(OciError::InvalidRef("empty repository".into()));
}
return Ok((first.to_string(), repository));
}
Ok(("docker.io".to_string(), parts.join("/")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ghcr_tag() {
let r = parse_image_ref("ghcr.io/xylex-group/athena:4.0.1").unwrap();
assert_eq!(r.registry, "ghcr.io");
assert_eq!(r.repository, "xylex-group/athena");
assert_eq!(r.tag.as_deref(), Some("4.0.1"));
}
#[test]
fn parses_digest() {
let r = parse_image_ref("ghcr.io/xylex-group/athena@sha256:abc").unwrap();
assert_eq!(r.digest.as_deref(), Some("sha256:abc"));
}
#[test]
fn parses_registry_port() {
let r = parse_image_ref("localhost:5000/my/app:dev").unwrap();
assert_eq!(r.registry, "localhost:5000");
assert_eq!(r.repository, "my/app");
}
}