Skip to main content

suno_core/
graph.rs

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