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