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