1use std::collections::BTreeMap;
11use std::collections::btree_map::Iter;
12
13use serde::{Deserialize, Serialize};
14
15use crate::config::AudioFormat;
16
17#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(default)]
23pub struct ArtifactState {
24 pub path: String,
26 pub hash: String,
28}
29
30#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(default)]
33pub struct ManifestEntry {
34 pub path: String,
36 pub format: AudioFormat,
38 pub meta_hash: String,
40 pub art_hash: String,
42 pub size: u64,
44 pub preserve: bool,
48 #[serde(default)]
50 pub cover_jpg: Option<ArtifactState>,
51 #[serde(default)]
53 pub cover_webp: Option<ArtifactState>,
54 #[serde(default)]
56 pub details_txt: Option<ArtifactState>,
57 #[serde(default)]
59 pub lyrics_txt: Option<ArtifactState>,
60 #[serde(default)]
62 pub lrc: Option<ArtifactState>,
63 #[serde(default)]
65 pub video_mp4: Option<ArtifactState>,
66}
67
68impl ManifestEntry {
69 pub(crate) fn artifact_paths(&self) -> impl Iterator<Item = &str> {
73 [
74 self.cover_jpg.as_ref(),
75 self.cover_webp.as_ref(),
76 self.details_txt.as_ref(),
77 self.lyrics_txt.as_ref(),
78 self.lrc.as_ref(),
79 self.video_mp4.as_ref(),
80 ]
81 .into_iter()
82 .flatten()
83 .map(|state| state.path.as_str())
84 }
85}
86
87#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(transparent)]
93pub struct Manifest {
94 pub entries: BTreeMap<String, ManifestEntry>,
96}
97
98impl Manifest {
99 pub fn new() -> Self {
101 Self::default()
102 }
103
104 pub fn get(&self, clip_id: &str) -> Option<&ManifestEntry> {
106 self.entries.get(clip_id)
107 }
108
109 pub fn insert(
111 &mut self,
112 clip_id: impl Into<String>,
113 entry: ManifestEntry,
114 ) -> Option<ManifestEntry> {
115 self.entries.insert(clip_id.into(), entry)
116 }
117
118 pub fn remove(&mut self, clip_id: &str) -> Option<ManifestEntry> {
120 self.entries.remove(clip_id)
121 }
122
123 pub fn contains(&self, clip_id: &str) -> bool {
125 self.entries.contains_key(clip_id)
126 }
127
128 pub fn iter(&self) -> Iter<'_, String, ManifestEntry> {
130 self.entries.iter()
131 }
132
133 pub fn len(&self) -> usize {
135 self.entries.len()
136 }
137
138 pub fn is_empty(&self) -> bool {
140 self.entries.is_empty()
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
149 ManifestEntry {
150 path: path.to_string(),
151 format,
152 meta_hash: "m".to_string(),
153 art_hash: "a".to_string(),
154 size: 42,
155 preserve: false,
156 ..Default::default()
157 }
158 }
159
160 #[test]
161 fn new_is_empty() {
162 let m = Manifest::new();
163 assert!(m.is_empty());
164 assert_eq!(m.len(), 0);
165 }
166
167 #[test]
168 fn insert_get_contains() {
169 let mut m = Manifest::new();
170 assert!(m.insert("a", entry("a.flac", AudioFormat::Flac)).is_none());
171 assert!(m.contains("a"));
172 assert_eq!(m.get("a").unwrap().path, "a.flac");
173 assert_eq!(m.len(), 1);
174 assert!(!m.is_empty());
175 }
176
177 #[test]
178 fn insert_replaces_and_returns_prior() {
179 let mut m = Manifest::new();
180 m.insert("a", entry("a.flac", AudioFormat::Flac));
181 let prior = m.insert("a", entry("a.mp3", AudioFormat::Mp3));
182 assert_eq!(prior.unwrap().path, "a.flac");
183 assert_eq!(m.get("a").unwrap().format, AudioFormat::Mp3);
184 assert_eq!(m.len(), 1);
185 }
186
187 #[test]
188 fn remove_returns_prior_then_absent() {
189 let mut m = Manifest::new();
190 m.insert("a", entry("a.flac", AudioFormat::Flac));
191 let removed = m.remove("a");
192 assert_eq!(removed.unwrap().path, "a.flac");
193 assert!(!m.contains("a"));
194 assert!(m.remove("a").is_none());
195 }
196
197 #[test]
198 fn get_absent_is_none() {
199 let m = Manifest::new();
200 assert!(m.get("missing").is_none());
201 }
202
203 #[test]
204 fn iter_is_clip_id_sorted() {
205 let mut m = Manifest::new();
206 m.insert("c", entry("c.flac", AudioFormat::Flac));
207 m.insert("a", entry("a.flac", AudioFormat::Flac));
208 m.insert("b", entry("b.flac", AudioFormat::Flac));
209 let ids: Vec<&str> = m.iter().map(|(id, _)| id.as_str()).collect();
210 assert_eq!(ids, ["a", "b", "c"]);
211 }
212
213 #[test]
214 fn serde_roundtrip_preserves_entries() {
215 let mut m = Manifest::new();
216 m.insert("a", entry("a.flac", AudioFormat::Flac));
217 m.insert("b", entry("b.mp3", AudioFormat::Mp3));
218 let mut c = entry("c.flac", AudioFormat::Flac);
220 c.cover_jpg = Some(ArtifactState {
221 path: "c/cover.jpg".to_string(),
222 hash: "jpg-hash".to_string(),
223 });
224 c.cover_webp = Some(ArtifactState {
225 path: "c/cover.webp".to_string(),
226 hash: "webp-hash".to_string(),
227 });
228 c.details_txt = Some(ArtifactState {
229 path: "c.details.txt".to_string(),
230 hash: "details-hash".to_string(),
231 });
232 c.lyrics_txt = Some(ArtifactState {
233 path: "c.lyrics.txt".to_string(),
234 hash: "lyrics-hash".to_string(),
235 });
236 c.lrc = Some(ArtifactState {
237 path: "c.lrc".to_string(),
238 hash: "lrc-hash".to_string(),
239 });
240 m.insert("c", c);
241 let json = serde_json::to_string(&m).unwrap();
242 let back: Manifest = serde_json::from_str(&json).unwrap();
243 assert_eq!(m, back);
244 }
245
246 #[test]
247 fn serde_is_unversioned_flat_object() {
248 let mut m = Manifest::new();
249 m.insert("clip1", entry("song.flac", AudioFormat::Flac));
250 let value: serde_json::Value = serde_json::to_value(&m).unwrap();
251 assert!(value.is_object());
253 assert!(value.get("entries").is_none());
254 assert!(value.get("version").is_none());
255 let entry = value.get("clip1").unwrap();
256 assert_eq!(entry.get("format").unwrap(), "flac");
257 assert_eq!(entry.get("path").unwrap(), "song.flac");
258 }
259
260 #[test]
261 fn empty_manifest_roundtrips() {
262 let m = Manifest::new();
263 let json = serde_json::to_string(&m).unwrap();
264 assert_eq!(json, "{}");
265 let back: Manifest = serde_json::from_str(&json).unwrap();
266 assert!(back.is_empty());
267 }
268
269 #[test]
270 fn unicode_and_reserved_ids_roundtrip() {
271 let mut m = Manifest::new();
272 m.insert("ünïcode-🎵", entry("音楽.flac", AudioFormat::Flac));
273 m.insert("with\"quote", entry("a.flac", AudioFormat::Flac));
274 let json = serde_json::to_string(&m).unwrap();
275 let back: Manifest = serde_json::from_str(&json).unwrap();
276 assert_eq!(m, back);
277 assert!(back.contains("ünïcode-🎵"));
278 }
279
280 #[test]
281 fn default_format_deserialises_when_absent() {
282 let json = r#"{"clip1":{"path":"a.flac","meta_hash":"","art_hash":"","size":0}}"#;
284 let m: Manifest = serde_json::from_str(json).unwrap();
285 assert_eq!(m.get("clip1").unwrap().format, AudioFormat::default());
286 }
287
288 #[test]
289 fn preserve_defaults_to_false_when_absent() {
290 let json =
293 r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"","art_hash":"","size":1}}"#;
294 let m: Manifest = serde_json::from_str(json).unwrap();
295 assert!(!m.get("clip1").unwrap().preserve);
296 }
297
298 #[test]
299 fn preserve_roundtrips() {
300 let mut m = Manifest::new();
301 let mut e = entry("a.flac", AudioFormat::Flac);
302 e.preserve = true;
303 m.insert("a", e);
304 let json = serde_json::to_string(&m).unwrap();
305 let back: Manifest = serde_json::from_str(&json).unwrap();
306 assert!(back.get("a").unwrap().preserve);
307 assert_eq!(m, back);
308 }
309
310 #[test]
311 fn cover_artifacts_default_to_none_when_absent() {
312 let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
316 let m: Manifest = serde_json::from_str(json).unwrap();
317 let e = m.get("clip1").unwrap();
318 assert_eq!(e.cover_jpg, None);
319 assert_eq!(e.cover_webp, None);
320 assert_eq!(e.details_txt, None);
321 assert_eq!(e.lyrics_txt, None);
322 assert_eq!(e.lrc, None);
323 assert!(!e.preserve);
324 }
325
326 #[test]
327 fn artifact_state_defaults_and_roundtrips() {
328 let empty = ArtifactState::default();
329 assert_eq!(empty.path, "");
330 assert_eq!(empty.hash, "");
331 let json = serde_json::to_string(&empty).unwrap();
332 let back: ArtifactState = serde_json::from_str(&json).unwrap();
333 assert_eq!(empty, back);
334
335 let populated = ArtifactState {
336 path: "x/cover.webp".to_string(),
337 hash: "content-hash".to_string(),
338 };
339 let json = serde_json::to_string(&populated).unwrap();
340 let back: ArtifactState = serde_json::from_str(&json).unwrap();
341 assert_eq!(populated, back);
342 }
343}