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