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