Skip to main content

suno_core/
manifest.rs

1//! The on-disk manifest: the engine's record of prior download state.
2//!
3//! The manifest is the prior on the reconcile engine: it records, per clip id,
4//! where the file lives, its format, the content hashes used to detect tag and
5//! art drift, its size, and the state of each external sidecar artifact. The CLI
6//! loads and saves it; this module only models it and provides pure helpers. It
7//! is unversioned: serde round-trips it to a flat JSON object keyed by clip id
8//! with no envelope.
9
10use std::collections::BTreeMap;
11use std::collections::btree_map::Iter;
12
13use serde::{Deserialize, Serialize};
14
15use crate::config::AudioFormat;
16
17/// The prior known state of one external sidecar artifact for a clip.
18///
19/// Records where the sidecar lives and a hash of the content or source it was
20/// rendered from, so a later reconcile can detect drift and trigger a rewrite.
21#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(default)]
23pub struct ArtifactState {
24    /// Relative path of the sidecar file under the account root.
25    pub path: String,
26    /// Content/source change hash; a change triggers a rewrite.
27    pub hash: String,
28}
29
30/// One manifest record: the prior known state of a single downloaded clip.
31#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(default)]
33pub struct ManifestEntry {
34    /// Relative path of the audio file under the account root.
35    pub path: String,
36    /// Format the file was written in.
37    pub format: AudioFormat,
38    /// Hash of the clip's tag-bearing metadata, for detecting retag needs.
39    pub meta_hash: String,
40    /// Hash of the embedded cover art, for detecting art drift.
41    pub art_hash: String,
42    /// Size of the file in bytes when last written.
43    pub size: u64,
44    /// When set, this clip is held by a copy or archive source, or is private,
45    /// so it must never be deleted as an orphan no matter the current selection.
46    /// The caller writes this marker; the reconcile engine only reads it.
47    pub preserve: bool,
48    /// Prior state of the external `cover.jpg` sidecar, when one was written.
49    #[serde(default)]
50    pub cover_jpg: Option<ArtifactState>,
51    /// Prior state of the external `cover.webp` sidecar, when one was written.
52    #[serde(default)]
53    pub cover_webp: Option<ArtifactState>,
54}
55
56/// The full prior download state, keyed by clip id.
57///
58/// Backed by a [`BTreeMap`] so iteration order is stable, which keeps any plan
59/// derived from it deterministic.
60#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(transparent)]
62pub struct Manifest {
63    /// Records keyed by clip id.
64    pub entries: BTreeMap<String, ManifestEntry>,
65}
66
67impl Manifest {
68    /// Create an empty manifest.
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Return the entry for `clip_id`, if present.
74    pub fn get(&self, clip_id: &str) -> Option<&ManifestEntry> {
75        self.entries.get(clip_id)
76    }
77
78    /// Insert or replace the entry for `clip_id`, returning any prior value.
79    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    /// Remove and return the entry for `clip_id`, if present.
88    pub fn remove(&mut self, clip_id: &str) -> Option<ManifestEntry> {
89        self.entries.remove(clip_id)
90    }
91
92    /// Return true when an entry exists for `clip_id`.
93    pub fn contains(&self, clip_id: &str) -> bool {
94        self.entries.contains_key(clip_id)
95    }
96
97    /// Iterate entries in clip-id order.
98    pub fn iter(&self) -> Iter<'_, String, ManifestEntry> {
99        self.entries.iter()
100    }
101
102    /// Number of entries.
103    pub fn len(&self) -> usize {
104        self.entries.len()
105    }
106
107    /// True when there are no entries.
108    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        // An entry carrying both sidecar artifacts must round-trip intact.
188        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        // Top level is the clip-id map itself, with no envelope or version key.
209        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        // A record missing the format key falls back to the compiled default.
240        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        // Older manifests written before the marker existed must load as not
248        // preserved, so the field is purely additive.
249        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        // A pre-growth manifest, written before the sidecar fields existed, must
270        // load with no artifacts and unpreserved, proving the growth is purely
271        // additive and backwards compatible.
272        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}