Skip to main content

suno_core/
graph.rs

1//! The durable lineage graph store: a relational archive of clips, their parent
2//! edges, and cached root resolutions.
3//!
4//! This is a pure serde type with no IO of its own; the CLI persists it beside
5//! the library (mirroring the manifest). The shape is deliberately relational —
6//! separate `nodes`, `edges`, and `resolution_cache` collections rather than an
7//! adjacency blob per clip — so it migrates cleanly to SQLite later. A root's
8//! title is read from its node, never copied into every row where it would go
9//! stale.
10//!
11//! [`LineageStore::update`] is the only mutator: given the clips seen this run
12//! and their [`Resolution`], it upserts nodes and edges and refreshes the
13//! resolution cache. The store takes the wall clock as a `now` string from the
14//! caller so it stays free of IO. The cache is monotonic (HARDENING H3): a
15//! resolved root is never downgraded by a later transient miss. Gap-filled
16//! (often trashed) ancestors are persisted as nodes so lineage survives Suno's
17//! ~30-day trash purge.
18
19use std::collections::btree_map::Iter;
20use std::collections::{BTreeMap, BTreeSet};
21
22use serde::{Deserialize, Serialize};
23
24use crate::lineage::{
25    Edge, EdgeRole, EdgeType, LineageContext, Resolution, ResolveStatus, RootInfo,
26    immediate_parent, lineage_edges,
27};
28use crate::manifest::ArtifactState;
29use crate::model::Clip;
30use crate::reconcile::ArtifactKind;
31
32/// The whole lineage graph, kept relational for a clean SQLite migration.
33///
34/// `nodes` and `resolution_cache` are [`BTreeMap`]s and `edges` is sorted after
35/// every [`update`](LineageStore::update), so serialisation is deterministic.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37#[serde(default)]
38pub struct LineageStore {
39    /// On-disk schema version, so a future migration can branch on it.
40    pub schema_version: u32,
41    /// Every clip ever seen (including trashed ancestors), keyed by clip id.
42    pub nodes: BTreeMap<String, Node>,
43    /// Every observed parent link, as a flat relational list.
44    pub edges: Vec<StoredEdge>,
45    /// The last resolved (or last-known) root per clip, keyed by clip id.
46    pub resolution_cache: BTreeMap<String, CacheEntry>,
47    /// The reconciled folder-art state per album, keyed by the album's stable
48    /// root id (HARDENING H2). Additive: absent in older stores, defaults empty.
49    pub albums: BTreeMap<String, AlbumArt>,
50    /// The reconciled `.m3u8` state per playlist, keyed by the playlist's Suno
51    /// id (the synthetic `"liked"` id for the liked feed). Additive: absent in
52    /// older stores, defaults empty.
53    pub playlists: BTreeMap<String, PlaylistState>,
54}
55
56impl Default for LineageStore {
57    fn default() -> Self {
58        Self {
59            schema_version: 1,
60            nodes: BTreeMap::new(),
61            edges: Vec::new(),
62            resolution_cache: BTreeMap::new(),
63            albums: BTreeMap::new(),
64            playlists: BTreeMap::new(),
65        }
66    }
67}
68
69/// The reconciled folder-art state for one album (one stable root id).
70///
71/// Folder art is album-scoped, not per-clip, so it lives here rather than on a
72/// [`ManifestEntry`](crate::manifest::ManifestEntry). Each slot records the
73/// sidecar's path and the content hash of the art it was rendered from, so a
74/// later reconcile rewrites only on a genuine content change (HARDENING H1: a
75/// most-played flip that yields the same art hash is a no-op). Kept relational
76/// (two explicit slots) so it migrates cleanly to a SQLite `album_art` table.
77#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(default)]
79pub struct AlbumArt {
80    /// The album's static `folder.jpg`, sourced from the most-played variant.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub folder_jpg: Option<ArtifactState>,
83    /// The album's animated `cover.webp`, from the first-created animated variant.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub folder_webp: Option<ArtifactState>,
86}
87
88impl AlbumArt {
89    /// The stored state for one folder-art `kind`, if present. Per-clip and
90    /// library kinds have no album slot and map to `None`.
91    pub fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
92        match kind {
93            ArtifactKind::FolderJpg => self.folder_jpg.as_ref(),
94            ArtifactKind::FolderWebp => self.folder_webp.as_ref(),
95            ArtifactKind::CoverJpg | ArtifactKind::CoverWebp | ArtifactKind::Playlist => None,
96        }
97    }
98
99    /// Set (or clear, with `None`) the state for one folder-art `kind`.
100    ///
101    /// The executor calls this after a folder-art write (with the new state) or
102    /// delete (with `None`), so the kind-to-slot mapping lives in one place.
103    /// Non-album kinds have no slot here and are no-ops.
104    pub fn set(&mut self, kind: ArtifactKind, state: Option<ArtifactState>) {
105        match kind {
106            ArtifactKind::FolderJpg => self.folder_jpg = state,
107            ArtifactKind::FolderWebp => self.folder_webp = state,
108            ArtifactKind::CoverJpg | ArtifactKind::CoverWebp | ArtifactKind::Playlist => {}
109        }
110    }
111
112    /// True when the album holds no folder art at all (both slots empty), so the
113    /// store can prune the now-dead album row.
114    pub fn is_empty(&self) -> bool {
115        self.folder_jpg.is_none() && self.folder_webp.is_none()
116    }
117}
118
119/// The reconciled `.m3u8` state for one playlist.
120///
121/// A playlist's body is *generated*, not fetched, so unlike per-clip artifacts
122/// its change detection is a single content hash over the full rendered text
123/// (HARDENING B1: name, order, and every member's path/title/duration feed it).
124/// The `path` is the sidecar's library-relative location, tracked so a rename
125/// (a playlist renamed on Suno) is detected and the old file removed. Kept as a
126/// flat row so it migrates cleanly to a SQLite `playlists` table.
127#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(default)]
129pub struct PlaylistState {
130    /// The playlist's display name at the time it was last written.
131    pub name: String,
132    /// The `.m3u8` file's library-relative path (`<sanitised name>.m3u8`).
133    pub path: String,
134    /// The content hash of the rendered `.m3u8` this row was written from.
135    pub hash: String,
136}
137
138/// One clip in the graph. Mirrors the fields lineage needs to survive a purge:
139/// enough to name and date the clip long after Suno deletes it.
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(default)]
142pub struct Node {
143    pub title: String,
144    pub created_at: String,
145    pub clip_type: String,
146    pub task: String,
147    pub is_remix: bool,
148    pub is_trashed: bool,
149    /// Lifecycle marker; `"observed"` for a clip seen from the feed or gap-fill.
150    pub status: String,
151    pub first_seen_at: String,
152    pub last_seen_at: String,
153}
154
155impl Default for Node {
156    fn default() -> Self {
157        Self {
158            title: String::new(),
159            created_at: String::new(),
160            clip_type: String::new(),
161            task: String::new(),
162            is_remix: false,
163            is_trashed: false,
164            status: "observed".to_owned(),
165            first_seen_at: String::new(),
166            last_seen_at: String::new(),
167        }
168    }
169}
170
171/// One parent link, keyed (for upsert) by `(child_id, parent_id, edge_type,
172/// role, ordinal)`. A flat row, not nested under its child, so it maps directly
173/// to a `lineage_edges` table.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175#[serde(default)]
176pub struct StoredEdge {
177    pub child_id: String,
178    pub parent_id: String,
179    /// Stable lowercase slug, e.g. `"cover"`, `"remaster"`, `"section_replace"`.
180    pub edge_type: String,
181    /// `"primary"` for the rooting parent, `"secondary"` for extra sources.
182    pub role: String,
183    /// The clip field the parent id was read from, e.g. `"cover_clip_id"`.
184    pub source_field: String,
185    /// Position within its role (0 for the primary, then secondaries in order).
186    pub ordinal: u32,
187    /// Lifecycle marker; `"active"` for an edge observed this run.
188    pub status: String,
189    pub first_seen_at: String,
190    pub last_seen_at: String,
191}
192
193impl Default for StoredEdge {
194    fn default() -> Self {
195        Self {
196            child_id: String::new(),
197            parent_id: String::new(),
198            edge_type: String::new(),
199            role: String::new(),
200            source_field: String::new(),
201            ordinal: 0,
202            status: "active".to_owned(),
203            first_seen_at: String::new(),
204            last_seen_at: String::new(),
205        }
206    }
207}
208
209/// A cached root resolution for one clip: the O(1) album lookup, kept monotonic.
210#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
211#[serde(default)]
212pub struct CacheEntry {
213    pub root_id: String,
214    /// `"resolved"`, or a slug of the terminal status (`"external"`, …).
215    pub status: String,
216    pub algorithm_version: u32,
217    pub computed_at: String,
218}
219
220impl LineageStore {
221    /// Create an empty store at the current schema version.
222    pub fn new() -> Self {
223        Self::default()
224    }
225
226    /// The node for `id`, if present.
227    pub fn node(&self, id: &str) -> Option<&Node> {
228        self.nodes.get(id)
229    }
230
231    /// The cached root resolution for `id`, if present.
232    pub fn get_root(&self, id: &str) -> Option<&CacheEntry> {
233        self.resolution_cache.get(id)
234    }
235
236    /// The reconciled folder-art state for the album rooted at `root_id`.
237    pub fn album_art(&self, root_id: &str) -> Option<&AlbumArt> {
238        self.albums.get(root_id)
239    }
240
241    /// Set (or clear, with `None`) one folder-art `kind` for the album rooted at
242    /// `root_id`.
243    ///
244    /// A set upserts the album row; a clear that empties the row removes it, so
245    /// the store never accumulates dead all-`None` album entries. This is the
246    /// store-level counterpart the CLI persists after the executor mutates the
247    /// [`albums`](Self::albums) map in place.
248    pub fn set_album_artifact(
249        &mut self,
250        root_id: &str,
251        kind: ArtifactKind,
252        state: Option<ArtifactState>,
253    ) {
254        match state {
255            Some(state) => self
256                .albums
257                .entry(root_id.to_owned())
258                .or_default()
259                .set(kind, Some(state)),
260            None => {
261                if let Some(art) = self.albums.get_mut(root_id) {
262                    art.set(kind, None);
263                    if art.is_empty() {
264                        self.albums.remove(root_id);
265                    }
266                }
267            }
268        }
269    }
270
271    /// The reconciled `.m3u8` state for the playlist with `id`, if present.
272    pub fn playlist(&self, id: &str) -> Option<&PlaylistState> {
273        self.playlists.get(id)
274    }
275
276    /// Upsert (with `Some`) or remove (with `None`) the `.m3u8` state for the
277    /// playlist `id`.
278    ///
279    /// This is the store-level counterpart the CLI persists after the executor
280    /// mutates the [`playlists`](Self::playlists) map in place: a write records
281    /// the new state; a delete clears the row so the store never keeps a
282    /// dangling entry for a playlist whose file was removed.
283    pub fn set_playlist(&mut self, id: &str, state: Option<PlaylistState>) {
284        match state {
285            Some(state) => {
286                self.playlists.insert(id.to_owned(), state);
287            }
288            None => {
289                self.playlists.remove(id);
290            }
291        }
292    }
293
294    /// Build a [`LineageContext`] for `clip` from the durable store.
295    ///
296    /// This is the source of truth for every file-affecting lineage decision
297    /// (album folder, embedded tags, the change hash), so a dropped resolution
298    /// call never rewrites the library (HARDENING H3). The root comes from the
299    /// monotonic resolution cache (the clip's own id when the store has no
300    /// better answer) and the root title from that root's archived node, so a
301    /// transient miss keeps the last-known-good album even for a since-purged
302    /// ancestor. The parent edge is read structurally from the clip itself.
303    pub fn context_for(&self, clip: &Clip) -> LineageContext {
304        let cached = self.get_root(&clip.id);
305        let root_id = cached
306            .map(|entry| entry.root_id.clone())
307            .filter(|id| !id.is_empty())
308            .unwrap_or_else(|| clip.id.clone());
309        let root_title = self
310            .node(&root_id)
311            .map(|node| node.title.clone())
312            .unwrap_or_else(|| clip.title.clone());
313        let (parent_id, edge_type) = match immediate_parent(clip) {
314            Some((id, edge)) => (id, Some(edge)),
315            None => (String::new(), None),
316        };
317        let status = cached
318            .map(|entry| status_from_slug(&entry.status))
319            .unwrap_or(ResolveStatus::Resolved);
320        LineageContext {
321            root_id,
322            root_title,
323            parent_id,
324            edge_type,
325            status,
326        }
327    }
328
329    /// The set of root titles shared by more than one distinct root.
330    ///
331    /// Two distinct roots must never share an album folder (two different
332    /// uploads titled "Break Through" exist), so naming appends the short root
333    /// id to the album of any clip whose root title is in this set. It is
334    /// computed from the whole archive — every distinct root in the resolution
335    /// cache paired with its node title — so the decision is stable across runs
336    /// and independent of the current batch: a `--since`/`--limit` slice that
337    /// shows only one of two same-titled roots still disambiguates, instead of
338    /// oscillating between a bare and a suffixed folder.
339    pub fn colliding_root_titles(&self) -> BTreeSet<String> {
340        let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
341        for entry in self.resolution_cache.values() {
342            if entry.root_id.is_empty() {
343                continue;
344            }
345            let Some(node) = self.nodes.get(&entry.root_id) else {
346                continue;
347            };
348            let title = node.title.trim();
349            if title.is_empty() {
350                continue;
351            }
352            roots_by_title
353                .entry(title.to_owned())
354                .or_default()
355                .insert(entry.root_id.clone());
356        }
357        roots_by_title
358            .into_iter()
359            .filter(|(_, roots)| roots.len() > 1)
360            .map(|(title, _)| title)
361            .collect()
362    }
363
364    /// Number of nodes in the graph.
365    pub fn len(&self) -> usize {
366        self.nodes.len()
367    }
368
369    /// True when the graph holds no nodes.
370    pub fn is_empty(&self) -> bool {
371        self.nodes.is_empty()
372    }
373
374    /// Iterate nodes in clip-id order.
375    pub fn iter(&self) -> Iter<'_, String, Node> {
376        self.nodes.iter()
377    }
378
379    /// Fold this run's clips and their [`Resolution`] into the store.
380    ///
381    /// Pure: it takes `now` (an ISO timestamp) from the caller rather than
382    /// reading a clock. Upserts a node for every clip *and* every gap-filled
383    /// ancestor (so trashed ancestors are archived), upserts an edge for every
384    /// [`lineage_edges`] link, and refreshes the monotonic resolution cache.
385    /// `edges` is left sorted so the serialised form is deterministic.
386    pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
387        for clip in clips {
388            self.upsert_node(clip, now);
389        }
390        // Gap-filled ancestors are not download candidates, but their lineage
391        // must be archived before Suno purges them, so they become nodes too.
392        for clip in &resolution.gap_filled {
393            self.upsert_node(clip, now);
394        }
395
396        for clip in clips {
397            for edge in lineage_edges(clip) {
398                self.upsert_edge(&clip.id, &edge, now);
399            }
400        }
401        self.edges.sort_by(|a, b| {
402            a.child_id
403                .cmp(&b.child_id)
404                .then(a.ordinal.cmp(&b.ordinal))
405                .then(a.parent_id.cmp(&b.parent_id))
406                .then(a.edge_type.cmp(&b.edge_type))
407                .then(a.role.cmp(&b.role))
408        });
409
410        for (child_id, info) in &resolution.roots {
411            self.upsert_cache(child_id, info, now);
412        }
413    }
414
415    /// Insert or refresh the node for `clip`. `first_seen_at` and `status` are
416    /// set once on insert; everything else is refreshed to the latest sighting.
417    fn upsert_node(&mut self, clip: &Clip, now: &str) {
418        let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
419            first_seen_at: now.to_owned(),
420            ..Node::default()
421        });
422        node.title = clip.title.clone();
423        node.created_at = clip.created_at.clone();
424        node.clip_type = clip.clip_type.clone();
425        node.task = clip.task.clone();
426        node.is_remix = clip.is_remix;
427        node.is_trashed = clip.is_trashed;
428        node.last_seen_at = now.to_owned();
429    }
430
431    /// Insert or refresh the edge from `child_id` to `edge.parent_id`, keyed by
432    /// `(child_id, parent_id, edge_type, role, ordinal)`.
433    fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
434        let edge_type = edge_type_slug(edge.edge_type);
435        let role = edge_role_slug(edge.role);
436        if let Some(existing) = self.edges.iter_mut().find(|stored| {
437            stored.child_id == child_id
438                && stored.parent_id == edge.parent_id
439                && stored.edge_type == edge_type
440                && stored.role == role
441                && stored.ordinal == edge.ordinal
442        }) {
443            existing.source_field = edge.source_field.to_owned();
444            existing.status = "active".to_owned();
445            existing.last_seen_at = now.to_owned();
446        } else {
447            self.edges.push(StoredEdge {
448                child_id: child_id.to_owned(),
449                parent_id: edge.parent_id.clone(),
450                edge_type: edge_type.to_owned(),
451                role: role.to_owned(),
452                source_field: edge.source_field.to_owned(),
453                ordinal: edge.ordinal,
454                status: "active".to_owned(),
455                first_seen_at: now.to_owned(),
456                last_seen_at: now.to_owned(),
457            });
458        }
459    }
460
461    /// Fold one clip's root resolution into the cache, monotonically.
462    ///
463    /// A [`Resolved`](ResolveStatus::Resolved) root always wins. A non-resolved
464    /// outcome (external, unresolved, cycle) never overwrites an existing
465    /// resolved root — a transient gap-fill miss must not downgrade a good
466    /// album. Otherwise the last-known non-resolved status is recorded.
467    fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
468        if info.status != ResolveStatus::Resolved
469            && self
470                .resolution_cache
471                .get(child_id)
472                .is_some_and(|entry| entry.status == "resolved")
473        {
474            return;
475        }
476        self.resolution_cache.insert(
477            child_id.to_owned(),
478            CacheEntry {
479                root_id: info.root_id.clone(),
480                status: resolve_status_slug(info.status).to_owned(),
481                algorithm_version: 1,
482                computed_at: now.to_owned(),
483            },
484        );
485    }
486}
487
488/// The stable on-disk slug for an [`EdgeType`].
489fn edge_type_slug(edge_type: EdgeType) -> &'static str {
490    match edge_type {
491        EdgeType::Cover => "cover",
492        EdgeType::Remaster => "remaster",
493        EdgeType::SpeedEdit => "speed_edit",
494        EdgeType::Edit => "edit",
495        EdgeType::Extend => "extend",
496        EdgeType::SectionReplace => "section_replace",
497        EdgeType::Stitch => "stitch",
498        EdgeType::Derived => "derived",
499        EdgeType::Uploaded => "uploaded",
500    }
501}
502
503/// The stable on-disk slug for an [`EdgeRole`].
504fn edge_role_slug(role: EdgeRole) -> &'static str {
505    match role {
506        EdgeRole::Primary => "primary",
507        EdgeRole::Secondary => "secondary",
508    }
509}
510
511/// The stable on-disk slug for a [`ResolveStatus`].
512fn resolve_status_slug(status: ResolveStatus) -> &'static str {
513    match status {
514        ResolveStatus::Resolved => "resolved",
515        ResolveStatus::External => "external",
516        ResolveStatus::Unresolved => "unresolved",
517        ResolveStatus::Cycle => "cycle",
518    }
519}
520
521/// Parse a cached status slug back into a [`ResolveStatus`], defaulting to
522/// [`Resolved`](ResolveStatus::Resolved) for the self-root/unknown case.
523fn status_from_slug(slug: &str) -> ResolveStatus {
524    match slug {
525        "external" => ResolveStatus::External,
526        "unresolved" => ResolveStatus::Unresolved,
527        "cycle" => ResolveStatus::Cycle,
528        _ => ResolveStatus::Resolved,
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use std::collections::HashMap;
536
537    /// A clean three-clip chain: cover -> remaster -> gen root, all present.
538    fn chain_clips() -> Vec<Clip> {
539        vec![
540            Clip {
541                id: "c".into(),
542                title: "Cover".into(),
543                clip_type: "gen".into(),
544                task: "cover".into(),
545                created_at: "t2".into(),
546                cover_clip_id: "b".into(),
547                edited_clip_id: "b".into(),
548                ..Default::default()
549            },
550            Clip {
551                id: "b".into(),
552                title: "Remaster".into(),
553                clip_type: "upsample".into(),
554                task: "upsample".into(),
555                created_at: "t1".into(),
556                upsample_clip_id: "a".into(),
557                edited_clip_id: "a".into(),
558                ..Default::default()
559            },
560            Clip {
561                id: "a".into(),
562                title: "Root".into(),
563                clip_type: "gen".into(),
564                created_at: "t0".into(),
565                ..Default::default()
566            },
567        ]
568    }
569
570    /// The matching resolution: every clip roots at `a`, all resolved.
571    fn chain_resolution() -> Resolution {
572        let mut roots = HashMap::new();
573        for id in ["a", "b", "c"] {
574            roots.insert(
575                id.to_owned(),
576                RootInfo {
577                    root_id: "a".into(),
578                    root_title: "Root".into(),
579                    status: ResolveStatus::Resolved,
580                },
581            );
582        }
583        Resolution {
584            roots,
585            gap_filled: Vec::new(),
586        }
587    }
588
589    fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
590        store
591            .edges
592            .iter()
593            .find(|e| e.child_id == child && e.parent_id == parent)
594            .expect("edge should exist")
595    }
596
597    #[test]
598    fn new_store_is_empty_and_versioned() {
599        let store = LineageStore::new();
600        assert!(store.is_empty());
601        assert_eq!(store.len(), 0);
602        assert_eq!(store.schema_version, 1);
603    }
604
605    #[test]
606    fn update_populates_nodes_edges_and_cache() {
607        let mut store = LineageStore::new();
608        store.update(&chain_clips(), &chain_resolution(), "now");
609
610        // A node per clip, dated and typed from the clip.
611        assert_eq!(store.len(), 3);
612        let cover = store.node("c").unwrap();
613        assert_eq!(cover.title, "Cover");
614        assert_eq!(cover.clip_type, "gen");
615        assert_eq!(cover.task, "cover");
616        assert_eq!(cover.created_at, "t2");
617        assert_eq!(cover.status, "observed");
618        assert!(!cover.is_trashed);
619        assert_eq!(cover.first_seen_at, "now");
620        assert_eq!(cover.last_seen_at, "now");
621
622        // One primary edge per non-root clip; the root emits none.
623        assert_eq!(store.edges.len(), 2);
624        let cb = edge(&store, "c", "b");
625        assert_eq!(cb.edge_type, "cover");
626        assert_eq!(cb.role, "primary");
627        assert_eq!(cb.ordinal, 0);
628        assert_eq!(cb.source_field, "cover_clip_id");
629        assert_eq!(cb.status, "active");
630        let ba = edge(&store, "b", "a");
631        assert_eq!(ba.edge_type, "remaster");
632        assert!(!store.edges.iter().any(|e| e.child_id == "a"));
633
634        // The cache roots every clip at `a`, resolved.
635        for id in ["a", "b", "c"] {
636            let cached = store.get_root(id).unwrap();
637            assert_eq!(cached.root_id, "a");
638            assert_eq!(cached.status, "resolved");
639            assert_eq!(cached.algorithm_version, 1);
640        }
641    }
642
643    #[test]
644    fn serde_roundtrip_preserves_a_relational_shape() {
645        let mut store = LineageStore::new();
646        store.update(&chain_clips(), &chain_resolution(), "now");
647
648        let json = serde_json::to_string(&store).unwrap();
649        let back: LineageStore = serde_json::from_str(&json).unwrap();
650        assert_eq!(store, back);
651
652        let value: serde_json::Value = serde_json::to_value(&store).unwrap();
653        assert_eq!(value.get("schema_version").unwrap(), 1);
654        assert!(value.get("nodes").unwrap().is_object());
655        assert!(value.get("edges").unwrap().is_array());
656        assert!(value.get("resolution_cache").unwrap().is_object());
657
658        // Relational, not adjacency: a node carries no edges/parent of its own,
659        // and an edge is a flat row keyed by child and parent.
660        let node = value.get("nodes").unwrap().get("c").unwrap();
661        assert!(node.get("edges").is_none());
662        assert!(node.get("parent_id").is_none());
663        let first_edge = value.get("edges").unwrap().get(0).unwrap();
664        assert!(first_edge.get("child_id").is_some());
665        assert!(first_edge.get("parent_id").is_some());
666    }
667
668    #[test]
669    fn update_is_idempotent_bar_last_seen() {
670        let clips = chain_clips();
671        let resolution = chain_resolution();
672        let mut store = LineageStore::new();
673        store.update(&clips, &resolution, "first");
674        let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
675        let edge_count = store.edges.len();
676
677        store.update(&clips, &resolution, "second");
678
679        // No new nodes, edges, or cache rows: the second run only refreshes.
680        assert_eq!(
681            store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
682            node_ids
683        );
684        assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
685        assert_eq!(store.resolution_cache.len(), 3);
686
687        // first_seen_at sticks; last_seen_at advances.
688        let cover = store.node("c").unwrap();
689        assert_eq!(cover.first_seen_at, "first");
690        assert_eq!(cover.last_seen_at, "second");
691        let cb = edge(&store, "c", "b");
692        assert_eq!(cb.first_seen_at, "first");
693        assert_eq!(cb.last_seen_at, "second");
694        // Root ids are stable across the re-run.
695        assert_eq!(store.get_root("c").unwrap().root_id, "a");
696    }
697
698    #[test]
699    fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
700        let mut store = LineageStore::new();
701        store.update(&chain_clips(), &chain_resolution(), "first");
702        assert_eq!(store.get_root("c").unwrap().status, "resolved");
703
704        // A later run where `c` fails to resolve (a transient gap-fill miss)
705        // and a brand-new clip `d` that only reaches an external boundary.
706        let child = Clip {
707            id: "c".into(),
708            title: "Cover".into(),
709            clip_type: "gen".into(),
710            task: "cover".into(),
711            cover_clip_id: "b".into(),
712            edited_clip_id: "b".into(),
713            ..Default::default()
714        };
715        let mut roots = HashMap::new();
716        roots.insert(
717            "c".to_owned(),
718            RootInfo {
719                root_id: "elsewhere".into(),
720                root_title: String::new(),
721                status: ResolveStatus::External,
722            },
723        );
724        roots.insert(
725            "d".to_owned(),
726            RootInfo {
727                root_id: "boundary".into(),
728                root_title: String::new(),
729                status: ResolveStatus::External,
730            },
731        );
732        let resolution = Resolution {
733            roots,
734            gap_filled: Vec::new(),
735        };
736        store.update(&[child], &resolution, "second");
737
738        // The resolved root of `c` is kept, not downgraded.
739        let cached = store.get_root("c").unwrap();
740        assert_eq!(cached.root_id, "a");
741        assert_eq!(cached.status, "resolved");
742        assert_eq!(cached.computed_at, "first");
743        // A never-resolved clip records its last-known non-resolved status.
744        let d = store.get_root("d").unwrap();
745        assert_eq!(d.root_id, "boundary");
746        assert_eq!(d.status, "external");
747    }
748
749    #[test]
750    fn gap_filled_trashed_ancestor_is_a_durable_node() {
751        // The trashed ancestor is not among `clips`; it arrives only via the
752        // resolution's gap_filled set, yet must be archived as a node so its
753        // lineage survives Suno's purge (HARDENING H4 / L2).
754        let child = Clip {
755            id: "c".into(),
756            title: "Cover".into(),
757            clip_type: "gen".into(),
758            task: "cover".into(),
759            cover_clip_id: "t".into(),
760            edited_clip_id: "t".into(),
761            ..Default::default()
762        };
763        let trashed = Clip {
764            id: "t".into(),
765            title: "Trashed Original".into(),
766            clip_type: "gen".into(),
767            is_trashed: true,
768            ..Default::default()
769        };
770        let mut roots = HashMap::new();
771        roots.insert(
772            "c".to_owned(),
773            RootInfo {
774                root_id: "t".into(),
775                root_title: "Trashed Original".into(),
776                status: ResolveStatus::Resolved,
777            },
778        );
779        let resolution = Resolution {
780            roots,
781            gap_filled: vec![trashed],
782        };
783        store_update_and_assert_trashed(child, resolution);
784    }
785
786    fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
787        let mut store = LineageStore::new();
788        store.update(&[child], &resolution, "now");
789
790        let node = store
791            .node("t")
792            .expect("trashed ancestor should be archived");
793        assert!(node.is_trashed);
794        assert_eq!(node.title, "Trashed Original");
795        // The child roots at the trashed ancestor.
796        assert_eq!(store.get_root("c").unwrap().root_id, "t");
797    }
798
799    #[test]
800    fn partial_json_loads_with_defaults() {
801        // An older/partial file missing whole collections and per-row fields
802        // still loads: container and row defaults fill the gaps.
803        let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
804        let store: LineageStore = serde_json::from_str(json).unwrap();
805        assert_eq!(store.schema_version, 1);
806        let node = store.node("x").unwrap();
807        assert_eq!(node.title, "Kept");
808        assert_eq!(node.status, "observed");
809        assert_eq!(store.edges[0].status, "active");
810        assert!(store.resolution_cache.is_empty());
811        // The album-art collection is additive: a store written before folder
812        // art existed loads with no albums and no folder art.
813        assert!(store.albums.is_empty());
814        assert!(store.album_art("x").is_none());
815        // The playlist collection is likewise additive: absent in an older
816        // store, it defaults empty (HARDENING B2: no stored playlist means no
817        // reconcile ever treats one as stale).
818        assert!(store.playlists.is_empty());
819        assert!(store.playlist("x").is_none());
820    }
821
822    #[test]
823    fn album_art_roundtrips_and_reads_by_kind() {
824        let mut store = LineageStore::new();
825        store.albums.insert(
826            "root-1".to_owned(),
827            AlbumArt {
828                folder_jpg: Some(ArtifactState {
829                    path: "alice/Album/folder.jpg".to_owned(),
830                    hash: "jpg-h".to_owned(),
831                }),
832                folder_webp: Some(ArtifactState {
833                    path: "alice/Album/cover.webp".to_owned(),
834                    hash: "webp-h".to_owned(),
835                }),
836            },
837        );
838
839        let json = serde_json::to_string(&store).unwrap();
840        let back: LineageStore = serde_json::from_str(&json).unwrap();
841        assert_eq!(store, back);
842
843        // The serialised shape is a relational `albums` map keyed by root id.
844        let value: serde_json::Value = serde_json::to_value(&store).unwrap();
845        let album = value.get("albums").unwrap().get("root-1").unwrap();
846        assert_eq!(
847            album.get("folder_jpg").unwrap().get("hash").unwrap(),
848            "jpg-h"
849        );
850
851        let art = back.album_art("root-1").unwrap();
852        assert_eq!(
853            art.artifact(ArtifactKind::FolderJpg).unwrap().path,
854            "alice/Album/folder.jpg"
855        );
856        assert_eq!(
857            art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
858            "webp-h"
859        );
860        // A per-clip kind has no album slot.
861        assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
862    }
863
864    #[test]
865    fn empty_album_art_omits_slots_when_serialised() {
866        // An all-`None` AlbumArt round-trips and writes an empty object, so the
867        // absent-slot default holds both ways.
868        let empty = AlbumArt::default();
869        assert!(empty.is_empty());
870        let value = serde_json::to_value(&empty).unwrap();
871        assert!(value.get("folder_jpg").is_none());
872        assert!(value.get("folder_webp").is_none());
873        let back: AlbumArt = serde_json::from_str("{}").unwrap();
874        assert_eq!(back, empty);
875    }
876
877    #[test]
878    fn set_album_artifact_upserts_then_prunes_when_emptied() {
879        let mut store = LineageStore::new();
880        let jpg = ArtifactState {
881            path: "a/folder.jpg".to_owned(),
882            hash: "h1".to_owned(),
883        };
884        store.set_album_artifact("root-1", ArtifactKind::FolderJpg, Some(jpg.clone()));
885        assert_eq!(store.album_art("root-1").unwrap().folder_jpg, Some(jpg));
886
887        // Clearing the only slot prunes the whole album row (no dead entries).
888        store.set_album_artifact("root-1", ArtifactKind::FolderJpg, None);
889        assert!(store.album_art("root-1").is_none());
890        assert!(store.albums.is_empty());
891    }
892
893    #[test]
894    fn playlist_state_roundtrips_by_id() {
895        let mut store = LineageStore::new();
896        store.playlists.insert(
897            "pl1".to_owned(),
898            PlaylistState {
899                name: "Road Trip".to_owned(),
900                path: "Road Trip.m3u8".to_owned(),
901                hash: "abc123".to_owned(),
902            },
903        );
904
905        let json = serde_json::to_string(&store).unwrap();
906        let back: LineageStore = serde_json::from_str(&json).unwrap();
907        assert_eq!(store, back);
908
909        // The serialised shape is a relational `playlists` map keyed by id.
910        let value: serde_json::Value = serde_json::to_value(&store).unwrap();
911        let pl = value.get("playlists").unwrap().get("pl1").unwrap();
912        assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
913        assert_eq!(pl.get("hash").unwrap(), "abc123");
914
915        let stored = back.playlist("pl1").unwrap();
916        assert_eq!(stored.name, "Road Trip");
917        assert_eq!(stored.hash, "abc123");
918    }
919
920    #[test]
921    fn set_playlist_upserts_then_clears() {
922        let mut store = LineageStore::new();
923        let state = PlaylistState {
924            name: "Mix".to_owned(),
925            path: "Mix.m3u8".to_owned(),
926            hash: "h1".to_owned(),
927        };
928        store.set_playlist("pl1", Some(state.clone()));
929        assert_eq!(store.playlist("pl1"), Some(&state));
930
931        // A rewrite replaces the row in place.
932        let renamed = PlaylistState {
933            name: "Mix v2".to_owned(),
934            path: "Mix v2.m3u8".to_owned(),
935            hash: "h2".to_owned(),
936        };
937        store.set_playlist("pl1", Some(renamed.clone()));
938        assert_eq!(store.playlist("pl1"), Some(&renamed));
939
940        // Clearing removes the row so no dangling entry survives a delete.
941        store.set_playlist("pl1", None);
942        assert!(store.playlist("pl1").is_none());
943        assert!(store.playlists.is_empty());
944    }
945
946    #[test]
947    fn context_for_roots_a_remix_at_its_stored_ancestor() {
948        let mut store = LineageStore::new();
949        store.update(&chain_clips(), &chain_resolution(), "now");
950
951        let child = &chain_clips()[0]; // "c", a cover of "b"
952        let ctx = store.context_for(child);
953        assert_eq!(ctx.root_id, "a");
954        assert_eq!(ctx.root_title, "Root");
955        assert_eq!(ctx.parent_id, "b");
956        assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
957        assert_eq!(ctx.status, ResolveStatus::Resolved);
958        // The remix folders under its resolved root's album.
959        assert_eq!(ctx.album("Cover"), "Root");
960    }
961
962    #[test]
963    fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
964        let mut store = LineageStore::new();
965        store.update(&chain_clips(), &chain_resolution(), "now");
966
967        let root = &chain_clips()[2]; // "a"
968        let ctx = store.context_for(root);
969        assert_eq!(ctx.root_id, "a");
970        assert_eq!(ctx.root_title, "Root");
971        assert_eq!(ctx.parent_id, "");
972        assert_eq!(ctx.edge_type, None);
973        assert_eq!(ctx.album("Root"), "Root");
974    }
975
976    #[test]
977    fn context_for_an_unknown_clip_is_self_rooted() {
978        let store = LineageStore::new();
979        let orphan = Clip {
980            id: "z".into(),
981            title: "Lonely".into(),
982            ..Default::default()
983        };
984        let ctx = store.context_for(&orphan);
985        assert_eq!(ctx.root_id, "z");
986        assert_eq!(ctx.root_title, "Lonely");
987        assert_eq!(ctx.parent_id, "");
988        assert_eq!(ctx.status, ResolveStatus::Resolved);
989    }
990
991    #[test]
992    fn context_for_retains_a_purged_ancestor_album() {
993        // The trashed ancestor arrives only via gap_filled, yet a later run
994        // whose resolver failed (modelled here by simply not re-updating) must
995        // still root the child at the archived ancestor with its stored title
996        // (HARDENING H3).
997        let child = Clip {
998            id: "c".into(),
999            title: "Cover".into(),
1000            clip_type: "gen".into(),
1001            task: "cover".into(),
1002            cover_clip_id: "t".into(),
1003            edited_clip_id: "t".into(),
1004            ..Default::default()
1005        };
1006        let trashed = Clip {
1007            id: "t".into(),
1008            title: "Trashed Original".into(),
1009            clip_type: "gen".into(),
1010            is_trashed: true,
1011            ..Default::default()
1012        };
1013        let mut roots = HashMap::new();
1014        roots.insert(
1015            "c".to_owned(),
1016            RootInfo {
1017                root_id: "t".into(),
1018                root_title: "Trashed Original".into(),
1019                status: ResolveStatus::Resolved,
1020            },
1021        );
1022        let resolution = Resolution {
1023            roots,
1024            gap_filled: vec![trashed],
1025        };
1026        let mut store = LineageStore::new();
1027        store.update(std::slice::from_ref(&child), &resolution, "now");
1028
1029        let ctx = store.context_for(&child);
1030        assert_eq!(ctx.root_id, "t");
1031        assert_eq!(ctx.root_title, "Trashed Original");
1032        assert_eq!(ctx.album("Cover"), "Trashed Original");
1033    }
1034
1035    #[test]
1036    fn colliding_root_titles_flags_only_shared_distinct_roots() {
1037        // Two distinct roots share the title "Break Through"; a third root is
1038        // unique; a child of a shared root does not add a spurious distinct root.
1039        let clips = vec![
1040            Clip {
1041                id: "r1".into(),
1042                title: "Break Through".into(),
1043                clip_type: "gen".into(),
1044                ..Default::default()
1045            },
1046            Clip {
1047                id: "r2".into(),
1048                title: "Break Through".into(),
1049                clip_type: "gen".into(),
1050                ..Default::default()
1051            },
1052            Clip {
1053                id: "r3".into(),
1054                title: "Solo".into(),
1055                clip_type: "gen".into(),
1056                ..Default::default()
1057            },
1058            Clip {
1059                id: "c1".into(),
1060                title: "Break Through".into(),
1061                clip_type: "gen".into(),
1062                task: "cover".into(),
1063                cover_clip_id: "r1".into(),
1064                edited_clip_id: "r1".into(),
1065                ..Default::default()
1066            },
1067        ];
1068        let mut roots = HashMap::new();
1069        for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1070            let title = if root == "r3" {
1071                "Solo"
1072            } else {
1073                "Break Through"
1074            };
1075            roots.insert(
1076                id.to_owned(),
1077                RootInfo {
1078                    root_id: root.into(),
1079                    root_title: title.into(),
1080                    status: ResolveStatus::Resolved,
1081                },
1082            );
1083        }
1084        let resolution = Resolution {
1085            roots,
1086            gap_filled: Vec::new(),
1087        };
1088        let mut store = LineageStore::new();
1089        store.update(&clips, &resolution, "now");
1090
1091        let colliding = store.colliding_root_titles();
1092        assert!(colliding.contains("Break Through"));
1093        assert!(!colliding.contains("Solo"));
1094        assert_eq!(colliding.len(), 1);
1095    }
1096}