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, HashMap, HashSet};
21
22use serde::{Deserialize, Serialize};
23
24use crate::album_art::{AlbumArt, PlaylistState};
25use crate::identity::Owner;
26use crate::lineage::{
27    AttributionEdge, Edge, EdgeRole, EdgeType, LineageContext, Resolution, ResolveStatus, RootInfo,
28    attribution_edges, immediate_parent, lineage_edges,
29};
30use crate::model::Clip;
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, 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(crate) nodes: BTreeMap<String, Node>,
43    /// Every observed parent link, as a flat relational list.
44    pub(crate) edges: Vec<StoredEdge>,
45    /// The last resolved (or last-known) root per clip, keyed by clip id.
46    pub(crate) 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    /// The Suno account this library is pinned to (trust-on-first-use). Absent
55    /// in older stores and in a fresh library until the first run adopts it.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub owner: Option<Owner>,
58    /// Manual album-name overrides, keyed by lineage root id, layered over the
59    /// store each run from config (see [`set_album_overrides`]). Runtime-only:
60    /// it is never serialised, so it can never persist into the durable graph or
61    /// silently outlive its config entry.
62    ///
63    /// [`set_album_overrides`]: LineageStore::set_album_overrides
64    #[serde(skip)]
65    pub album_overrides: BTreeMap<String, String>,
66    /// The set of root ids eligible for an album name (an override or a
67    /// collision suffix): every non-empty `root_id` that appears as a *value* in
68    /// [`resolution_cache`](Self::resolution_cache). This is the single source
69    /// both override-application ([`effective_root_title`]) and collision
70    /// detection ([`colliding_root_titles`]) draw from, so they can never
71    /// disagree about which roots exist. Runtime-only and derived from the cache
72    /// via [`refresh_eligible_roots`]; kept in sync by [`update`] and refreshed
73    /// after a load.
74    ///
75    /// [`effective_root_title`]: LineageStore::effective_root_title
76    /// [`colliding_root_titles`]: LineageStore::colliding_root_titles
77    /// [`refresh_eligible_roots`]: LineageStore::refresh_eligible_roots
78    /// [`update`]: LineageStore::update
79    #[serde(skip)]
80    eligible_root_ids: HashSet<String>,
81    /// Runtime index from edge identity to its row in `edges`, rebuilt from the
82    /// vector and kept in sync so upserts are O(1) without changing on-disk
83    /// shape.
84    #[serde(skip)]
85    edge_index: HashMap<EdgeKey, usize>,
86}
87
88impl Default for LineageStore {
89    fn default() -> Self {
90        Self {
91            schema_version: 1,
92            nodes: BTreeMap::new(),
93            edges: Vec::new(),
94            resolution_cache: BTreeMap::new(),
95            albums: BTreeMap::new(),
96            playlists: BTreeMap::new(),
97            owner: None,
98            album_overrides: BTreeMap::new(),
99            eligible_root_ids: HashSet::new(),
100            edge_index: HashMap::new(),
101        }
102    }
103}
104
105/// Equality over the durable graph only.
106///
107/// `album_overrides` and `eligible_root_ids` are runtime-only overlays
108/// (`#[serde(skip)]`): the first is layered from config each run, the second is
109/// a cache derived from `resolution_cache`. Neither is part of the persisted
110/// relational shape, so two stores are equal when their durable content is,
111/// regardless of the overrides in force or whether the derived set has been
112/// refreshed after a load.
113impl PartialEq for LineageStore {
114    fn eq(&self, other: &Self) -> bool {
115        self.schema_version == other.schema_version
116            && self.nodes == other.nodes
117            && self.edges == other.edges
118            && self.resolution_cache == other.resolution_cache
119            && self.albums == other.albums
120            && self.playlists == other.playlists
121            && self.owner == other.owner
122    }
123}
124
125/// Lifecycle marker for a [`Node`]: `"observed"` for a clip seen from the feed or gap-fill.
126#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum NodeStatus {
129    #[default]
130    #[serde(other)]
131    Observed,
132}
133
134/// Lifecycle marker for a [`StoredEdge`]: `"active"` for an edge observed this run.
135#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum EdgeStatus {
138    #[default]
139    #[serde(other)]
140    Active,
141}
142
143/// One clip in the graph. Mirrors the fields lineage needs to survive a purge:
144/// enough to name and date the clip long after Suno deletes it.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[serde(default)]
147pub struct Node {
148    pub title: String,
149    pub created_at: String,
150    pub clip_type: String,
151    pub task: String,
152    pub is_remix: bool,
153    pub is_trashed: bool,
154    pub status: NodeStatus,
155    pub first_seen_at: String,
156    pub last_seen_at: String,
157}
158
159impl Default for Node {
160    fn default() -> Self {
161        Self {
162            title: String::new(),
163            created_at: String::new(),
164            clip_type: String::new(),
165            task: String::new(),
166            is_remix: false,
167            is_trashed: false,
168            status: NodeStatus::Observed,
169            first_seen_at: String::new(),
170            last_seen_at: String::new(),
171        }
172    }
173}
174
175/// One parent link, keyed (for upsert) by `(child_id, parent_id, edge_type,
176/// role, ordinal)`. A flat row, not nested under its child, so it maps directly
177/// to a `lineage_edges` table.
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179#[serde(default)]
180pub struct StoredEdge {
181    pub child_id: String,
182    pub parent_id: String,
183    /// Stable lowercase slug, e.g. `"cover"`, `"remaster"`, `"section_replace"`.
184    pub edge_type: String,
185    pub role: EdgeRole,
186    /// The clip field the parent id was read from, e.g. `"cover_clip_id"`.
187    pub source_field: String,
188    /// Position within its role (0 for the primary, then secondaries in order).
189    pub ordinal: u32,
190    pub status: EdgeStatus,
191    pub first_seen_at: String,
192    pub last_seen_at: String,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Hash)]
196struct EdgeKey {
197    child_id: String,
198    parent_id: String,
199    edge_type: String,
200    role: EdgeRole,
201    ordinal: u32,
202}
203
204impl EdgeKey {
205    fn new(child_id: &str, parent_id: &str, edge_type: &str, role: EdgeRole, ordinal: u32) -> Self {
206        Self {
207            child_id: child_id.to_owned(),
208            parent_id: parent_id.to_owned(),
209            edge_type: edge_type.to_owned(),
210            role,
211            ordinal,
212        }
213    }
214
215    fn from_stored(edge: &StoredEdge) -> Self {
216        Self::new(
217            &edge.child_id,
218            &edge.parent_id,
219            &edge.edge_type,
220            edge.role,
221            edge.ordinal,
222        )
223    }
224}
225
226impl Default for StoredEdge {
227    fn default() -> Self {
228        Self {
229            child_id: String::new(),
230            parent_id: String::new(),
231            edge_type: String::new(),
232            role: EdgeRole::Primary,
233            source_field: String::new(),
234            ordinal: 0,
235            status: EdgeStatus::Active,
236            first_seen_at: String::new(),
237            last_seen_at: String::new(),
238        }
239    }
240}
241
242/// A cached root resolution for one clip: the O(1) album lookup, kept monotonic.
243#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
244#[serde(default)]
245pub struct CacheEntry {
246    pub root_id: String,
247    pub status: ResolveStatus,
248    pub algorithm_version: u32,
249    pub computed_at: String,
250}
251
252impl LineageStore {
253    /// Create an empty store at the current schema version.
254    pub fn new() -> Self {
255        Self::default()
256    }
257
258    /// Layer this run's manual album-name overrides onto the store.
259    ///
260    /// Keyed by lineage root id, sourced from the account's config each run and
261    /// never persisted (the field is `#[serde(skip)]`). Applied wherever the
262    /// album title is resolved ([`context_for`], [`album_for_id`],
263    /// [`colliding_root_titles`]), so a single call makes the folder path, the
264    /// `ALBUM` tag, the change hash, the on-disk index, and disambiguation all
265    /// reflect the preferred name from one source of truth.
266    ///
267    /// [`context_for`]: LineageStore::context_for
268    /// [`album_for_id`]: LineageStore::album_for_id
269    /// [`colliding_root_titles`]: LineageStore::colliding_root_titles
270    pub fn set_album_overrides(&mut self, overrides: BTreeMap<String, String>) {
271        self.album_overrides = overrides;
272    }
273
274    /// The effective album title for a lineage root: the manual override when
275    /// one is configured for `root_id` AND that root is eligible (see
276    /// [`eligible_root_ids`]), otherwise the derived `root_title`.
277    ///
278    /// This is the single point at which a manual override supplants the derived
279    /// name, so every consumer that resolves an album title routes through it.
280    ///
281    /// The override is applied only when `root_id` is in the eligible set —
282    /// exactly the roots [`colliding_root_titles`] groups over. Tying
283    /// override-application and collision-detection to one set means an override
284    /// is never applied to a root that collision detection cannot suffix, which
285    /// would otherwise let two distinct roots share one album folder. The set is
286    /// the non-empty `root_id`s appearing as cache *values*, so it covers normal
287    /// resolved roots and gap-filled/archived ancestor roots (a value for their
288    /// children, never a key) alike. A truly uncached fallback self-root on a
289    /// resolution-failed run appears nowhere in the cache and is NOT overridden;
290    /// it folders under its own derived title this run and converges to the
291    /// override on a later run once the root resolves. This is intended, safe
292    /// degraded behaviour: a transient resolution miss can never collapse two
293    /// albums onto one path.
294    ///
295    /// [`eligible_root_ids`]: Self::eligible_root_ids
296    /// [`colliding_root_titles`]: LineageStore::colliding_root_titles
297    fn effective_root_title(&self, root_id: &str, root_title: String) -> String {
298        if !self.eligible_root_ids.contains(root_id) {
299            return root_title;
300        }
301        match self.album_overrides.get(root_id) {
302            Some(name) if !name.trim().is_empty() => name.clone(),
303            _ => root_title,
304        }
305    }
306
307    /// Recompute the eligible-root set from the resolution cache.
308    ///
309    /// The set is the non-empty `root_id`s across the cache's values (the roots
310    /// every clip resolves to), which is exactly what [`colliding_root_titles`]
311    /// groups over. Called at the end of [`update`] and after a load (the field
312    /// is not serialised), so the set always reflects the populated cache.
313    ///
314    /// [`colliding_root_titles`]: LineageStore::colliding_root_titles
315    /// [`update`]: LineageStore::update
316    pub fn refresh_eligible_roots(&mut self) {
317        self.eligible_root_ids = self
318            .resolution_cache
319            .values()
320            .map(|entry| entry.root_id.as_str())
321            .filter(|root_id| !root_id.is_empty())
322            .map(str::to_owned)
323            .collect();
324    }
325
326    /// The eligible-root set, for tests that assert override-application and
327    /// collision detection share one domain.
328    #[cfg(test)]
329    pub(crate) fn eligible_root_ids_for_test(&self) -> &HashSet<String> {
330        &self.eligible_root_ids
331    }
332
333    /// The node for `id`, if present.
334    pub fn node(&self, id: &str) -> Option<&Node> {
335        self.nodes.get(id)
336    }
337
338    /// The cached root resolution for `id`, if present.
339    pub fn get_root(&self, id: &str) -> Option<&CacheEntry> {
340        self.resolution_cache.get(id)
341    }
342
343    /// Build a [`LineageContext`] for `clip` from the durable store.
344    ///
345    /// This is the source of truth for every file-affecting lineage decision
346    /// (album folder, embedded tags, the change hash), so a dropped resolution
347    /// call never rewrites the library (HARDENING H3). The root comes from the
348    /// monotonic resolution cache (the clip's own id when the store has no
349    /// better answer) and the root title and date from that root's archived
350    /// node, so a transient miss keeps the last-known-good album (even for a
351    /// since-purged ancestor) and the Year tag anchors on the root's year. The
352    /// parent edge is read structurally from the clip itself.
353    pub fn context_for(&self, clip: &Clip) -> LineageContext {
354        let cached = self.get_root(&clip.id);
355        let root_id = cached
356            .map(|entry| entry.root_id.clone())
357            .filter(|id| !id.is_empty())
358            .unwrap_or_else(|| clip.id.clone());
359        let root_title = self
360            .node(&root_id)
361            .map(|node| node.title.clone())
362            .unwrap_or_else(|| clip.title.clone());
363        let root_title = self.effective_root_title(&root_id, root_title);
364        let root_date = self
365            .node(&root_id)
366            .map(|node| node.created_at.clone())
367            .unwrap_or_else(|| clip.created_at.clone());
368        let (parent_id, edge_type) = match immediate_parent(clip) {
369            Some((id, edge)) => (id, Some(edge)),
370            None => (String::new(), None),
371        };
372        let status = cached
373            .map(|entry| entry.status)
374            .unwrap_or(ResolveStatus::Resolved);
375        LineageContext {
376            root_id,
377            root_title,
378            root_date,
379            parent_id,
380            edge_type,
381            status,
382        }
383    }
384
385    /// The canonical logical album title for a clip identified only by `id`.
386    ///
387    /// The store-side counterpart of `context_for(clip).album(clip.title)` for a
388    /// clip that is not part of the current run (so no live [`Clip`] is on hand).
389    /// The clip's own title and its root come from the archived nodes and the
390    /// monotonic resolution cache, then the same [`LineageContext::album`] rule
391    /// decides whether the clip folders under its root's album or its own title.
392    /// A clip absent from the store folds to a self-root with an empty title.
393    pub fn album_for_id(&self, id: &str) -> String {
394        let own = self.node(id);
395        let own_title = own.map(|node| node.title.clone()).unwrap_or_default();
396        let own_created_at = own.map(|node| node.created_at.clone()).unwrap_or_default();
397        let root_id = self
398            .get_root(id)
399            .map(|entry| entry.root_id.clone())
400            .filter(|root| !root.is_empty())
401            .unwrap_or_else(|| id.to_owned());
402        let root_title = self
403            .node(&root_id)
404            .map(|node| node.title.clone())
405            .unwrap_or_else(|| own_title.clone());
406        let root_title = self.effective_root_title(&root_id, root_title);
407        let root_date = self
408            .node(&root_id)
409            .map(|node| node.created_at.clone())
410            .unwrap_or(own_created_at);
411        let context = LineageContext {
412            root_id,
413            root_title,
414            root_date,
415            parent_id: String::new(),
416            edge_type: None,
417            status: ResolveStatus::Resolved,
418        };
419        context.album(&own_title)
420    }
421
422    /// The set of *effective* album titles shared by more than one distinct
423    /// root.
424    ///
425    /// Two distinct roots must never share an album folder (two different
426    /// uploads titled "Break Through" exist), so naming appends the short root
427    /// id to the album of any clip whose album is in this set. It is computed
428    /// from the whole archive — every eligible root (see
429    /// [`eligible_root_ids`](Self::eligible_root_ids)) paired with its effective
430    /// title (a manual override when configured, else the node title) — so the
431    /// decision is stable across runs and independent of the current batch: a
432    /// `--since`/`--limit` slice that shows only one of two same-titled roots
433    /// still disambiguates, instead of oscillating between a bare and a suffixed
434    /// folder. Because it folds overrides in first, a rename that collides two
435    /// albums (or one that resolves a collision) is honoured consistently with
436    /// the path, tag, and hash.
437    ///
438    /// This iterates the exact same eligible-root set that
439    /// [`effective_root_title`](Self::effective_root_title) gates overrides on,
440    /// so an override affects a root's album name if and only if that root is
441    /// grouped here — the two can never disagree. The set is the non-empty
442    /// `root_id`s appearing as cache values, so it includes gap-filled/archived
443    /// ancestor roots (a value for their children, never a key) and node-less
444    /// cached roots. A root with no node and no override has an empty effective
445    /// title and is skipped. An uncached fallback self-root on a
446    /// resolution-failed run is in neither set.
447    pub fn colliding_root_titles(&self) -> BTreeSet<String> {
448        let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
449        for root_id in &self.eligible_root_ids {
450            let node_title = self
451                .nodes
452                .get(root_id)
453                .map(|node| node.title.clone())
454                .unwrap_or_default();
455            let effective = self.effective_root_title(root_id, node_title);
456            let title = effective.trim();
457            if title.is_empty() {
458                continue;
459            }
460            roots_by_title
461                .entry(title.to_owned())
462                .or_default()
463                .insert(root_id.clone());
464        }
465        roots_by_title
466            .into_iter()
467            .filter(|(_, roots)| roots.len() > 1)
468            .map(|(title, _)| title)
469            .collect()
470    }
471
472    /// Number of nodes in the graph.
473    pub fn len(&self) -> usize {
474        self.nodes.len()
475    }
476
477    /// True when the graph holds no nodes.
478    pub fn is_empty(&self) -> bool {
479        self.nodes.is_empty()
480    }
481
482    /// Iterate nodes in clip-id order.
483    pub fn iter(&self) -> Iter<'_, String, Node> {
484        self.nodes.iter()
485    }
486
487    /// Fold this run's clips and their [`Resolution`] into the store.
488    ///
489    /// Pure: it takes `now` (an ISO timestamp) from the caller rather than
490    /// reading a clock. Upserts a node for every clip *and* every gap-filled
491    /// ancestor (so trashed ancestors are archived), upserts an edge for every
492    /// [`lineage_edges`] link, and refreshes the monotonic resolution cache.
493    /// `edges` is left sorted so the serialised form is deterministic.
494    pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
495        self.rebuild_edge_index();
496
497        for clip in clips {
498            self.upsert_node(clip, now);
499        }
500        // Gap-filled ancestors are not download candidates, but their lineage
501        // must be archived before Suno purges them, so they become nodes too.
502        for clip in &resolution.gap_filled {
503            self.upsert_node(clip, now);
504        }
505
506        // Persist edges for the input clips AND the gap-filled ancestors. A
507        // gap-filled ancestor carries its own parent pointer, so recording its
508        // `lineage_edges` keeps the stored graph connected (an intermediate
509        // remix is no longer a disconnected root) and lets a later run resolve
510        // through it from the store, without re-fetching, even after Suno purges
511        // it. Parent-endpoint bridges have no clip of their own, so they are
512        // persisted directly below to keep that hop durable too.
513        for clip in clips.iter().chain(resolution.gap_filled.iter()) {
514            for edge in lineage_edges(clip) {
515                self.upsert_edge(&clip.id, &edge, now);
516            }
517        }
518        for (child_id, parent_id) in &resolution.bridges {
519            let edge = Edge {
520                parent_id: parent_id.clone(),
521                edge_type: EdgeType::Derived,
522                role: EdgeRole::Primary,
523                ordinal: 0,
524                source_field: "parent_endpoint",
525            };
526            self.upsert_edge(child_id, &edge, now);
527        }
528        // Attribution edges from `clip_roots` are additive and informational,
529        // never structural: they carry the open attribution slug directly and
530        // are role Secondary, so `archived_parents` (Primary, ordinal 0) never
531        // reads them and root resolution stays untouched.
532        for clip in clips.iter().chain(resolution.gap_filled.iter()) {
533            for edge in attribution_edges(clip) {
534                self.upsert_attribution_edge(&clip.id, &edge, now);
535            }
536        }
537        self.edges.sort_by(|a, b| {
538            a.child_id
539                .cmp(&b.child_id)
540                .then(a.ordinal.cmp(&b.ordinal))
541                .then(a.parent_id.cmp(&b.parent_id))
542                .then(a.edge_type.cmp(&b.edge_type))
543                .then(a.role.cmp(&b.role))
544        });
545        self.rebuild_edge_index();
546
547        for (child_id, info) in &resolution.roots {
548            self.upsert_cache(child_id, info, now);
549        }
550        self.refresh_eligible_roots();
551    }
552
553    /// The persisted `child_id -> parent_id` map from the active primary edges
554    /// (each clip's ordinal-0 lineage parent), for seeding
555    /// [`resolve_roots`](crate::resolve_roots).
556    ///
557    /// This lets a resolution walk hop through an ancestor whose clip is absent
558    /// this run (an intermediate remix, or one Suno has purged) using the link
559    /// captured on an earlier run, instead of self-rooting. It is resolution
560    /// input only: these ids are never download candidates.
561    pub fn archived_parents(&self) -> HashMap<String, String> {
562        self.edges
563            .iter()
564            .filter(|edge| {
565                edge.role == EdgeRole::Primary
566                    && edge.ordinal == 0
567                    && edge.status == EdgeStatus::Active
568            })
569            .map(|edge| (edge.child_id.clone(), edge.parent_id.clone()))
570            .collect()
571    }
572
573    /// Insert or refresh the node for `clip`. `first_seen_at` and `status` are
574    /// set once on insert; everything else is refreshed to the latest sighting.
575    fn upsert_node(&mut self, clip: &Clip, now: &str) {
576        let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
577            first_seen_at: now.to_owned(),
578            ..Node::default()
579        });
580        node.title = clip.title.clone();
581        node.created_at = clip.created_at.clone();
582        node.clip_type = clip.clip_type.clone();
583        node.task = clip.task.clone();
584        node.is_remix = clip.is_remix;
585        node.is_trashed = clip.is_trashed;
586        node.last_seen_at = now.to_owned();
587    }
588
589    /// Insert or refresh the edge from `child_id` to `edge.parent_id`, keyed by
590    /// `(child_id, parent_id, edge_type, role, ordinal)`.
591    fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
592        let edge_type = edge_type_slug(edge.edge_type);
593        let key = EdgeKey::new(
594            child_id,
595            &edge.parent_id,
596            edge_type,
597            edge.role,
598            edge.ordinal,
599        );
600        if let Some(&index) = self.edge_index.get(&key) {
601            let existing = &mut self.edges[index];
602            existing.source_field = edge.source_field.to_owned();
603            existing.status = EdgeStatus::Active;
604            existing.last_seen_at = now.to_owned();
605        } else {
606            self.edges.push(StoredEdge {
607                child_id: child_id.to_owned(),
608                parent_id: edge.parent_id.clone(),
609                edge_type: edge_type.to_owned(),
610                role: edge.role,
611                source_field: edge.source_field.to_owned(),
612                ordinal: edge.ordinal,
613                status: EdgeStatus::Active,
614                first_seen_at: now.to_owned(),
615                last_seen_at: now.to_owned(),
616            });
617            self.edge_index.insert(key, self.edges.len() - 1);
618        }
619    }
620
621    /// Insert or refresh an attribution edge from `clip_roots`, keyed like any
622    /// edge by `(child_id, parent_id, edge_type, role, ordinal)`.
623    ///
624    /// The open attribution slug (normalised) is written DIRECTLY to
625    /// `edge_type`, bypassing the closed-[`EdgeType`] slug path, so an unknown
626    /// `clip_attribution_type` is preserved verbatim rather than forced into the
627    /// structural enum.
628    fn upsert_attribution_edge(&mut self, child_id: &str, edge: &AttributionEdge, now: &str) {
629        let edge_type = normalise_slug(&edge.edge_slug);
630        let key = EdgeKey::new(
631            child_id,
632            &edge.parent_id,
633            &edge_type,
634            edge.role,
635            edge.ordinal,
636        );
637        if let Some(&index) = self.edge_index.get(&key) {
638            let existing = &mut self.edges[index];
639            existing.source_field = edge.source_field.to_owned();
640            existing.status = EdgeStatus::Active;
641            existing.last_seen_at = now.to_owned();
642        } else {
643            self.edges.push(StoredEdge {
644                child_id: child_id.to_owned(),
645                parent_id: edge.parent_id.clone(),
646                edge_type,
647                role: edge.role,
648                source_field: edge.source_field.to_owned(),
649                ordinal: edge.ordinal,
650                status: EdgeStatus::Active,
651                first_seen_at: now.to_owned(),
652                last_seen_at: now.to_owned(),
653            });
654            self.edge_index.insert(key, self.edges.len() - 1);
655        }
656    }
657
658    fn rebuild_edge_index(&mut self) {
659        self.edge_index.clear();
660        for (index, edge) in self.edges.iter().enumerate() {
661            self.edge_index
662                .entry(EdgeKey::from_stored(edge))
663                .or_insert(index);
664        }
665    }
666
667    /// Fold one clip's root resolution into the cache, monotonically.
668    ///
669    /// A [`Resolved`](ResolveStatus::Resolved) root always wins. A non-resolved
670    /// outcome (external, unresolved, cycle) never overwrites an existing
671    /// resolved root — a transient gap-fill miss must not downgrade a good
672    /// album. Otherwise the last-known non-resolved status is recorded.
673    fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
674        if info.status != ResolveStatus::Resolved
675            && self
676                .resolution_cache
677                .get(child_id)
678                .is_some_and(|entry| entry.status == ResolveStatus::Resolved)
679        {
680            return;
681        }
682        self.resolution_cache.insert(
683            child_id.to_owned(),
684            CacheEntry {
685                root_id: info.root_id.clone(),
686                status: info.status,
687                algorithm_version: 1,
688                computed_at: now.to_owned(),
689            },
690        );
691    }
692}
693
694/// The stable on-disk slug for an [`EdgeType`].
695fn edge_type_slug(edge_type: EdgeType) -> &'static str {
696    match edge_type {
697        EdgeType::Cover => "cover",
698        EdgeType::Remaster => "remaster",
699        EdgeType::SpeedEdit => "speed_edit",
700        EdgeType::Edit => "edit",
701        EdgeType::Extend => "extend",
702        EdgeType::SectionReplace => "section_replace",
703        EdgeType::Stitch => "stitch",
704        EdgeType::Derived => "derived",
705        EdgeType::Uploaded => "uploaded",
706    }
707}
708
709/// Normalise an open attribution slug to a stable lowercase, underscore-joined
710/// form, e.g. `"Remix Cover"` -> `"remix_cover"`. An empty (or whitespace-only)
711/// slug maps to `"attribution"` so an edge always carries a non-empty type.
712fn normalise_slug(slug: &str) -> String {
713    let normalised = slug
714        .split_whitespace()
715        .collect::<Vec<_>>()
716        .join("_")
717        .to_lowercase();
718    if normalised.is_empty() {
719        "attribution".to_owned()
720    } else {
721        normalised
722    }
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728    use std::collections::HashMap;
729
730    /// A clean three-clip chain: cover -> remaster -> gen root, all present.
731    fn chain_clips() -> Vec<Clip> {
732        vec![
733            Clip {
734                id: "c".into(),
735                title: "Cover".into(),
736                clip_type: "gen".into(),
737                task: "cover".into(),
738                created_at: "t2".into(),
739                cover_clip_id: "b".into(),
740                edited_clip_id: "b".into(),
741                ..Default::default()
742            },
743            Clip {
744                id: "b".into(),
745                title: "Remaster".into(),
746                clip_type: "upsample".into(),
747                task: "upsample".into(),
748                created_at: "t1".into(),
749                upsample_clip_id: "a".into(),
750                edited_clip_id: "a".into(),
751                ..Default::default()
752            },
753            Clip {
754                id: "a".into(),
755                title: "Root".into(),
756                clip_type: "gen".into(),
757                created_at: "t0".into(),
758                ..Default::default()
759            },
760        ]
761    }
762
763    /// The matching resolution: every clip roots at `a`, all resolved.
764    fn chain_resolution() -> Resolution {
765        let mut roots = HashMap::new();
766        for id in ["a", "b", "c"] {
767            roots.insert(
768                id.to_owned(),
769                RootInfo {
770                    root_id: "a".into(),
771                    root_title: "Root".into(),
772                    status: ResolveStatus::Resolved,
773                },
774            );
775        }
776        Resolution {
777            roots,
778            gap_filled: Vec::new(),
779            bridges: Vec::new(),
780        }
781    }
782
783    fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
784        store
785            .edges
786            .iter()
787            .find(|e| e.child_id == child && e.parent_id == parent)
788            .expect("edge should exist")
789    }
790
791    #[test]
792    fn new_store_is_empty_and_versioned() {
793        let store = LineageStore::new();
794        assert!(store.is_empty());
795        assert_eq!(store.len(), 0);
796        assert_eq!(store.schema_version, 1);
797    }
798
799    #[test]
800    fn update_populates_nodes_edges_and_cache() {
801        let mut store = LineageStore::new();
802        store.update(&chain_clips(), &chain_resolution(), "now");
803
804        // A node per clip, dated and typed from the clip.
805        assert_eq!(store.len(), 3);
806        let cover = store.node("c").unwrap();
807        assert_eq!(cover.title, "Cover");
808        assert_eq!(cover.clip_type, "gen");
809        assert_eq!(cover.task, "cover");
810        assert_eq!(cover.created_at, "t2");
811        assert_eq!(cover.status, NodeStatus::Observed);
812        assert!(!cover.is_trashed);
813        assert_eq!(cover.first_seen_at, "now");
814        assert_eq!(cover.last_seen_at, "now");
815
816        // One primary edge per non-root clip; the root emits none.
817        assert_eq!(store.edges.len(), 2);
818        let cb = edge(&store, "c", "b");
819        assert_eq!(cb.edge_type, "cover");
820        assert_eq!(cb.role, EdgeRole::Primary);
821        assert_eq!(cb.ordinal, 0);
822        assert_eq!(cb.source_field, "cover_clip_id");
823        assert_eq!(cb.status, EdgeStatus::Active);
824        let ba = edge(&store, "b", "a");
825        assert_eq!(ba.edge_type, "remaster");
826        assert!(!store.edges.iter().any(|e| e.child_id == "a"));
827
828        // The cache roots every clip at `a`, resolved.
829        for id in ["a", "b", "c"] {
830            let cached = store.get_root(id).unwrap();
831            assert_eq!(cached.root_id, "a");
832            assert_eq!(cached.status, ResolveStatus::Resolved);
833            assert_eq!(cached.algorithm_version, 1);
834        }
835    }
836
837    #[test]
838    fn update_persists_edges_for_gap_filled_ancestors() {
839        // A gap-filled intermediate carries its own parent pointer; update()
840        // must record ITS edge (not only the input clips'), so the stored graph
841        // stays connected and a later run resolves through it without a fetch.
842        let child = Clip {
843            id: "child".into(),
844            title: "Cover".into(),
845            clip_type: "gen".into(),
846            task: "cover".into(),
847            cover_clip_id: "mid".into(),
848            edited_clip_id: "mid".into(),
849            ..Default::default()
850        };
851        let mid = Clip {
852            id: "mid".into(),
853            title: "Mid".into(),
854            clip_type: "gen".into(),
855            task: "cover".into(),
856            cover_clip_id: "root".into(),
857            edited_clip_id: "root".into(),
858            ..Default::default()
859        };
860        let mut roots = HashMap::new();
861        roots.insert(
862            "child".to_owned(),
863            RootInfo {
864                root_id: "root".into(),
865                root_title: "Original".into(),
866                status: ResolveStatus::Resolved,
867            },
868        );
869        let resolution = Resolution {
870            roots,
871            gap_filled: vec![mid],
872            bridges: Vec::new(),
873        };
874        let mut store = LineageStore::new();
875        store.update(std::slice::from_ref(&child), &resolution, "now");
876
877        // The gap-filled ancestor's own edge is persisted (pre-fix it was not).
878        let mid_edge = edge(&store, "mid", "root");
879        assert_eq!(mid_edge.role, EdgeRole::Primary);
880        assert_eq!(mid_edge.ordinal, 0);
881        // Both hops are now reachable from the archive for a later resolve.
882        let archived = store.archived_parents();
883        assert_eq!(archived.get("child").map(String::as_str), Some("mid"));
884        assert_eq!(archived.get("mid").map(String::as_str), Some("root"));
885    }
886
887    #[test]
888    fn update_persists_bridges_as_edges() {
889        // A parent-endpoint bridge has no clip of its own, so it is persisted
890        // directly as a primary edge to keep that hop durable.
891        let child = Clip {
892            id: "child".into(),
893            title: "Cover".into(),
894            clip_type: "gen".into(),
895            task: "cover".into(),
896            cover_clip_id: "gone".into(),
897            edited_clip_id: "gone".into(),
898            ..Default::default()
899        };
900        let mut roots = HashMap::new();
901        roots.insert(
902            "child".to_owned(),
903            RootInfo {
904                root_id: "found".into(),
905                root_title: String::new(),
906                status: ResolveStatus::External,
907            },
908        );
909        let resolution = Resolution {
910            roots,
911            gap_filled: Vec::new(),
912            bridges: vec![("gone".to_owned(), "found".to_owned())],
913        };
914        let mut store = LineageStore::new();
915        store.update(std::slice::from_ref(&child), &resolution, "now");
916
917        let bridged = edge(&store, "gone", "found");
918        assert_eq!(bridged.source_field, "parent_endpoint");
919        assert_eq!(bridged.role, EdgeRole::Primary);
920        assert_eq!(bridged.ordinal, 0);
921        assert_eq!(
922            store.archived_parents().get("gone").map(String::as_str),
923            Some("found")
924        );
925    }
926
927    #[test]
928    fn archived_parents_maps_children_to_primary_parents_only() {
929        let mut store = LineageStore::new();
930        store.update(&chain_clips(), &chain_resolution(), "now");
931        let archived = store.archived_parents();
932        assert_eq!(archived.get("c").map(String::as_str), Some("b"));
933        assert_eq!(archived.get("b").map(String::as_str), Some("a"));
934        assert!(
935            !archived.contains_key("a"),
936            "a root has no primary parent edge"
937        );
938    }
939
940    #[test]
941    fn update_persists_attribution_edges_without_polluting_resolution() {
942        // A clip whose clip_roots point at a different node than its structural
943        // parent: the attribution edge is stored (role Secondary, open slug),
944        // but it must NOT be read by archived_parents (which seeds resolution)
945        // nor appear in the resolution cache's root set.
946        let child = Clip {
947            id: "child".into(),
948            title: "Remix".into(),
949            clip_type: "gen".into(),
950            task: "cover".into(),
951            cover_clip_id: "struct-parent".into(),
952            edited_clip_id: "struct-parent".into(),
953            handle: "me".into(),
954            clip_attribution_type: "remix".into(),
955            clip_roots: vec![crate::model::ClipRoot {
956                id: "attr-root".into(),
957                handle: "me".into(),
958                ..Default::default()
959            }],
960            ..Default::default()
961        };
962        let mut roots = HashMap::new();
963        roots.insert(
964            "child".to_owned(),
965            RootInfo {
966                root_id: "struct-parent".into(),
967                root_title: "Structural Root".into(),
968                status: ResolveStatus::Resolved,
969            },
970        );
971        let resolution = Resolution {
972            roots,
973            gap_filled: Vec::new(),
974            bridges: Vec::new(),
975        };
976        let mut store = LineageStore::new();
977        store.update(std::slice::from_ref(&child), &resolution, "now");
978
979        // The attribution edge is stored as a Secondary with the open slug.
980        let attr = edge(&store, "child", "attr-root");
981        assert_eq!(attr.edge_type, "remix");
982        assert_eq!(attr.role, EdgeRole::Secondary);
983        assert_eq!(attr.ordinal, 0);
984        assert_eq!(attr.source_field, "clip_roots");
985
986        // The structural edge is separate and unaffected.
987        let structural = edge(&store, "child", "struct-parent");
988        assert_eq!(structural.role, EdgeRole::Primary);
989
990        // Deletion/resolution safety: the attribution edge never seeds a walk.
991        let archived = store.archived_parents();
992        assert_eq!(
993            archived.get("child").map(String::as_str),
994            Some("struct-parent"),
995            "archived_parents reads only the structural primary, never clip_roots"
996        );
997        assert_eq!(
998            store.get_root("child").unwrap().root_id,
999            "struct-parent",
1000            "the resolution cache roots at the structural parent, not the attribution root"
1001        );
1002    }
1003
1004    #[test]
1005    fn update_defaults_a_blank_attribution_type_to_attribution() {
1006        // clip_roots present with a blank clip_attribution_type still records an
1007        // edge, slugged "attribution" so it always carries a type.
1008        let child = Clip {
1009            id: "child".into(),
1010            title: "Remix".into(),
1011            handle: "me".into(),
1012            clip_attribution_type: "".into(),
1013            clip_roots: vec![crate::model::ClipRoot {
1014                id: "attr-root".into(),
1015                handle: "me".into(),
1016                ..Default::default()
1017            }],
1018            ..Default::default()
1019        };
1020        let mut roots = HashMap::new();
1021        roots.insert(
1022            "child".to_owned(),
1023            RootInfo {
1024                root_id: "child".into(),
1025                root_title: "Remix".into(),
1026                status: ResolveStatus::Resolved,
1027            },
1028        );
1029        let resolution = Resolution {
1030            roots,
1031            gap_filled: Vec::new(),
1032            bridges: Vec::new(),
1033        };
1034        let mut store = LineageStore::new();
1035        store.update(std::slice::from_ref(&child), &resolution, "now");
1036        assert_eq!(edge(&store, "child", "attr-root").edge_type, "attribution");
1037    }
1038
1039    #[test]
1040    fn normalise_slug_lowercases_joins_and_defaults() {
1041        assert_eq!(normalise_slug("remix"), "remix");
1042        assert_eq!(normalise_slug("Remix Cover"), "remix_cover");
1043        assert_eq!(
1044            normalise_slug("  Remix   Reuse Style "),
1045            "remix_reuse_style"
1046        );
1047        assert_eq!(normalise_slug(""), "attribution");
1048        assert_eq!(normalise_slug("   "), "attribution");
1049    }
1050
1051    #[test]
1052    fn album_for_id_matches_context_for_and_handles_unknown() {
1053        let mut store = LineageStore::new();
1054        store.update(&chain_clips(), &chain_resolution(), "now");
1055
1056        // A child folds under its differently-titled root, agreeing with the
1057        // live-clip rule via context_for.
1058        assert_eq!(store.album_for_id("c"), "Root");
1059        let cover = &chain_clips()[0];
1060        assert_eq!(
1061            store.album_for_id("c"),
1062            store.context_for(cover).album(&cover.title)
1063        );
1064        // The root folders under its own title.
1065        assert_eq!(store.album_for_id("a"), "Root");
1066        // An id absent from the store folds to an empty own title.
1067        assert_eq!(store.album_for_id("missing"), "");
1068    }
1069
1070    #[test]
1071    fn serde_roundtrip_preserves_a_relational_shape() {
1072        let mut store = LineageStore::new();
1073        store.update(&chain_clips(), &chain_resolution(), "now");
1074
1075        let json = serde_json::to_string(&store).unwrap();
1076        let back: LineageStore = serde_json::from_str(&json).unwrap();
1077        assert_eq!(store, back);
1078
1079        let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1080        assert_eq!(value.get("schema_version").unwrap(), 1);
1081        assert!(value.get("nodes").unwrap().is_object());
1082        assert!(value.get("edges").unwrap().is_array());
1083        assert!(value.get("resolution_cache").unwrap().is_object());
1084        assert!(value.get("edge_index").is_none());
1085
1086        // Relational, not adjacency: a node carries no edges/parent of its own,
1087        // and an edge is a flat row keyed by child and parent.
1088        let node = value.get("nodes").unwrap().get("c").unwrap();
1089        assert!(node.get("edges").is_none());
1090        assert!(node.get("parent_id").is_none());
1091        let first_edge = value.get("edges").unwrap().get(0).unwrap();
1092        assert!(first_edge.get("child_id").is_some());
1093        assert!(first_edge.get("parent_id").is_some());
1094    }
1095
1096    #[test]
1097    fn album_overrides_are_runtime_only_and_never_persist() {
1098        // Overrides come from config each run, so they must not serialise into
1099        // the durable graph or survive a round-trip (they would then outlive the
1100        // config entry that set them).
1101        let mut store = LineageStore::new();
1102        store.update(&chain_clips(), &chain_resolution(), "now");
1103        store.set_album_overrides(
1104            [("a".to_owned(), "Preferred".to_owned())]
1105                .into_iter()
1106                .collect(),
1107        );
1108
1109        let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1110        assert!(value.get("album_overrides").is_none());
1111
1112        let json = serde_json::to_string(&store).unwrap();
1113        let back: LineageStore = serde_json::from_str(&json).unwrap();
1114        assert!(back.album_overrides.is_empty());
1115        assert_eq!(back.album_for_id("c"), "Root");
1116    }
1117
1118    #[test]
1119    fn update_is_idempotent_bar_last_seen() {
1120        let clips = chain_clips();
1121        let resolution = chain_resolution();
1122        let mut store = LineageStore::new();
1123        store.update(&clips, &resolution, "first");
1124        let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
1125        let edge_count = store.edges.len();
1126
1127        store.update(&clips, &resolution, "second");
1128
1129        // No new nodes, edges, or cache rows: the second run only refreshes.
1130        assert_eq!(
1131            store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
1132            node_ids
1133        );
1134        assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
1135        assert_eq!(store.resolution_cache.len(), 3);
1136
1137        // first_seen_at sticks; last_seen_at advances.
1138        let cover = store.node("c").unwrap();
1139        assert_eq!(cover.first_seen_at, "first");
1140        assert_eq!(cover.last_seen_at, "second");
1141        let cb = edge(&store, "c", "b");
1142        assert_eq!(cb.first_seen_at, "first");
1143        assert_eq!(cb.last_seen_at, "second");
1144        // Root ids are stable across the re-run.
1145        assert_eq!(store.get_root("c").unwrap().root_id, "a");
1146    }
1147
1148    #[test]
1149    fn update_after_roundtrip_rebuilds_edge_index_without_duplicates() {
1150        let clips = chain_clips();
1151        let resolution = chain_resolution();
1152
1153        let mut store = LineageStore::new();
1154        store.update(&clips, &resolution, "first");
1155
1156        let json = serde_json::to_string(&store).unwrap();
1157        let mut store: LineageStore = serde_json::from_str(&json).unwrap();
1158
1159        store.update(&clips, &resolution, "second");
1160
1161        assert_eq!(store.edges.len(), 2);
1162        let cb = edge(&store, "c", "b");
1163        assert_eq!(cb.first_seen_at, "first");
1164        assert_eq!(cb.last_seen_at, "second");
1165        let ba = edge(&store, "b", "a");
1166        assert_eq!(ba.first_seen_at, "first");
1167        assert_eq!(ba.last_seen_at, "second");
1168    }
1169
1170    #[test]
1171    fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
1172        let mut store = LineageStore::new();
1173        store.update(&chain_clips(), &chain_resolution(), "first");
1174        assert_eq!(store.get_root("c").unwrap().status, ResolveStatus::Resolved);
1175
1176        // A later run where `c` fails to resolve (a transient gap-fill miss)
1177        // and a brand-new clip `d` that only reaches an external boundary.
1178        let child = Clip {
1179            id: "c".into(),
1180            title: "Cover".into(),
1181            clip_type: "gen".into(),
1182            task: "cover".into(),
1183            cover_clip_id: "b".into(),
1184            edited_clip_id: "b".into(),
1185            ..Default::default()
1186        };
1187        let mut roots = HashMap::new();
1188        roots.insert(
1189            "c".to_owned(),
1190            RootInfo {
1191                root_id: "elsewhere".into(),
1192                root_title: String::new(),
1193                status: ResolveStatus::External,
1194            },
1195        );
1196        roots.insert(
1197            "d".to_owned(),
1198            RootInfo {
1199                root_id: "boundary".into(),
1200                root_title: String::new(),
1201                status: ResolveStatus::External,
1202            },
1203        );
1204        let resolution = Resolution {
1205            roots,
1206            gap_filled: Vec::new(),
1207            bridges: Vec::new(),
1208        };
1209        store.update(&[child], &resolution, "second");
1210
1211        // The resolved root of `c` is kept, not downgraded.
1212        let cached = store.get_root("c").unwrap();
1213        assert_eq!(cached.root_id, "a");
1214        assert_eq!(cached.status, ResolveStatus::Resolved);
1215        assert_eq!(cached.computed_at, "first");
1216        // A never-resolved clip records its last-known non-resolved status.
1217        let d = store.get_root("d").unwrap();
1218        assert_eq!(d.root_id, "boundary");
1219        assert_eq!(d.status, ResolveStatus::External);
1220    }
1221
1222    #[test]
1223    fn gap_filled_trashed_ancestor_is_a_durable_node() {
1224        // The trashed ancestor is not among `clips`; it arrives only via the
1225        // resolution's gap_filled set, yet must be archived as a node so its
1226        // lineage survives Suno's purge (HARDENING H4 / L2).
1227        let child = Clip {
1228            id: "c".into(),
1229            title: "Cover".into(),
1230            clip_type: "gen".into(),
1231            task: "cover".into(),
1232            cover_clip_id: "t".into(),
1233            edited_clip_id: "t".into(),
1234            ..Default::default()
1235        };
1236        let trashed = Clip {
1237            id: "t".into(),
1238            title: "Trashed Original".into(),
1239            clip_type: "gen".into(),
1240            is_trashed: true,
1241            ..Default::default()
1242        };
1243        let mut roots = HashMap::new();
1244        roots.insert(
1245            "c".to_owned(),
1246            RootInfo {
1247                root_id: "t".into(),
1248                root_title: "Trashed Original".into(),
1249                status: ResolveStatus::Resolved,
1250            },
1251        );
1252        let resolution = Resolution {
1253            roots,
1254            gap_filled: vec![trashed],
1255            bridges: Vec::new(),
1256        };
1257        store_update_and_assert_trashed(child, resolution);
1258    }
1259
1260    fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
1261        let mut store = LineageStore::new();
1262        store.update(&[child], &resolution, "now");
1263
1264        let node = store
1265            .node("t")
1266            .expect("trashed ancestor should be archived");
1267        assert!(node.is_trashed);
1268        assert_eq!(node.title, "Trashed Original");
1269        // The child roots at the trashed ancestor.
1270        assert_eq!(store.get_root("c").unwrap().root_id, "t");
1271    }
1272
1273    #[test]
1274    fn partial_json_loads_with_defaults() {
1275        // An older/partial file missing whole collections and per-row fields
1276        // still loads: container and row defaults fill the gaps.
1277        let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
1278        let store: LineageStore = serde_json::from_str(json).unwrap();
1279        assert_eq!(store.schema_version, 1);
1280        let node = store.node("x").unwrap();
1281        assert_eq!(node.title, "Kept");
1282        assert_eq!(node.status, NodeStatus::Observed);
1283        assert_eq!(store.edges[0].status, EdgeStatus::Active);
1284        assert!(store.resolution_cache.is_empty());
1285        // The album-art collection is additive: a store written before folder
1286        // art existed loads with no albums and no folder art.
1287        assert!(store.albums.is_empty());
1288        assert!(store.album_art("x").is_none());
1289        // The playlist collection is likewise additive: absent in an older
1290        // store, it defaults empty (HARDENING B2: no stored playlist means no
1291        // reconcile ever treats one as stale).
1292        assert!(store.playlists.is_empty());
1293        assert!(store.playlist("x").is_none());
1294    }
1295
1296    #[test]
1297    fn context_for_roots_a_remix_at_its_stored_ancestor() {
1298        let mut store = LineageStore::new();
1299        store.update(&chain_clips(), &chain_resolution(), "now");
1300
1301        let child = &chain_clips()[0]; // "c", a cover of "b"
1302        let ctx = store.context_for(child);
1303        assert_eq!(ctx.root_id, "a");
1304        assert_eq!(ctx.root_title, "Root");
1305        assert_eq!(ctx.parent_id, "b");
1306        assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1307        assert_eq!(ctx.status, ResolveStatus::Resolved);
1308        // The remix folders under its resolved root's album.
1309        assert_eq!(ctx.album("Cover"), "Root");
1310    }
1311
1312    #[test]
1313    fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
1314        let mut store = LineageStore::new();
1315        store.update(&chain_clips(), &chain_resolution(), "now");
1316
1317        let root = &chain_clips()[2]; // "a"
1318        let ctx = store.context_for(root);
1319        assert_eq!(ctx.root_id, "a");
1320        assert_eq!(ctx.root_title, "Root");
1321        assert_eq!(ctx.parent_id, "");
1322        assert_eq!(ctx.edge_type, None);
1323        assert_eq!(ctx.album("Root"), "Root");
1324    }
1325
1326    #[test]
1327    fn context_for_tags_the_root_year_across_a_calendar_boundary() {
1328        // A December root with a January revision: both tag the root's year, so
1329        // the album groups under one year even across the boundary.
1330        let clips = vec![
1331            Clip {
1332                id: "child".into(),
1333                title: "Revision".into(),
1334                clip_type: "gen".into(),
1335                task: "cover".into(),
1336                created_at: "2024-01-02T08:00:00Z".into(),
1337                cover_clip_id: "root".into(),
1338                edited_clip_id: "root".into(),
1339                ..Default::default()
1340            },
1341            Clip {
1342                id: "root".into(),
1343                title: "Origin".into(),
1344                clip_type: "gen".into(),
1345                created_at: "2023-12-30T23:00:00Z".into(),
1346                ..Default::default()
1347            },
1348        ];
1349        let mut roots = HashMap::new();
1350        for id in ["child", "root"] {
1351            roots.insert(
1352                id.to_owned(),
1353                RootInfo {
1354                    root_id: "root".into(),
1355                    root_title: "Origin".into(),
1356                    status: ResolveStatus::Resolved,
1357                },
1358            );
1359        }
1360        let resolution = Resolution {
1361            roots,
1362            gap_filled: Vec::new(),
1363            bridges: Vec::new(),
1364        };
1365        let mut store = LineageStore::new();
1366        store.update(&clips, &resolution, "now");
1367
1368        let child_ctx = store.context_for(&clips[0]);
1369        assert_eq!(child_ctx.root_id, "root");
1370        assert_eq!(child_ctx.root_date, "2023-12-30T23:00:00Z");
1371        // The January child tags the December root's year, not its own 2024.
1372        assert_eq!(child_ctx.year(&clips[0].created_at), "2023");
1373
1374        // The root tags its own year (the same year).
1375        let root_ctx = store.context_for(&clips[1]);
1376        assert_eq!(root_ctx.year(&clips[1].created_at), "2023");
1377    }
1378
1379    #[test]
1380    fn context_for_an_unknown_clip_is_self_rooted() {
1381        let store = LineageStore::new();
1382        let orphan = Clip {
1383            id: "z".into(),
1384            title: "Lonely".into(),
1385            ..Default::default()
1386        };
1387        let ctx = store.context_for(&orphan);
1388        assert_eq!(ctx.root_id, "z");
1389        assert_eq!(ctx.root_title, "Lonely");
1390        assert_eq!(ctx.parent_id, "");
1391        assert_eq!(ctx.status, ResolveStatus::Resolved);
1392    }
1393
1394    #[test]
1395    fn context_for_retains_a_purged_ancestor_album() {
1396        // The trashed ancestor arrives only via gap_filled, yet a later run
1397        // whose resolver failed (modelled here by simply not re-updating) must
1398        // still root the child at the archived ancestor with its stored title
1399        // (HARDENING H3).
1400        let child = Clip {
1401            id: "c".into(),
1402            title: "Cover".into(),
1403            clip_type: "gen".into(),
1404            task: "cover".into(),
1405            cover_clip_id: "t".into(),
1406            edited_clip_id: "t".into(),
1407            ..Default::default()
1408        };
1409        let trashed = Clip {
1410            id: "t".into(),
1411            title: "Trashed Original".into(),
1412            clip_type: "gen".into(),
1413            is_trashed: true,
1414            ..Default::default()
1415        };
1416        let mut roots = HashMap::new();
1417        roots.insert(
1418            "c".to_owned(),
1419            RootInfo {
1420                root_id: "t".into(),
1421                root_title: "Trashed Original".into(),
1422                status: ResolveStatus::Resolved,
1423            },
1424        );
1425        let resolution = Resolution {
1426            roots,
1427            gap_filled: vec![trashed],
1428            bridges: Vec::new(),
1429        };
1430        let mut store = LineageStore::new();
1431        store.update(std::slice::from_ref(&child), &resolution, "now");
1432
1433        let ctx = store.context_for(&child);
1434        assert_eq!(ctx.root_id, "t");
1435        assert_eq!(ctx.root_title, "Trashed Original");
1436        assert_eq!(ctx.album("Cover"), "Trashed Original");
1437    }
1438
1439    #[test]
1440    fn colliding_root_titles_flags_only_shared_distinct_roots() {
1441        // Two distinct roots share the title "Break Through"; a third root is
1442        // unique; a child of a shared root does not add a spurious distinct root.
1443        let clips = vec![
1444            Clip {
1445                id: "r1".into(),
1446                title: "Break Through".into(),
1447                clip_type: "gen".into(),
1448                ..Default::default()
1449            },
1450            Clip {
1451                id: "r2".into(),
1452                title: "Break Through".into(),
1453                clip_type: "gen".into(),
1454                ..Default::default()
1455            },
1456            Clip {
1457                id: "r3".into(),
1458                title: "Solo".into(),
1459                clip_type: "gen".into(),
1460                ..Default::default()
1461            },
1462            Clip {
1463                id: "c1".into(),
1464                title: "Break Through".into(),
1465                clip_type: "gen".into(),
1466                task: "cover".into(),
1467                cover_clip_id: "r1".into(),
1468                edited_clip_id: "r1".into(),
1469                ..Default::default()
1470            },
1471        ];
1472        let mut roots = HashMap::new();
1473        for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1474            let title = if root == "r3" {
1475                "Solo"
1476            } else {
1477                "Break Through"
1478            };
1479            roots.insert(
1480                id.to_owned(),
1481                RootInfo {
1482                    root_id: root.into(),
1483                    root_title: title.into(),
1484                    status: ResolveStatus::Resolved,
1485                },
1486            );
1487        }
1488        let resolution = Resolution {
1489            roots,
1490            gap_filled: Vec::new(),
1491            bridges: Vec::new(),
1492        };
1493        let mut store = LineageStore::new();
1494        store.update(&clips, &resolution, "now");
1495
1496        let colliding = store.colliding_root_titles();
1497        assert!(colliding.contains("Break Through"));
1498        assert!(!colliding.contains("Solo"));
1499        assert_eq!(colliding.len(), 1);
1500    }
1501
1502    /// Build the two-distinct-root store used by the disambiguation tests: `r1`
1503    /// and `r2` are separate `gen` roots titled `t1`/`t2`.
1504    fn two_root_store(t1: &str, t2: &str) -> LineageStore {
1505        let clips = vec![
1506            Clip {
1507                id: "r1".into(),
1508                title: t1.into(),
1509                clip_type: "gen".into(),
1510                ..Default::default()
1511            },
1512            Clip {
1513                id: "r2".into(),
1514                title: t2.into(),
1515                clip_type: "gen".into(),
1516                ..Default::default()
1517            },
1518        ];
1519        let mut roots = HashMap::new();
1520        roots.insert(
1521            "r1".to_owned(),
1522            RootInfo {
1523                root_id: "r1".into(),
1524                root_title: t1.into(),
1525                status: ResolveStatus::Resolved,
1526            },
1527        );
1528        roots.insert(
1529            "r2".to_owned(),
1530            RootInfo {
1531                root_id: "r2".into(),
1532                root_title: t2.into(),
1533                status: ResolveStatus::Resolved,
1534            },
1535        );
1536        let mut store = LineageStore::new();
1537        store.update(
1538            &clips,
1539            &Resolution {
1540                roots,
1541                gap_filled: Vec::new(),
1542                bridges: Vec::new(),
1543            },
1544            "now",
1545        );
1546        store
1547    }
1548
1549    #[test]
1550    fn album_override_flows_into_context_tag_hash_and_index() {
1551        // Override the lineage root's album name; every album-bearing surface
1552        // (the resolved context, the ALBUM tag, the change hash, and the
1553        // id-only index) must reflect the preferred name from one source.
1554        let clips = chain_clips();
1555        let mut store = LineageStore::new();
1556        store.update(&clips, &chain_resolution(), "now");
1557
1558        let cover = &clips[0]; // "Cover", rooted at "a" ("Root")
1559        let before_hash = crate::hash::meta_hash(cover, &store.context_for(cover));
1560
1561        store.set_album_overrides(
1562            [("a".to_owned(), "Preferred Name".to_owned())]
1563                .into_iter()
1564                .collect(),
1565        );
1566
1567        // Every clip in the lineage now folders under the preferred album.
1568        for id in ["a", "b", "c"] {
1569            let clip = clips.iter().find(|c| c.id == id).unwrap();
1570            let ctx = store.context_for(clip);
1571            assert_eq!(ctx.album(&clip.title), "Preferred Name");
1572            assert_eq!(store.album_for_id(id), "Preferred Name");
1573        }
1574
1575        // The ALBUM tag follows the override.
1576        let ctx = store.context_for(cover);
1577        let meta = crate::tag::TrackMetadata::from_clip(cover, &ctx);
1578        assert_eq!(meta.album, "Preferred Name");
1579
1580        // The change hash shifts, so reconcile retags the file in place.
1581        let after_hash = crate::hash::meta_hash(cover, &ctx);
1582        assert_ne!(before_hash, after_hash);
1583    }
1584
1585    #[test]
1586    fn empty_album_override_is_ignored() {
1587        // A blank value must never blank an album; the derived title stands.
1588        let clips = chain_clips();
1589        let mut store = LineageStore::new();
1590        store.update(&clips, &chain_resolution(), "now");
1591        store.set_album_overrides([("a".to_owned(), "   ".to_owned())].into_iter().collect());
1592        assert_eq!(store.album_for_id("c"), "Root");
1593    }
1594
1595    #[test]
1596    fn album_override_creates_a_collision_that_disambiguates() {
1597        // Two uniquely titled roots collide once one is renamed onto the other.
1598        let mut store = two_root_store("Alpha", "Beta");
1599        assert!(store.colliding_root_titles().is_empty());
1600
1601        store.set_album_overrides(
1602            [("r2".to_owned(), "Alpha".to_owned())]
1603                .into_iter()
1604                .collect(),
1605        );
1606        let colliding = store.colliding_root_titles();
1607        assert!(colliding.contains("Alpha"));
1608        assert_eq!(colliding.len(), 1);
1609    }
1610
1611    #[test]
1612    fn album_override_resolves_a_natural_collision() {
1613        // Two roots share a title; renaming one apart settles the collision.
1614        let mut store = two_root_store("Break Through", "Break Through");
1615        assert!(store.colliding_root_titles().contains("Break Through"));
1616
1617        store.set_album_overrides(
1618            [("r2".to_owned(), "Second Wind".to_owned())]
1619                .into_iter()
1620                .collect(),
1621        );
1622        assert!(store.colliding_root_titles().is_empty());
1623    }
1624
1625    /// Insert a cache-only root: an entry in the resolution cache whose root_id
1626    /// has NO backing node (an external or not-yet-archived root). Such a root
1627    /// still folders under a configured override, so it must be visible to
1628    /// collision detection.
1629    fn insert_cache_only_root(store: &mut LineageStore, root_id: &str) {
1630        store.resolution_cache.insert(
1631            root_id.to_owned(),
1632            CacheEntry {
1633                root_id: root_id.to_owned(),
1634                status: ResolveStatus::External,
1635                algorithm_version: 1,
1636                computed_at: "now".to_owned(),
1637            },
1638        );
1639        // Direct cache mutation bypasses `update`, so mirror what a real run does
1640        // after loading: refresh the derived eligible-root set.
1641        store.refresh_eligible_roots();
1642    }
1643
1644    #[test]
1645    fn override_on_node_less_root_collides_with_a_real_root() {
1646        // A node-less (cache-only) root overridden onto a real root's title must
1647        // be flagged as colliding, so both albums get the [root_id8] suffix and
1648        // two distinct roots never share one folder.
1649        let mut store = LineageStore::new();
1650        store.update(
1651            std::slice::from_ref(&Clip {
1652                id: "realroot".into(),
1653                title: "Shared".into(),
1654                clip_type: "gen".into(),
1655                ..Default::default()
1656            }),
1657            &Resolution {
1658                roots: [(
1659                    "realroot".to_owned(),
1660                    RootInfo {
1661                        root_id: "realroot".into(),
1662                        root_title: "Shared".into(),
1663                        status: ResolveStatus::Resolved,
1664                    },
1665                )]
1666                .into_iter()
1667                .collect(),
1668                gap_filled: Vec::new(),
1669                bridges: Vec::new(),
1670            },
1671            "now",
1672        );
1673        insert_cache_only_root(&mut store, "extroot");
1674        store.set_album_overrides(
1675            [("extroot".to_owned(), "Shared".to_owned())]
1676                .into_iter()
1677                .collect(),
1678        );
1679
1680        let colliding = store.colliding_root_titles();
1681        assert!(
1682            colliding.contains("Shared"),
1683            "a node-less overridden root must still be seen by collision detection"
1684        );
1685    }
1686
1687    #[test]
1688    fn two_node_less_roots_overridden_to_same_name_collide() {
1689        let mut store = LineageStore::new();
1690        insert_cache_only_root(&mut store, "extone");
1691        insert_cache_only_root(&mut store, "exttwo");
1692        store.set_album_overrides(
1693            [
1694                ("extone".to_owned(), "Shared".to_owned()),
1695                ("exttwo".to_owned(), "Shared".to_owned()),
1696            ]
1697            .into_iter()
1698            .collect(),
1699        );
1700        assert!(store.colliding_root_titles().contains("Shared"));
1701    }
1702
1703    #[test]
1704    fn colliding_node_less_overrides_keep_album_art_paths_distinct() {
1705        // End-to-end guard: two node-less roots overridden to one name must not
1706        // collapse their album-art (folder.jpg) onto a single shared path. The
1707        // colliding set drives naming to append [root_id8], giving each root its
1708        // own album folder and so its own folder.jpg.
1709        let mut store = LineageStore::new();
1710        insert_cache_only_root(&mut store, "aaaaaaaa-root-one");
1711        insert_cache_only_root(&mut store, "bbbbbbbb-root-two");
1712        store.set_album_overrides(
1713            [
1714                ("aaaaaaaa-root-one".to_owned(), "Shared".to_owned()),
1715                ("bbbbbbbb-root-two".to_owned(), "Shared".to_owned()),
1716            ]
1717            .into_iter()
1718            .collect(),
1719        );
1720        let colliding = store.colliding_root_titles();
1721
1722        let clip_of = |id: &str| Clip {
1723            id: id.to_owned(),
1724            title: "Track".to_owned(),
1725            display_name: "alice".to_owned(),
1726            image_large_url: "https://art.example/large.jpg".to_owned(),
1727            ..Default::default()
1728        };
1729        let ctx_of = |root_id: &str| LineageContext {
1730            root_id: root_id.to_owned(),
1731            root_title: "Shared".to_owned(),
1732            root_date: String::new(),
1733            parent_id: String::new(),
1734            edge_type: None,
1735            status: ResolveStatus::Resolved,
1736        };
1737        let clip_a = clip_of("clipaaaa-1111");
1738        let clip_b = clip_of("clipbbbb-2222");
1739        let ctx_a = ctx_of("aaaaaaaa-root-one");
1740        let ctx_b = ctx_of("bbbbbbbb-root-two");
1741        let requests = [
1742            crate::naming::NamingRequest {
1743                clip: &clip_a,
1744                lineage: &ctx_a,
1745            },
1746            crate::naming::NamingRequest {
1747                clip: &clip_b,
1748                lineage: &ctx_b,
1749            },
1750        ];
1751        let names = crate::naming::render_clip_names(
1752            &requests,
1753            &crate::naming::NamingConfig::default(),
1754            &colliding,
1755        );
1756
1757        let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
1758            crate::reconcile::Desired {
1759                clip: clip.clone(),
1760                lineage: ctx.clone(),
1761                path: format!(
1762                    "{}.flac",
1763                    crate::desired::rel_to_string(&name.relative_path)
1764                ),
1765                format: crate::AudioFormat::Flac,
1766                meta_hash: String::new(),
1767                art_hash: String::new(),
1768                modes: vec![crate::reconcile::SourceMode::Mirror],
1769                trashed: false,
1770                private: false,
1771                artifacts: Vec::new(),
1772                stems: None,
1773            }
1774        };
1775        let desired = vec![
1776            desired_of(&clip_a, &ctx_a, &names[0]),
1777            desired_of(&clip_b, &ctx_b, &names[1]),
1778        ];
1779
1780        let albums = crate::reconcile::album_desired(
1781            &desired,
1782            false,
1783            false,
1784            crate::ffmpeg::WebpEncodeSettings::default(),
1785        );
1786        assert_eq!(albums.len(), 2, "each distinct root is its own album");
1787        let jpg_paths: Vec<String> = albums
1788            .iter()
1789            .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
1790            .collect();
1791        assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
1792        assert_ne!(
1793            jpg_paths[0], jpg_paths[1],
1794            "colliding roots must not share one folder.jpg path"
1795        );
1796    }
1797
1798    #[test]
1799    fn override_on_uncached_selected_root_is_ignored_and_keeps_albums_distinct() {
1800        // Residual-bug guard. On a resolution-FAILED run (no store.update), a
1801        // newly-listed clip is uncached, so context_for falls back to a
1802        // self-root (root_id = clip.id) that colliding_root_titles cannot see.
1803        // An override on that id must NOT apply this run, or the clip would
1804        // render/tag under a stored root's title with no [root_id8] suffix and
1805        // collapse two distinct albums onto one folder (and one folder.jpg).
1806        let mut store = LineageStore::new();
1807        store.update(
1808            std::slice::from_ref(&Clip {
1809                id: "realroot".into(),
1810                title: "Shared".into(),
1811                clip_type: "gen".into(),
1812                ..Default::default()
1813            }),
1814            &Resolution {
1815                roots: [(
1816                    "realroot".to_owned(),
1817                    RootInfo {
1818                        root_id: "realroot".into(),
1819                        root_title: "Shared".into(),
1820                        status: ResolveStatus::Resolved,
1821                    },
1822                )]
1823                .into_iter()
1824                .collect(),
1825                gap_filled: Vec::new(),
1826                bridges: Vec::new(),
1827            },
1828            "now",
1829        );
1830        // A newly-listed clip that failed to resolve this run: it is NOT in the
1831        // cache, and config overrides its id onto the stored root's title.
1832        let new_clip = Clip {
1833            id: "newnewnew-9999".into(),
1834            title: "Solo Track".into(),
1835            display_name: "alice".into(),
1836            image_large_url: "https://art.example/large.jpg".into(),
1837            ..Default::default()
1838        };
1839        store.set_album_overrides(
1840            [("newnewnew-9999".to_owned(), "Shared".to_owned())]
1841                .into_iter()
1842                .collect(),
1843        );
1844
1845        // The uncached clip folders under its OWN title, not the override.
1846        let new_ctx = store.context_for(&new_clip);
1847        assert_eq!(new_ctx.root_id, "newnewnew-9999");
1848        assert_eq!(new_ctx.album(&new_clip.title), "Solo Track");
1849
1850        // Collision detection is unchanged: the stored root stands alone.
1851        assert!(store.colliding_root_titles().is_empty());
1852
1853        // Album-art paths for the two distinct roots stay distinct.
1854        let real_clip = Clip {
1855            id: "realroot".into(),
1856            title: "Shared".into(),
1857            display_name: "alice".into(),
1858            image_large_url: "https://art.example/large.jpg".into(),
1859            ..Default::default()
1860        };
1861        let real_ctx = store.context_for(&real_clip);
1862        let colliding = store.colliding_root_titles();
1863        let requests = [
1864            crate::naming::NamingRequest {
1865                clip: &real_clip,
1866                lineage: &real_ctx,
1867            },
1868            crate::naming::NamingRequest {
1869                clip: &new_clip,
1870                lineage: &new_ctx,
1871            },
1872        ];
1873        let names = crate::naming::render_clip_names(
1874            &requests,
1875            &crate::naming::NamingConfig::default(),
1876            &colliding,
1877        );
1878        let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
1879            crate::reconcile::Desired {
1880                clip: clip.clone(),
1881                lineage: ctx.clone(),
1882                path: format!(
1883                    "{}.flac",
1884                    crate::desired::rel_to_string(&name.relative_path)
1885                ),
1886                format: crate::AudioFormat::Flac,
1887                meta_hash: String::new(),
1888                art_hash: String::new(),
1889                modes: vec![crate::reconcile::SourceMode::Mirror],
1890                trashed: false,
1891                private: false,
1892                artifacts: Vec::new(),
1893                stems: None,
1894            }
1895        };
1896        let desired = vec![
1897            desired_of(&real_clip, &real_ctx, &names[0]),
1898            desired_of(&new_clip, &new_ctx, &names[1]),
1899        ];
1900        let albums = crate::reconcile::album_desired(
1901            &desired,
1902            false,
1903            false,
1904            crate::ffmpeg::WebpEncodeSettings::default(),
1905        );
1906        let jpg_paths: Vec<String> = albums
1907            .iter()
1908            .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
1909            .collect();
1910        assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
1911        assert_ne!(
1912            jpg_paths[0], jpg_paths[1],
1913            "an uncached override must not collapse two albums onto one path"
1914        );
1915    }
1916
1917    #[test]
1918    fn override_on_gap_filled_root_applies_to_children_and_collides() {
1919        // Round-3 regression. A gap-filled/archived root is a cache VALUE for its
1920        // children but never a cache KEY (see
1921        // resolve_roots_returns_gap_filled_ancestors_for_archival). An override
1922        // keyed by such a root must still apply to its children AND participate
1923        // in collision detection, so gating override-application on the cache
1924        // VALUE set (not the key set) is essential.
1925        let child = Clip {
1926            id: "childclip".into(),
1927            title: "Cover".into(),
1928            clip_type: "gen".into(),
1929            task: "cover".into(),
1930            cover_clip_id: "gaproot".into(),
1931            edited_clip_id: "gaproot".into(),
1932            ..Default::default()
1933        };
1934        let other_root = Clip {
1935            id: "otherroot".into(),
1936            title: "Preferred".into(),
1937            clip_type: "gen".into(),
1938            ..Default::default()
1939        };
1940        let gap_ancestor = Clip {
1941            id: "gaproot".into(),
1942            title: "Working Title".into(),
1943            clip_type: "gen".into(),
1944            ..Default::default()
1945        };
1946        let mut roots = HashMap::new();
1947        roots.insert(
1948            "childclip".to_owned(),
1949            RootInfo {
1950                root_id: "gaproot".into(),
1951                root_title: "Working Title".into(),
1952                status: ResolveStatus::Resolved,
1953            },
1954        );
1955        roots.insert(
1956            "otherroot".to_owned(),
1957            RootInfo {
1958                root_id: "otherroot".into(),
1959                root_title: "Preferred".into(),
1960                status: ResolveStatus::Resolved,
1961            },
1962        );
1963        let mut store = LineageStore::new();
1964        store.update(
1965            &[child.clone(), other_root],
1966            &Resolution {
1967                roots,
1968                gap_filled: vec![gap_ancestor],
1969                bridges: Vec::new(),
1970            },
1971            "now",
1972        );
1973        // "gaproot" is a node and a cache value, but NOT a cache key.
1974        assert!(store.node("gaproot").is_some());
1975        assert!(!store.resolution_cache.contains_key("gaproot"));
1976
1977        store.set_album_overrides(
1978            [("gaproot".to_owned(), "Preferred".to_owned())]
1979                .into_iter()
1980                .collect(),
1981        );
1982
1983        // The override on the gap-filled root reaches its child (would be
1984        // ignored under a cache-KEY gate).
1985        assert_eq!(store.context_for(&child).album(&child.title), "Preferred");
1986        assert_eq!(store.album_for_id("childclip"), "Preferred");
1987
1988        // And it participates in collision detection: two distinct roots now
1989        // resolve to "Preferred", so it is flagged.
1990        assert!(store.colliding_root_titles().contains("Preferred"));
1991    }
1992
1993    #[test]
1994    fn eligible_root_set_is_exactly_the_cache_value_domain() {
1995        // Tie-together guard: the set effective_root_title gates overrides on is
1996        // literally the set colliding_root_titles groups over (both read
1997        // eligible_root_ids), and refresh_eligible_roots computes it as the
1998        // non-empty root_id VALUES of the cache. If these drift, an override
1999        // could apply where a collision is invisible (or vice versa).
2000        let child = Clip {
2001            id: "childclip".into(),
2002            title: "Cover".into(),
2003            clip_type: "gen".into(),
2004            task: "cover".into(),
2005            cover_clip_id: "gaproot".into(),
2006            edited_clip_id: "gaproot".into(),
2007            ..Default::default()
2008        };
2009        let mut roots = HashMap::new();
2010        roots.insert(
2011            "childclip".to_owned(),
2012            RootInfo {
2013                root_id: "gaproot".into(),
2014                root_title: "Working Title".into(),
2015                status: ResolveStatus::Resolved,
2016            },
2017        );
2018        let mut store = LineageStore::new();
2019        store.update(
2020            std::slice::from_ref(&child),
2021            &Resolution {
2022                roots,
2023                gap_filled: vec![Clip {
2024                    id: "gaproot".into(),
2025                    title: "Working Title".into(),
2026                    clip_type: "gen".into(),
2027                    ..Default::default()
2028                }],
2029                bridges: Vec::new(),
2030            },
2031            "now",
2032        );
2033
2034        let expected: std::collections::HashSet<String> = store
2035            .resolution_cache
2036            .values()
2037            .map(|entry| entry.root_id.clone())
2038            .filter(|root_id| !root_id.is_empty())
2039            .collect();
2040        assert_eq!(*store.eligible_root_ids_for_test(), expected);
2041        // The gap-filled root is in the domain (a value), not because it is a key.
2042        assert!(store.eligible_root_ids_for_test().contains("gaproot"));
2043        assert!(!store.resolution_cache.contains_key("gaproot"));
2044    }
2045
2046    #[test]
2047    fn on_disk_slugs_are_byte_identical_to_the_legacy_string_literals() {
2048        // The typed enums must serialize to the SAME slug strings as the old
2049        // hand-written literals. Any change here would corrupt existing stores.
2050        let mut store = LineageStore::new();
2051        store.update(&chain_clips(), &chain_resolution(), "now");
2052
2053        let value = serde_json::to_value(&store).unwrap();
2054        let edges = value.get("edges").unwrap().as_array().unwrap();
2055
2056        // There are two edges: c->b (cover/primary) and b->a (remaster/primary).
2057        let primary_edge = edges
2058            .iter()
2059            .find(|e| e.get("child_id").unwrap() == "c")
2060            .unwrap();
2061        assert_eq!(primary_edge.get("role").unwrap(), "primary");
2062        assert_eq!(primary_edge.get("status").unwrap(), "active");
2063        assert_eq!(primary_edge.get("edge_type").unwrap(), "cover");
2064
2065        // Node status slug.
2066        let node = value.get("nodes").unwrap().get("c").unwrap();
2067        assert_eq!(node.get("status").unwrap(), "observed");
2068
2069        // Cache entry status slug.
2070        let cache = value.get("resolution_cache").unwrap();
2071        assert_eq!(cache.get("a").unwrap().get("status").unwrap(), "resolved");
2072
2073        // A non-resolved status also serialises to the correct slug.
2074        let mut store2 = LineageStore::new();
2075        let child = Clip {
2076            id: "x".into(),
2077            ..Default::default()
2078        };
2079        let mut roots = HashMap::new();
2080        roots.insert(
2081            "x".to_owned(),
2082            RootInfo {
2083                root_id: "ext".into(),
2084                root_title: String::new(),
2085                status: ResolveStatus::External,
2086            },
2087        );
2088        store2.update(
2089            std::slice::from_ref(&child),
2090            &Resolution {
2091                roots,
2092                gap_filled: Vec::new(),
2093                bridges: Vec::new(),
2094            },
2095            "now",
2096        );
2097        let v2 = serde_json::to_value(&store2).unwrap();
2098        assert_eq!(
2099            v2.get("resolution_cache")
2100                .unwrap()
2101                .get("x")
2102                .unwrap()
2103                .get("status")
2104                .unwrap(),
2105            "external"
2106        );
2107    }
2108
2109    #[test]
2110    fn serde_roundtrip_is_byte_identical() {
2111        // The typed enums must not change the wire format: a store serialised
2112        // and then re-serialised must produce the same bytes.
2113        let mut store = LineageStore::new();
2114        store.update(&chain_clips(), &chain_resolution(), "now");
2115
2116        let first = serde_json::to_string(&store).unwrap();
2117        let back: LineageStore = serde_json::from_str(&first).unwrap();
2118        let second = serde_json::to_string(&back).unwrap();
2119        assert_eq!(first, second, "round-trip must be byte-identical");
2120    }
2121
2122    #[test]
2123    fn existing_string_form_json_deserialises_correctly() {
2124        // Existing on-disk stores use the plain slug strings; the typed enums
2125        // must still parse them correctly after this refactor.
2126        let json = r#"{
2127            "nodes": {"a": {"title": "Root", "status": "observed"}},
2128            "edges": [{"child_id": "b", "parent_id": "a", "role": "primary", "status": "active", "edge_type": "cover"}],
2129            "resolution_cache": {"b": {"root_id": "a", "status": "resolved"}}
2130        }"#;
2131        let store: LineageStore = serde_json::from_str(json).unwrap();
2132        assert_eq!(store.node("a").unwrap().status, NodeStatus::Observed);
2133        assert_eq!(store.edges[0].role, EdgeRole::Primary);
2134        assert_eq!(store.edges[0].status, EdgeStatus::Active);
2135        assert_eq!(store.get_root("b").unwrap().status, ResolveStatus::Resolved);
2136        // archived_parents uses typed comparison: the loaded edge must be returned.
2137        let archived = store.archived_parents();
2138        assert_eq!(archived.get("b").map(String::as_str), Some("a"));
2139    }
2140
2141    #[test]
2142    fn full_store_json_is_stable_across_the_split() {
2143        // Golden anchor spanning ALL THREE domains at once: the lineage graph
2144        // (nodes + edges + resolution_cache), the album/playlist art state, and
2145        // the identity owner pin. If any field, serde attribute, key name,
2146        // nesting, or emitted key order ever drifts, this fails, guarding the
2147        // on-disk `.suno-lineage.json` format the domain split must not change.
2148        let mut store = LineageStore::new();
2149        store.update(&chain_clips(), &chain_resolution(), "now");
2150        store.albums.insert(
2151            "a".to_owned(),
2152            AlbumArt {
2153                folder_jpg: Some(crate::ArtifactState {
2154                    path: "alice/Root/folder.jpg".to_owned(),
2155                    hash: "jpg-h".to_owned(),
2156                }),
2157                folder_webp: None,
2158                folder_mp4: None,
2159            },
2160        );
2161        store.playlists.insert(
2162            "liked".to_owned(),
2163            PlaylistState {
2164                name: "Liked".to_owned(),
2165                path: "Liked.m3u8".to_owned(),
2166                hash: "pl-h".to_owned(),
2167            },
2168        );
2169        store.pin_owner(Owner {
2170            user_id: "user_a".to_owned(),
2171            display_name: "Alice".to_owned(),
2172        });
2173
2174        // (1) Exact key set, nesting, and values across every domain.
2175        let value = serde_json::to_value(&store).unwrap();
2176        assert_eq!(
2177            value,
2178            serde_json::json!({
2179                "schema_version": 1,
2180                "nodes": {
2181                    "a": {"title": "Root", "created_at": "t0", "clip_type": "gen", "task": "", "is_remix": false, "is_trashed": false, "status": "observed", "first_seen_at": "now", "last_seen_at": "now"},
2182                    "b": {"title": "Remaster", "created_at": "t1", "clip_type": "upsample", "task": "upsample", "is_remix": false, "is_trashed": false, "status": "observed", "first_seen_at": "now", "last_seen_at": "now"},
2183                    "c": {"title": "Cover", "created_at": "t2", "clip_type": "gen", "task": "cover", "is_remix": false, "is_trashed": false, "status": "observed", "first_seen_at": "now", "last_seen_at": "now"}
2184                },
2185                "edges": [
2186                    {"child_id": "b", "parent_id": "a", "edge_type": "remaster", "role": "primary", "source_field": "upsample_clip_id", "ordinal": 0, "status": "active", "first_seen_at": "now", "last_seen_at": "now"},
2187                    {"child_id": "c", "parent_id": "b", "edge_type": "cover", "role": "primary", "source_field": "cover_clip_id", "ordinal": 0, "status": "active", "first_seen_at": "now", "last_seen_at": "now"}
2188                ],
2189                "resolution_cache": {
2190                    "a": {"root_id": "a", "status": "resolved", "algorithm_version": 1, "computed_at": "now"},
2191                    "b": {"root_id": "a", "status": "resolved", "algorithm_version": 1, "computed_at": "now"},
2192                    "c": {"root_id": "a", "status": "resolved", "algorithm_version": 1, "computed_at": "now"}
2193                },
2194                "albums": {"a": {"folder_jpg": {"path": "alice/Root/folder.jpg", "hash": "jpg-h"}}},
2195                "playlists": {"liked": {"name": "Liked", "path": "Liked.m3u8", "hash": "pl-h"}},
2196                "owner": {"user_id": "user_a", "display_name": "Alice"}
2197            })
2198        );
2199
2200        // (2) Exact serialised bytes, including emitted key order (struct field
2201        // declaration order; map keys sorted). Any reordering fails here.
2202        let expected = r#"{"schema_version":1,"nodes":{"a":{"title":"Root","created_at":"t0","clip_type":"gen","task":"","is_remix":false,"is_trashed":false,"status":"observed","first_seen_at":"now","last_seen_at":"now"},"b":{"title":"Remaster","created_at":"t1","clip_type":"upsample","task":"upsample","is_remix":false,"is_trashed":false,"status":"observed","first_seen_at":"now","last_seen_at":"now"},"c":{"title":"Cover","created_at":"t2","clip_type":"gen","task":"cover","is_remix":false,"is_trashed":false,"status":"observed","first_seen_at":"now","last_seen_at":"now"}},"edges":[{"child_id":"b","parent_id":"a","edge_type":"remaster","role":"primary","source_field":"upsample_clip_id","ordinal":0,"status":"active","first_seen_at":"now","last_seen_at":"now"},{"child_id":"c","parent_id":"b","edge_type":"cover","role":"primary","source_field":"cover_clip_id","ordinal":0,"status":"active","first_seen_at":"now","last_seen_at":"now"}],"resolution_cache":{"a":{"root_id":"a","status":"resolved","algorithm_version":1,"computed_at":"now"},"b":{"root_id":"a","status":"resolved","algorithm_version":1,"computed_at":"now"},"c":{"root_id":"a","status":"resolved","algorithm_version":1,"computed_at":"now"}},"albums":{"a":{"folder_jpg":{"path":"alice/Root/folder.jpg","hash":"jpg-h"}}},"playlists":{"liked":{"name":"Liked","path":"Liked.m3u8","hash":"pl-h"}},"owner":{"user_id":"user_a","display_name":"Alice"}}"#;
2203        assert_eq!(serde_json::to_string(&store).unwrap(), expected);
2204
2205        // (3) The three `#[serde(skip)]` runtime overlays never reach disk.
2206        assert!(value.get("album_overrides").is_none());
2207        assert!(value.get("eligible_root_ids").is_none());
2208        assert!(value.get("edge_index").is_none());
2209
2210        // And it round-trips back to an equal store.
2211        let back: LineageStore = serde_json::from_str(expected).unwrap();
2212        assert_eq!(store, back);
2213    }
2214}