use super::{AudioSnapshot, ImageSnapshot};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Result;
use std::path::Path;
#[derive(Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct FileSnapshot {
pub directory: Option<String>,
pub filename: String,
pub size: u64,
pub sha256: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<AudioSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<ImageSnapshot>,
}
impl FileSnapshot {
pub fn from_path(root: &Path, path: &Path) -> Result<Self> {
let relative = path.strip_prefix(root).expect("path should be under root");
let directory = relative.parent().and_then(|p| {
let s = p.to_string_lossy().to_string();
if s.is_empty() { None } else { Some(s) }
});
let filename = relative
.file_name()
.expect("path should have filename")
.to_string_lossy()
.to_string();
let ext = get_extension(path);
let content = fs::read(path)?;
let size = u64::try_from(content.len()).expect("file size fits in u64");
Ok(Self {
directory,
filename,
size,
sha256: format!("{:x}", Sha256::digest(&content)),
audio: parse_audio_metadata(path, ext.as_deref()),
image: parse_image_metadata(path, ext.as_deref()),
})
}
}
fn get_extension(path: &Path) -> Option<String> {
path.extension()
.and_then(|e| e.to_str())
.map(str::to_lowercase)
}
fn parse_audio_metadata(path: &Path, ext: Option<&str>) -> Option<AudioSnapshot> {
match ext {
Some("mp3" | "flac") => AudioSnapshot::from_path(path),
_ => None,
}
}
fn parse_image_metadata(path: &Path, ext: Option<&str>) -> Option<ImageSnapshot> {
match ext {
Some("png" | "jpg" | "jpeg" | "gif" | "webp") => ImageSnapshot::from_path(path),
_ => None,
}
}
fn read_snap_body(snap_path: &Path) -> Option<String> {
let content = fs::read_to_string(snap_path).ok()?;
content.splitn(3, "---\n").nth(2).map(String::from)
}
pub fn patch_platform_dependent_fields(files: &mut [FileSnapshot], snap_path: &Path) {
let Some(yaml_body) = read_snap_body(snap_path) else {
return;
};
let Ok(stored) = serde_yaml::from_str::<Vec<FileSnapshot>>(&yaml_body) else {
return;
};
for (actual, stored) in files.iter_mut().zip(stored.iter()) {
actual.sha256.clone_from(&stored.sha256);
actual.size = stored.size;
if let (Some(actual_audio), Some(stored_audio)) = (&mut actual.audio, &stored.audio) {
actual_audio.overall_bitrate = stored_audio.overall_bitrate;
actual_audio.audio_bitrate = stored_audio.audio_bitrate;
for (actual_pic, stored_pic) in actual_audio
.pictures
.iter_mut()
.zip(stored_audio.pictures.iter())
{
actual_pic.sha256.clone_from(&stored_pic.sha256);
actual_pic.size = stored_pic.size;
}
}
}
}
#[expect(clippy::panic, reason = "test assertion that should panic on failure")]
pub fn assert_line_count(actual: &str, snap_path: &Path) {
let Some(stored) = read_snap_body(snap_path) else {
panic!("stored snapshot not found at {}", snap_path.display());
};
let expected = stored.lines().count();
let got = actual.lines().count();
assert_eq!(
got, expected,
"inspect output should have {expected} lines but had {got}"
);
}