use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct IsoSidecar {
pub display_name: Option<String>,
pub description: Option<String>,
pub version: Option<String>,
pub category: Option<String>,
pub last_verified_at: Option<String>,
pub last_verified_on: Option<String>,
pub notes: Option<String>,
}
impl IsoSidecar {
#[must_use]
pub fn is_empty(&self) -> bool {
self.display_name.is_none()
&& self.description.is_none()
&& self.version.is_none()
&& self.category.is_none()
&& self.last_verified_at.is_none()
&& self.last_verified_on.is_none()
&& self.notes.is_none()
}
}
#[must_use]
pub fn sidecar_path_for(iso_path: &Path) -> PathBuf {
let mut s = iso_path.as_os_str().to_owned();
s.push(".aegis.toml");
PathBuf::from(s)
}
#[derive(Debug, thiserror::Error)]
pub enum SidecarError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("invalid toml in {path}: {source}")]
InvalidToml {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("toml serialize: {0}")]
SerializeToml(#[from] toml::ser::Error),
}
pub fn load_sidecar(iso_path: &Path) -> Result<Option<IsoSidecar>, SidecarError> {
let path = sidecar_path_for(iso_path);
let body = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(SidecarError::Io(e)),
};
let sidecar: IsoSidecar =
toml::from_str(&body).map_err(|source| SidecarError::InvalidToml {
path: path.clone(),
source,
})?;
Ok(Some(sidecar))
}
pub fn to_toml(sidecar: &IsoSidecar) -> Result<String, SidecarError> {
Ok(toml::to_string_pretty(sidecar)?)
}
pub fn write_sidecar(iso_path: &Path, sidecar: &IsoSidecar) -> Result<PathBuf, SidecarError> {
let path = sidecar_path_for(iso_path);
let body = to_toml(sidecar)?;
fs::write(&path, body)?;
Ok(path)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::tempdir;
#[test]
fn sidecar_path_appends_double_extension() {
let p = sidecar_path_for(Path::new("/mnt/aegis-isos/debian.iso"));
assert_eq!(p, PathBuf::from("/mnt/aegis-isos/debian.iso.aegis.toml"));
}
#[test]
fn sidecar_path_works_with_no_extension() {
let p = sidecar_path_for(Path::new("/tmp/oddly-named-image"));
assert_eq!(p, PathBuf::from("/tmp/oddly-named-image.aegis.toml"));
}
#[test]
fn load_returns_none_when_no_sidecar_present() {
let dir = tempdir().unwrap();
let iso = dir.path().join("nothing.iso");
let result = load_sidecar(&iso).unwrap();
assert!(result.is_none());
}
#[test]
fn load_returns_populated_sidecar_when_present() {
let dir = tempdir().unwrap();
let iso = dir.path().join("debian.iso");
let sidecar_path = sidecar_path_for(&iso);
let body = r#"display_name = "Network-install Debian 12"
description = "Recommended for headless servers"
version = "12.5.0"
category = "install"
last_verified_at = "2026-02-18"
last_verified_on = "lenovo-thinkpad-t440p-tpm12"
notes = "Boots cleanly under Secure Boot via shim."
"#;
fs::write(&sidecar_path, body).unwrap();
let sidecar = load_sidecar(&iso).unwrap().unwrap();
assert_eq!(
sidecar.display_name.as_deref(),
Some("Network-install Debian 12")
);
assert_eq!(sidecar.version.as_deref(), Some("12.5.0"));
assert_eq!(sidecar.category.as_deref(), Some("install"));
assert_eq!(sidecar.last_verified_at.as_deref(), Some("2026-02-18"));
assert!(!sidecar.is_empty());
}
#[test]
fn load_returns_empty_sidecar_when_file_is_blank() {
let dir = tempdir().unwrap();
let iso = dir.path().join("blank.iso");
fs::write(sidecar_path_for(&iso), "").unwrap();
let sidecar = load_sidecar(&iso).unwrap().unwrap();
assert!(sidecar.is_empty());
}
#[test]
fn load_accepts_partial_sidecar_with_serde_defaults() {
let dir = tempdir().unwrap();
let iso = dir.path().join("partial.iso");
let body = "display_name = \"Just a name\"\n";
fs::write(sidecar_path_for(&iso), body).unwrap();
let sidecar = load_sidecar(&iso).unwrap().unwrap();
assert_eq!(sidecar.display_name.as_deref(), Some("Just a name"));
assert!(sidecar.description.is_none());
assert!(sidecar.version.is_none());
}
#[test]
fn load_rejects_malformed_toml() {
let dir = tempdir().unwrap();
let iso = dir.path().join("bad.iso");
fs::write(sidecar_path_for(&iso), "this is not = valid = toml\n").unwrap();
match load_sidecar(&iso) {
Err(SidecarError::InvalidToml { path, .. }) => {
assert_eq!(path, sidecar_path_for(&iso));
}
other => panic!("expected InvalidToml, got {other:?}"),
}
}
#[test]
fn load_rejects_unknown_top_level_keys_with_default_serde_strict_mode() {
let dir = tempdir().unwrap();
let iso = dir.path().join("future.iso");
let body = "display_name = \"x\"\nfuture_field = 42\n";
fs::write(sidecar_path_for(&iso), body).unwrap();
let sidecar = load_sidecar(&iso).unwrap().unwrap();
assert_eq!(sidecar.display_name.as_deref(), Some("x"));
}
#[test]
fn write_then_load_roundtrips_full_sidecar() {
let dir = tempdir().unwrap();
let iso = dir.path().join("roundtrip.iso");
let original = IsoSidecar {
display_name: Some("Network-install Debian 12".into()),
description: Some("Recommended for headless servers".into()),
version: Some("12.5.0".into()),
category: Some("install".into()),
last_verified_at: Some("2026-02-18".into()),
last_verified_on: Some("framework-laptop-12gen".into()),
notes: Some("Boots cleanly under Secure Boot via shim.".into()),
};
let written_path = write_sidecar(&iso, &original).unwrap();
assert_eq!(written_path, sidecar_path_for(&iso));
let loaded = load_sidecar(&iso).unwrap().unwrap();
assert_eq!(loaded, original);
}
#[test]
fn write_then_load_roundtrips_empty_sidecar() {
let dir = tempdir().unwrap();
let iso = dir.path().join("empty.iso");
let original = IsoSidecar::default();
write_sidecar(&iso, &original).unwrap();
let loaded = load_sidecar(&iso).unwrap().unwrap();
assert_eq!(loaded, original);
assert!(loaded.is_empty());
}
#[test]
fn is_empty_default_sidecar() {
let s = IsoSidecar::default();
assert!(s.is_empty());
}
#[test]
fn is_empty_false_when_any_field_populated() {
let s = IsoSidecar {
description: Some("just one field".into()),
..Default::default()
};
assert!(!s.is_empty());
}
#[test]
fn to_toml_omits_none_fields() {
let s = IsoSidecar {
display_name: Some("name".into()),
..Default::default()
};
let out = to_toml(&s).unwrap();
assert!(out.contains("display_name = \"name\""), "got: {out}");
assert!(!out.contains("description"), "got: {out}");
assert!(!out.contains("version"), "got: {out}");
}
}