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