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