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