use std::collections::BTreeMap;
use std::collections::btree_map::Iter;
use serde::{Deserialize, Serialize};
use crate::config::AudioFormat;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ArtifactState {
pub path: String,
pub hash: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ManifestEntry {
pub path: String,
pub format: AudioFormat,
pub meta_hash: String,
pub art_hash: String,
pub size: u64,
pub preserve: bool,
#[serde(default)]
pub cover_jpg: Option<ArtifactState>,
#[serde(default)]
pub cover_webp: Option<ArtifactState>,
#[serde(default)]
pub details_txt: Option<ArtifactState>,
#[serde(default)]
pub lyrics_txt: Option<ArtifactState>,
#[serde(default)]
pub lrc: Option<ArtifactState>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Manifest {
pub entries: BTreeMap<String, ManifestEntry>,
}
impl Manifest {
pub fn new() -> Self {
Self::default()
}
pub fn get(&self, clip_id: &str) -> Option<&ManifestEntry> {
self.entries.get(clip_id)
}
pub fn insert(
&mut self,
clip_id: impl Into<String>,
entry: ManifestEntry,
) -> Option<ManifestEntry> {
self.entries.insert(clip_id.into(), entry)
}
pub fn remove(&mut self, clip_id: &str) -> Option<ManifestEntry> {
self.entries.remove(clip_id)
}
pub fn contains(&self, clip_id: &str) -> bool {
self.entries.contains_key(clip_id)
}
pub fn iter(&self) -> Iter<'_, String, ManifestEntry> {
self.entries.iter()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
ManifestEntry {
path: path.to_string(),
format,
meta_hash: "m".to_string(),
art_hash: "a".to_string(),
size: 42,
preserve: false,
..Default::default()
}
}
#[test]
fn new_is_empty() {
let m = Manifest::new();
assert!(m.is_empty());
assert_eq!(m.len(), 0);
}
#[test]
fn insert_get_contains() {
let mut m = Manifest::new();
assert!(m.insert("a", entry("a.flac", AudioFormat::Flac)).is_none());
assert!(m.contains("a"));
assert_eq!(m.get("a").unwrap().path, "a.flac");
assert_eq!(m.len(), 1);
assert!(!m.is_empty());
}
#[test]
fn insert_replaces_and_returns_prior() {
let mut m = Manifest::new();
m.insert("a", entry("a.flac", AudioFormat::Flac));
let prior = m.insert("a", entry("a.mp3", AudioFormat::Mp3));
assert_eq!(prior.unwrap().path, "a.flac");
assert_eq!(m.get("a").unwrap().format, AudioFormat::Mp3);
assert_eq!(m.len(), 1);
}
#[test]
fn remove_returns_prior_then_absent() {
let mut m = Manifest::new();
m.insert("a", entry("a.flac", AudioFormat::Flac));
let removed = m.remove("a");
assert_eq!(removed.unwrap().path, "a.flac");
assert!(!m.contains("a"));
assert!(m.remove("a").is_none());
}
#[test]
fn get_absent_is_none() {
let m = Manifest::new();
assert!(m.get("missing").is_none());
}
#[test]
fn iter_is_clip_id_sorted() {
let mut m = Manifest::new();
m.insert("c", entry("c.flac", AudioFormat::Flac));
m.insert("a", entry("a.flac", AudioFormat::Flac));
m.insert("b", entry("b.flac", AudioFormat::Flac));
let ids: Vec<&str> = m.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, ["a", "b", "c"]);
}
#[test]
fn serde_roundtrip_preserves_entries() {
let mut m = Manifest::new();
m.insert("a", entry("a.flac", AudioFormat::Flac));
m.insert("b", entry("b.mp3", AudioFormat::Mp3));
let mut c = entry("c.flac", AudioFormat::Flac);
c.cover_jpg = Some(ArtifactState {
path: "c/cover.jpg".to_string(),
hash: "jpg-hash".to_string(),
});
c.cover_webp = Some(ArtifactState {
path: "c/cover.webp".to_string(),
hash: "webp-hash".to_string(),
});
c.details_txt = Some(ArtifactState {
path: "c.details.txt".to_string(),
hash: "details-hash".to_string(),
});
c.lyrics_txt = Some(ArtifactState {
path: "c.lyrics.txt".to_string(),
hash: "lyrics-hash".to_string(),
});
c.lrc = Some(ArtifactState {
path: "c.lrc".to_string(),
hash: "lrc-hash".to_string(),
});
m.insert("c", c);
let json = serde_json::to_string(&m).unwrap();
let back: Manifest = serde_json::from_str(&json).unwrap();
assert_eq!(m, back);
}
#[test]
fn serde_is_unversioned_flat_object() {
let mut m = Manifest::new();
m.insert("clip1", entry("song.flac", AudioFormat::Flac));
let value: serde_json::Value = serde_json::to_value(&m).unwrap();
assert!(value.is_object());
assert!(value.get("entries").is_none());
assert!(value.get("version").is_none());
let entry = value.get("clip1").unwrap();
assert_eq!(entry.get("format").unwrap(), "flac");
assert_eq!(entry.get("path").unwrap(), "song.flac");
}
#[test]
fn empty_manifest_roundtrips() {
let m = Manifest::new();
let json = serde_json::to_string(&m).unwrap();
assert_eq!(json, "{}");
let back: Manifest = serde_json::from_str(&json).unwrap();
assert!(back.is_empty());
}
#[test]
fn unicode_and_reserved_ids_roundtrip() {
let mut m = Manifest::new();
m.insert("ünïcode-🎵", entry("音楽.flac", AudioFormat::Flac));
m.insert("with\"quote", entry("a.flac", AudioFormat::Flac));
let json = serde_json::to_string(&m).unwrap();
let back: Manifest = serde_json::from_str(&json).unwrap();
assert_eq!(m, back);
assert!(back.contains("ünïcode-🎵"));
}
#[test]
fn default_format_deserialises_when_absent() {
let json = r#"{"clip1":{"path":"a.flac","meta_hash":"","art_hash":"","size":0}}"#;
let m: Manifest = serde_json::from_str(json).unwrap();
assert_eq!(m.get("clip1").unwrap().format, AudioFormat::default());
}
#[test]
fn preserve_defaults_to_false_when_absent() {
let json =
r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"","art_hash":"","size":1}}"#;
let m: Manifest = serde_json::from_str(json).unwrap();
assert!(!m.get("clip1").unwrap().preserve);
}
#[test]
fn preserve_roundtrips() {
let mut m = Manifest::new();
let mut e = entry("a.flac", AudioFormat::Flac);
e.preserve = true;
m.insert("a", e);
let json = serde_json::to_string(&m).unwrap();
let back: Manifest = serde_json::from_str(&json).unwrap();
assert!(back.get("a").unwrap().preserve);
assert_eq!(m, back);
}
#[test]
fn cover_artifacts_default_to_none_when_absent() {
let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
let m: Manifest = serde_json::from_str(json).unwrap();
let e = m.get("clip1").unwrap();
assert_eq!(e.cover_jpg, None);
assert_eq!(e.cover_webp, None);
assert_eq!(e.details_txt, None);
assert_eq!(e.lyrics_txt, None);
assert_eq!(e.lrc, None);
assert!(!e.preserve);
}
#[test]
fn artifact_state_defaults_and_roundtrips() {
let empty = ArtifactState::default();
assert_eq!(empty.path, "");
assert_eq!(empty.hash, "");
let json = serde_json::to_string(&empty).unwrap();
let back: ArtifactState = serde_json::from_str(&json).unwrap();
assert_eq!(empty, back);
let populated = ArtifactState {
path: "x/cover.webp".to_string(),
hash: "content-hash".to_string(),
};
let json = serde_json::to_string(&populated).unwrap();
let back: ArtifactState = serde_json::from_str(&json).unwrap();
assert_eq!(populated, back);
}
}