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