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
68#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(transparent)]
74pub struct Manifest {
75 pub entries: BTreeMap<String, ManifestEntry>,
77}
78
79impl Manifest {
80 pub fn new() -> Self {
82 Self::default()
83 }
84
85 pub fn get(&self, clip_id: &str) -> Option<&ManifestEntry> {
87 self.entries.get(clip_id)
88 }
89
90 pub fn insert(
92 &mut self,
93 clip_id: impl Into<String>,
94 entry: ManifestEntry,
95 ) -> Option<ManifestEntry> {
96 self.entries.insert(clip_id.into(), entry)
97 }
98
99 pub fn remove(&mut self, clip_id: &str) -> Option<ManifestEntry> {
101 self.entries.remove(clip_id)
102 }
103
104 pub fn contains(&self, clip_id: &str) -> bool {
106 self.entries.contains_key(clip_id)
107 }
108
109 pub fn iter(&self) -> Iter<'_, String, ManifestEntry> {
111 self.entries.iter()
112 }
113
114 pub fn len(&self) -> usize {
116 self.entries.len()
117 }
118
119 pub fn is_empty(&self) -> bool {
121 self.entries.is_empty()
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
130 ManifestEntry {
131 path: path.to_string(),
132 format,
133 meta_hash: "m".to_string(),
134 art_hash: "a".to_string(),
135 size: 42,
136 preserve: false,
137 ..Default::default()
138 }
139 }
140
141 #[test]
142 fn new_is_empty() {
143 let m = Manifest::new();
144 assert!(m.is_empty());
145 assert_eq!(m.len(), 0);
146 }
147
148 #[test]
149 fn insert_get_contains() {
150 let mut m = Manifest::new();
151 assert!(m.insert("a", entry("a.flac", AudioFormat::Flac)).is_none());
152 assert!(m.contains("a"));
153 assert_eq!(m.get("a").unwrap().path, "a.flac");
154 assert_eq!(m.len(), 1);
155 assert!(!m.is_empty());
156 }
157
158 #[test]
159 fn insert_replaces_and_returns_prior() {
160 let mut m = Manifest::new();
161 m.insert("a", entry("a.flac", AudioFormat::Flac));
162 let prior = m.insert("a", entry("a.mp3", AudioFormat::Mp3));
163 assert_eq!(prior.unwrap().path, "a.flac");
164 assert_eq!(m.get("a").unwrap().format, AudioFormat::Mp3);
165 assert_eq!(m.len(), 1);
166 }
167
168 #[test]
169 fn remove_returns_prior_then_absent() {
170 let mut m = Manifest::new();
171 m.insert("a", entry("a.flac", AudioFormat::Flac));
172 let removed = m.remove("a");
173 assert_eq!(removed.unwrap().path, "a.flac");
174 assert!(!m.contains("a"));
175 assert!(m.remove("a").is_none());
176 }
177
178 #[test]
179 fn get_absent_is_none() {
180 let m = Manifest::new();
181 assert!(m.get("missing").is_none());
182 }
183
184 #[test]
185 fn iter_is_clip_id_sorted() {
186 let mut m = Manifest::new();
187 m.insert("c", entry("c.flac", AudioFormat::Flac));
188 m.insert("a", entry("a.flac", AudioFormat::Flac));
189 m.insert("b", entry("b.flac", AudioFormat::Flac));
190 let ids: Vec<&str> = m.iter().map(|(id, _)| id.as_str()).collect();
191 assert_eq!(ids, ["a", "b", "c"]);
192 }
193
194 #[test]
195 fn serde_roundtrip_preserves_entries() {
196 let mut m = Manifest::new();
197 m.insert("a", entry("a.flac", AudioFormat::Flac));
198 m.insert("b", entry("b.mp3", AudioFormat::Mp3));
199 let mut c = entry("c.flac", AudioFormat::Flac);
201 c.cover_jpg = Some(ArtifactState {
202 path: "c/cover.jpg".to_string(),
203 hash: "jpg-hash".to_string(),
204 });
205 c.cover_webp = Some(ArtifactState {
206 path: "c/cover.webp".to_string(),
207 hash: "webp-hash".to_string(),
208 });
209 c.details_txt = Some(ArtifactState {
210 path: "c.details.txt".to_string(),
211 hash: "details-hash".to_string(),
212 });
213 c.lyrics_txt = Some(ArtifactState {
214 path: "c.lyrics.txt".to_string(),
215 hash: "lyrics-hash".to_string(),
216 });
217 c.lrc = Some(ArtifactState {
218 path: "c.lrc".to_string(),
219 hash: "lrc-hash".to_string(),
220 });
221 m.insert("c", c);
222 let json = serde_json::to_string(&m).unwrap();
223 let back: Manifest = serde_json::from_str(&json).unwrap();
224 assert_eq!(m, back);
225 }
226
227 #[test]
228 fn serde_is_unversioned_flat_object() {
229 let mut m = Manifest::new();
230 m.insert("clip1", entry("song.flac", AudioFormat::Flac));
231 let value: serde_json::Value = serde_json::to_value(&m).unwrap();
232 assert!(value.is_object());
234 assert!(value.get("entries").is_none());
235 assert!(value.get("version").is_none());
236 let entry = value.get("clip1").unwrap();
237 assert_eq!(entry.get("format").unwrap(), "flac");
238 assert_eq!(entry.get("path").unwrap(), "song.flac");
239 }
240
241 #[test]
242 fn empty_manifest_roundtrips() {
243 let m = Manifest::new();
244 let json = serde_json::to_string(&m).unwrap();
245 assert_eq!(json, "{}");
246 let back: Manifest = serde_json::from_str(&json).unwrap();
247 assert!(back.is_empty());
248 }
249
250 #[test]
251 fn unicode_and_reserved_ids_roundtrip() {
252 let mut m = Manifest::new();
253 m.insert("ünïcode-🎵", entry("音楽.flac", AudioFormat::Flac));
254 m.insert("with\"quote", entry("a.flac", AudioFormat::Flac));
255 let json = serde_json::to_string(&m).unwrap();
256 let back: Manifest = serde_json::from_str(&json).unwrap();
257 assert_eq!(m, back);
258 assert!(back.contains("ünïcode-🎵"));
259 }
260
261 #[test]
262 fn default_format_deserialises_when_absent() {
263 let json = r#"{"clip1":{"path":"a.flac","meta_hash":"","art_hash":"","size":0}}"#;
265 let m: Manifest = serde_json::from_str(json).unwrap();
266 assert_eq!(m.get("clip1").unwrap().format, AudioFormat::default());
267 }
268
269 #[test]
270 fn preserve_defaults_to_false_when_absent() {
271 let json =
274 r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"","art_hash":"","size":1}}"#;
275 let m: Manifest = serde_json::from_str(json).unwrap();
276 assert!(!m.get("clip1").unwrap().preserve);
277 }
278
279 #[test]
280 fn preserve_roundtrips() {
281 let mut m = Manifest::new();
282 let mut e = entry("a.flac", AudioFormat::Flac);
283 e.preserve = true;
284 m.insert("a", e);
285 let json = serde_json::to_string(&m).unwrap();
286 let back: Manifest = serde_json::from_str(&json).unwrap();
287 assert!(back.get("a").unwrap().preserve);
288 assert_eq!(m, back);
289 }
290
291 #[test]
292 fn cover_artifacts_default_to_none_when_absent() {
293 let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
297 let m: Manifest = serde_json::from_str(json).unwrap();
298 let e = m.get("clip1").unwrap();
299 assert_eq!(e.cover_jpg, None);
300 assert_eq!(e.cover_webp, None);
301 assert_eq!(e.details_txt, None);
302 assert_eq!(e.lyrics_txt, None);
303 assert_eq!(e.lrc, None);
304 assert!(!e.preserve);
305 }
306
307 #[test]
308 fn artifact_state_defaults_and_roundtrips() {
309 let empty = ArtifactState::default();
310 assert_eq!(empty.path, "");
311 assert_eq!(empty.hash, "");
312 let json = serde_json::to_string(&empty).unwrap();
313 let back: ArtifactState = serde_json::from_str(&json).unwrap();
314 assert_eq!(empty, back);
315
316 let populated = ArtifactState {
317 path: "x/cover.webp".to_string(),
318 hash: "content-hash".to_string(),
319 };
320 let json = serde_json::to_string(&populated).unwrap();
321 let back: ArtifactState = serde_json::from_str(&json).unwrap();
322 assert_eq!(populated, back);
323 }
324}