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