1use std::fs;
32use std::path::{Path, PathBuf};
33
34use serde::{Deserialize, Serialize};
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
38#[serde(default)]
39pub struct IsoSidecar {
40 pub display_name: Option<String>,
42 pub description: Option<String>,
44 pub version: Option<String>,
46 pub category: Option<String>,
50 pub last_verified_at: Option<String>,
52 pub last_verified_on: Option<String>,
56 pub notes: Option<String>,
58}
59
60impl IsoSidecar {
61 #[must_use]
63 pub fn is_empty(&self) -> bool {
64 self.display_name.is_none()
65 && self.description.is_none()
66 && self.version.is_none()
67 && self.category.is_none()
68 && self.last_verified_at.is_none()
69 && self.last_verified_on.is_none()
70 && self.notes.is_none()
71 }
72}
73
74#[must_use]
79pub fn sidecar_path_for(iso_path: &Path) -> PathBuf {
80 let mut s = iso_path.as_os_str().to_owned();
81 s.push(".aegis.toml");
82 PathBuf::from(s)
83}
84
85#[derive(Debug, thiserror::Error)]
87pub enum SidecarError {
88 #[error("io: {0}")]
90 Io(#[from] std::io::Error),
91 #[error("invalid toml in {path}: {source}")]
93 InvalidToml {
94 path: PathBuf,
96 #[source]
98 source: toml::de::Error,
99 },
100 #[error("toml serialize: {0}")]
102 SerializeToml(#[from] toml::ser::Error),
103}
104
105pub fn load_sidecar(iso_path: &Path) -> Result<Option<IsoSidecar>, SidecarError> {
119 let path = sidecar_path_for(iso_path);
120 let body = match fs::read_to_string(&path) {
121 Ok(s) => s,
122 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
123 Err(e) => return Err(SidecarError::Io(e)),
124 };
125 let sidecar: IsoSidecar =
126 toml::from_str(&body).map_err(|source| SidecarError::InvalidToml {
127 path: path.clone(),
128 source,
129 })?;
130 Ok(Some(sidecar))
131}
132
133pub fn to_toml(sidecar: &IsoSidecar) -> Result<String, SidecarError> {
140 Ok(toml::to_string_pretty(sidecar)?)
141}
142
143pub fn write_sidecar(iso_path: &Path, sidecar: &IsoSidecar) -> Result<PathBuf, SidecarError> {
151 let path = sidecar_path_for(iso_path);
152 let body = to_toml(sidecar)?;
153 fs::write(&path, body)?;
154 Ok(path)
155}
156
157#[cfg(test)]
158#[allow(clippy::unwrap_used, clippy::expect_used)]
159mod tests {
160 use super::*;
161 use std::path::PathBuf;
162 use tempfile::tempdir;
163
164 #[test]
165 fn sidecar_path_appends_double_extension() {
166 let p = sidecar_path_for(Path::new("/mnt/aegis-isos/debian.iso"));
167 assert_eq!(p, PathBuf::from("/mnt/aegis-isos/debian.iso.aegis.toml"));
168 }
169
170 #[test]
171 fn sidecar_path_works_with_no_extension() {
172 let p = sidecar_path_for(Path::new("/tmp/oddly-named-image"));
173 assert_eq!(p, PathBuf::from("/tmp/oddly-named-image.aegis.toml"));
174 }
175
176 #[test]
177 fn load_returns_none_when_no_sidecar_present() {
178 let dir = tempdir().unwrap();
179 let iso = dir.path().join("nothing.iso");
180 let result = load_sidecar(&iso).unwrap();
181 assert!(result.is_none());
182 }
183
184 #[test]
185 fn load_returns_populated_sidecar_when_present() {
186 let dir = tempdir().unwrap();
187 let iso = dir.path().join("debian.iso");
188 let sidecar_path = sidecar_path_for(&iso);
189 let body = r#"display_name = "Network-install Debian 12"
190description = "Recommended for headless servers"
191version = "12.5.0"
192category = "install"
193last_verified_at = "2026-02-18"
194last_verified_on = "lenovo-thinkpad-t440p-tpm12"
195notes = "Boots cleanly under Secure Boot via shim."
196"#;
197 fs::write(&sidecar_path, body).unwrap();
198
199 let sidecar = load_sidecar(&iso).unwrap().unwrap();
200 assert_eq!(
201 sidecar.display_name.as_deref(),
202 Some("Network-install Debian 12")
203 );
204 assert_eq!(sidecar.version.as_deref(), Some("12.5.0"));
205 assert_eq!(sidecar.category.as_deref(), Some("install"));
206 assert_eq!(sidecar.last_verified_at.as_deref(), Some("2026-02-18"));
207 assert!(!sidecar.is_empty());
208 }
209
210 #[test]
211 fn load_returns_empty_sidecar_when_file_is_blank() {
212 let dir = tempdir().unwrap();
213 let iso = dir.path().join("blank.iso");
214 fs::write(sidecar_path_for(&iso), "").unwrap();
215
216 let sidecar = load_sidecar(&iso).unwrap().unwrap();
217 assert!(sidecar.is_empty());
218 }
219
220 #[test]
221 fn load_accepts_partial_sidecar_with_serde_defaults() {
222 let dir = tempdir().unwrap();
223 let iso = dir.path().join("partial.iso");
224 let body = "display_name = \"Just a name\"\n";
225 fs::write(sidecar_path_for(&iso), body).unwrap();
226
227 let sidecar = load_sidecar(&iso).unwrap().unwrap();
228 assert_eq!(sidecar.display_name.as_deref(), Some("Just a name"));
229 assert!(sidecar.description.is_none());
230 assert!(sidecar.version.is_none());
231 }
232
233 #[test]
234 fn load_rejects_malformed_toml() {
235 let dir = tempdir().unwrap();
236 let iso = dir.path().join("bad.iso");
237 fs::write(sidecar_path_for(&iso), "this is not = valid = toml\n").unwrap();
238
239 match load_sidecar(&iso) {
240 Err(SidecarError::InvalidToml { path, .. }) => {
241 assert_eq!(path, sidecar_path_for(&iso));
242 }
243 other => panic!("expected InvalidToml, got {other:?}"),
244 }
245 }
246
247 #[test]
248 fn load_rejects_unknown_top_level_keys_with_default_serde_strict_mode() {
249 let dir = tempdir().unwrap();
253 let iso = dir.path().join("future.iso");
254 let body = "display_name = \"x\"\nfuture_field = 42\n";
255 fs::write(sidecar_path_for(&iso), body).unwrap();
256 let sidecar = load_sidecar(&iso).unwrap().unwrap();
257 assert_eq!(sidecar.display_name.as_deref(), Some("x"));
258 }
259
260 #[test]
261 fn write_then_load_roundtrips_full_sidecar() {
262 let dir = tempdir().unwrap();
263 let iso = dir.path().join("roundtrip.iso");
264 let original = IsoSidecar {
265 display_name: Some("Network-install Debian 12".into()),
266 description: Some("Recommended for headless servers".into()),
267 version: Some("12.5.0".into()),
268 category: Some("install".into()),
269 last_verified_at: Some("2026-02-18".into()),
270 last_verified_on: Some("framework-laptop-12gen".into()),
271 notes: Some("Boots cleanly under Secure Boot via shim.".into()),
272 };
273
274 let written_path = write_sidecar(&iso, &original).unwrap();
275 assert_eq!(written_path, sidecar_path_for(&iso));
276
277 let loaded = load_sidecar(&iso).unwrap().unwrap();
278 assert_eq!(loaded, original);
279 }
280
281 #[test]
282 fn write_then_load_roundtrips_empty_sidecar() {
283 let dir = tempdir().unwrap();
284 let iso = dir.path().join("empty.iso");
285 let original = IsoSidecar::default();
286
287 write_sidecar(&iso, &original).unwrap();
288 let loaded = load_sidecar(&iso).unwrap().unwrap();
289 assert_eq!(loaded, original);
290 assert!(loaded.is_empty());
291 }
292
293 #[test]
294 fn is_empty_default_sidecar() {
295 let s = IsoSidecar::default();
296 assert!(s.is_empty());
297 }
298
299 #[test]
300 fn is_empty_false_when_any_field_populated() {
301 let s = IsoSidecar {
302 description: Some("just one field".into()),
303 ..Default::default()
304 };
305 assert!(!s.is_empty());
306 }
307
308 #[test]
309 fn to_toml_omits_none_fields() {
310 let s = IsoSidecar {
311 display_name: Some("name".into()),
312 ..Default::default()
313 };
314 let out = to_toml(&s).unwrap();
315 assert!(out.contains("display_name = \"name\""), "got: {out}");
316 assert!(!out.contains("description"), "got: {out}");
320 assert!(!out.contains("version"), "got: {out}");
321 }
322}