use bollard::models::ImageInspect;
use chrono::{DateTime, FixedOffset};
use semver::Version;
const VERSION_LABEL: &str = "org.opencontainers.image.version";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ImageFacts {
id: String,
pub(crate) version: Option<String>,
created: Option<DateTime<FixedOffset>>,
}
impl ImageFacts {
pub(crate) fn from_inspect(inspect: &ImageInspect) -> Option<Self> {
let version = inspect
.config
.as_ref()
.and_then(|config| config.labels.as_ref())
.and_then(|labels| labels.get(VERSION_LABEL))
.filter(|version| !version.is_empty())
.cloned();
Some(Self {
id: inspect.id.clone()?,
version,
created: inspect
.created
.as_deref()
.and_then(|created| DateTime::parse_from_rfc3339(created).ok()),
})
}
pub(crate) fn short_id(&self) -> &str {
let id = self.id.strip_prefix("sha256:").unwrap_or(&self.id);
id.get(..12).unwrap_or(id)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Compare {
Version,
Created,
}
fn parse_version(version: &str) -> Option<Version> {
Version::parse(version.strip_prefix('v').unwrap_or(version)).ok()
}
pub(crate) fn is_newer(have: &ImageFacts, wanted: &ImageFacts, by: Compare) -> bool {
if have.id == wanted.id {
return false;
}
match by {
Compare::Version => {
let Some(wanted) = wanted.version.as_deref().and_then(parse_version) else {
return false;
};
match have.version.as_deref() {
None => true,
Some(have) => parse_version(have).is_some_and(|have| wanted > have),
}
}
Compare::Created => match (have.created, wanted.created) {
(Some(have), Some(wanted)) => wanted > have,
_ => false,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn facts(id: &str, version: Option<&str>, created: Option<&str>) -> ImageFacts {
ImageFacts {
id: id.to_owned(),
version: version.map(str::to_owned),
created: created.map(|created| DateTime::parse_from_rfc3339(created).unwrap()),
}
}
#[test]
fn release_builds_move_up_but_never_down() {
let have = facts("a", Some("0.2.0"), None);
let by = Compare::Version;
assert!(is_newer(&have, &facts("b", Some("0.2.1"), None), by));
assert!(is_newer(&have, &facts("b", Some("0.3.0-beta.1"), None), by));
assert!(!is_newer(&have, &facts("b", Some("0.2.0"), None), by));
assert!(!is_newer(&have, &facts("b", Some("0.1.9"), None), by));
assert!(is_newer(
&facts("a", Some("0.3.0-beta.1"), None),
&facts("b", Some("0.3.0"), None),
by
));
assert!(!is_newer(
&facts("a", Some("0.3.0"), None),
&facts("b", Some("0.3.0-beta.2"), None),
by
));
}
#[test]
fn a_missing_version_is_older_but_an_unreadable_one_is_left_alone() {
let wanted = facts("b", Some("0.1.0"), None);
assert!(is_newer(&facts("a", None, None), &wanted, Compare::Version));
assert!(!is_newer(
&facts("a", Some("garbage"), None),
&wanted,
Compare::Version
));
assert!(!is_newer(
&wanted,
&facts("a", None, None),
Compare::Version
));
}
#[test]
fn the_same_image_is_never_an_upgrade() {
let same = facts("a", Some("0.1.0"), Some("2026-10-03T10:00:00Z"));
assert!(!is_newer(&same, &same, Compare::Version));
assert!(!is_newer(&same, &same, Compare::Created));
}
#[test]
fn dev_builds_follow_build_time_not_image_id() {
let have = facts("a", None, Some("2026-10-03T10:00:00.5Z"));
assert!(is_newer(
&have,
&facts("b", None, Some("2026-10-03T10:00:00.75Z")),
Compare::Created
));
assert!(!is_newer(
&have,
&facts("b", None, Some("2026-10-03T09:59:59Z")),
Compare::Created
));
assert!(!is_newer(&have, &facts("b", None, None), Compare::Created));
}
}