Skip to main content

suno_core/
graph.rs

1//! The durable lineage graph store: a relational archive of clips, their parent
2//! edges, and cached root resolutions.
3//!
4//! This is a pure serde type with no IO of its own; the CLI persists it beside
5//! the library (mirroring the manifest). The shape is deliberately relational —
6//! separate `nodes`, `edges`, and `resolution_cache` collections rather than an
7//! adjacency blob per clip — so it migrates cleanly to SQLite later. A root's
8//! title is read from its node, never copied into every row where it would go
9//! stale.
10//!
11//! [`LineageStore::update`] is the only mutator: given the clips seen this run
12//! and their [`Resolution`], it upserts nodes and edges and refreshes the
13//! resolution cache. The store takes the wall clock as a `now` string from the
14//! caller so it stays free of IO. The cache is monotonic (HARDENING H3): a
15//! resolved root is never downgraded by a later transient miss. Gap-filled
16//! (often trashed) ancestors are persisted as nodes so lineage survives Suno's
17//! ~30-day trash purge.
18
19use std::collections::btree_map::Iter;
20use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
21
22use serde::{Deserialize, Serialize};
23
24use crate::lineage::{
25    Edge, EdgeRole, EdgeType, LineageContext, Resolution, ResolveStatus, RootInfo,
26    immediate_parent, lineage_edges,
27};
28use crate::manifest::ArtifactState;
29use crate::model::Clip;
30use crate::reconcile::ArtifactKind;
31
32/// The whole lineage graph, kept relational for a clean SQLite migration.
33///
34/// `nodes` and `resolution_cache` are [`BTreeMap`]s and `edges` is sorted after
35/// every [`update`](LineageStore::update), so serialisation is deterministic.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(default)]
38pub struct LineageStore {
39    /// On-disk schema version, so a future migration can branch on it.
40    pub schema_version: u32,
41    /// Every clip ever seen (including trashed ancestors), keyed by clip id.
42    pub(crate) nodes: BTreeMap<String, Node>,
43    /// Every observed parent link, as a flat relational list.
44    pub(crate) edges: Vec<StoredEdge>,
45    /// The last resolved (or last-known) root per clip, keyed by clip id.
46    pub(crate) resolution_cache: BTreeMap<String, CacheEntry>,
47    /// The reconciled folder-art state per album, keyed by the album's stable
48    /// root id (HARDENING H2). Additive: absent in older stores, defaults empty.
49    pub albums: BTreeMap<String, AlbumArt>,
50    /// The reconciled `.m3u8` state per playlist, keyed by the playlist's Suno
51    /// id (the synthetic `"liked"` id for the liked feed). Additive: absent in
52    /// older stores, defaults empty.
53    pub playlists: BTreeMap<String, PlaylistState>,
54    /// The Suno account this library is pinned to (trust-on-first-use). Absent
55    /// in older stores and in a fresh library until the first run adopts it.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub owner: Option<Owner>,
58    /// Manual album-name overrides, keyed by lineage root id, layered over the
59    /// store each run from config (see [`set_album_overrides`]). Runtime-only:
60    /// it is never serialised, so it can never persist into the durable graph or
61    /// silently outlive its config entry.
62    ///
63    /// [`set_album_overrides`]: LineageStore::set_album_overrides
64    #[serde(skip)]
65    pub album_overrides: BTreeMap<String, String>,
66    /// The set of root ids eligible for an album name (an override or a
67    /// collision suffix): every non-empty `root_id` that appears as a *value* in
68    /// [`resolution_cache`](Self::resolution_cache). This is the single source
69    /// both override-application ([`effective_root_title`]) and collision
70    /// detection ([`colliding_root_titles`]) draw from, so they can never
71    /// disagree about which roots exist. Runtime-only and derived from the cache
72    /// via [`refresh_eligible_roots`]; kept in sync by [`update`] and refreshed
73    /// after a load.
74    ///
75    /// [`effective_root_title`]: LineageStore::effective_root_title
76    /// [`colliding_root_titles`]: LineageStore::colliding_root_titles
77    /// [`refresh_eligible_roots`]: LineageStore::refresh_eligible_roots
78    /// [`update`]: LineageStore::update
79    #[serde(skip)]
80    eligible_root_ids: HashSet<String>,
81    /// Runtime index from edge identity to its row in `edges`, rebuilt from the
82    /// vector and kept in sync so upserts are O(1) without changing on-disk
83    /// shape.
84    #[serde(skip)]
85    edge_index: HashMap<EdgeKey, usize>,
86}
87
88impl Default for LineageStore {
89    fn default() -> Self {
90        Self {
91            schema_version: 1,
92            nodes: BTreeMap::new(),
93            edges: Vec::new(),
94            resolution_cache: BTreeMap::new(),
95            albums: BTreeMap::new(),
96            playlists: BTreeMap::new(),
97            owner: None,
98            album_overrides: BTreeMap::new(),
99            eligible_root_ids: HashSet::new(),
100            edge_index: HashMap::new(),
101        }
102    }
103}
104
105/// Equality over the durable graph only.
106///
107/// `album_overrides` and `eligible_root_ids` are runtime-only overlays
108/// (`#[serde(skip)]`): the first is layered from config each run, the second is
109/// a cache derived from `resolution_cache`. Neither is part of the persisted
110/// relational shape, so two stores are equal when their durable content is,
111/// regardless of the overrides in force or whether the derived set has been
112/// refreshed after a load.
113impl PartialEq for LineageStore {
114    fn eq(&self, other: &Self) -> bool {
115        self.schema_version == other.schema_version
116            && self.nodes == other.nodes
117            && self.edges == other.edges
118            && self.resolution_cache == other.resolution_cache
119            && self.albums == other.albums
120            && self.playlists == other.playlists
121            && self.owner == other.owner
122    }
123}
124
125/// The identity guard pins a library to the account it is first synced against
126/// and refuses to run it against a different account, so a mistyped or swapped
127/// token can never make one account's clips look absent from source and delete
128/// another account's files. `user_id` is the stable identity; `display_name`
129/// is cosmetic (for messages) and refreshed opportunistically on a match.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct Owner {
132    pub user_id: String,
133    pub display_name: String,
134}
135
136/// The PHASE 1 identity verdict: whether an authenticated account may run
137/// against a library, computed with no network (see [`owner_gate`]).
138///
139/// This is the composition that gates deletion, kept pure so the full matrix
140/// (including the lock-in cases where a configured id or the owner pin refuses
141/// even when `--allow-account-change` is set) is unit-tested here rather than
142/// inline in the CLI.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum OwnerGate {
145    /// A configured `account_id` differs from the authenticated id: always
146    /// refuse, regardless of `--allow-account-change`.
147    AbortConfigMismatch,
148    /// The pinned owner differs and re-pinning was not permitted: refuse.
149    AbortMismatch,
150    /// The pinned owner differs but re-pinning was permitted: pin the new owner
151    /// and run additively (no deletions this invocation).
152    Repin,
153    /// The authenticated account owns this library: proceed (the caller then
154    /// refreshes the pinned display name).
155    Proceed,
156    /// The library is not pinned yet: defer to the PHASE 2 adoption decision.
157    FirstUse,
158}
159
160impl OwnerGate {
161    /// Whether this outcome forces an additive (no-deletion) run.
162    pub fn is_additive(self) -> bool {
163        matches!(self, OwnerGate::Repin)
164    }
165}
166
167/// Decide whether an authenticated account may run against a library (PHASE 1).
168///
169/// A configured `account_id` that differs always aborts, even with
170/// `allow_change` set, because it is an explicit operator assertion. Otherwise
171/// an unpinned library defers to first-use adoption, a matching owner proceeds,
172/// and a differing owner either re-pins (when `allow_change`) or aborts.
173pub fn owner_gate(
174    store_owner: Option<&Owner>,
175    configured_id: Option<&str>,
176    authed_user_id: &str,
177    allow_change: bool,
178) -> OwnerGate {
179    if let Some(configured) = configured_id
180        && configured != authed_user_id
181    {
182        return OwnerGate::AbortConfigMismatch;
183    }
184    match store_owner {
185        None => OwnerGate::FirstUse,
186        Some(owner) if owner.user_id == authed_user_id => OwnerGate::Proceed,
187        Some(_) if allow_change => OwnerGate::Repin,
188        Some(_) => OwnerGate::AbortMismatch,
189    }
190}
191
192/// The PHASE 2 first-use adoption decision for a not-yet-pinned library.
193///
194/// Computed by [`adopt_decision`] from the account's listed clip ids, the
195/// library's already-owned clip ids, whether the listing is complete, and
196/// whether `--allow-account-change` was passed.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum AdoptDecision {
199    /// The destination holds no clips yet: pin it as a fresh library (normal
200    /// mode; a fresh library has nothing to delete).
201    PinFresh,
202    /// A complete listing overlaps the existing library: same account, pin it
203    /// (normal mode).
204    PinAdopt,
205    /// A complete listing shares nothing with the existing library but
206    /// `--allow-account-change` was passed: adopt it and run additively.
207    AdoptForced,
208    /// A complete listing shares nothing with the existing library and no
209    /// override was passed: refuse.
210    Abort,
211    /// A narrowed (incomplete) listing cannot confirm identity: do not pin.
212    SkipPin,
213}
214
215impl AdoptDecision {
216    /// Whether this outcome forces an additive (no-deletion) run.
217    pub fn is_additive(self) -> bool {
218        matches!(self, AdoptDecision::AdoptForced)
219    }
220}
221
222/// Decide how to adopt a not-yet-pinned library from this run's listing.
223///
224/// An empty library is adopted outright; otherwise identity is confirmed by an
225/// overlap between the authenticated account's `listed` clip ids and the
226/// library's `owned` clip ids, but only on a fully `enumerated` listing. A
227/// complete listing with no overlap is a different (or wiped) account: it
228/// refuses, unless `allow_change` opts into a forced additive adoption. A
229/// narrowed listing (a `--limit`/`--since` run, where deletion is disabled
230/// anyway) cannot confirm identity, so the library is left unpinned.
231pub fn adopt_decision(
232    listed: &[&str],
233    owned: &BTreeSet<&str>,
234    enumerated: bool,
235    allow_change: bool,
236) -> AdoptDecision {
237    if owned.is_empty() {
238        return AdoptDecision::PinFresh;
239    }
240    if !enumerated {
241        return AdoptDecision::SkipPin;
242    }
243    if listed.iter().any(|id| owned.contains(id)) {
244        AdoptDecision::PinAdopt
245    } else if allow_change {
246        AdoptDecision::AdoptForced
247    } else {
248        AdoptDecision::Abort
249    }
250}
251
252/// The reconciled folder-art state for one album (one stable root id).
253///
254/// Folder art is album-scoped, not per-clip, so it lives here rather than on a
255/// [`ManifestEntry`](crate::manifest::ManifestEntry). Each slot records the
256/// sidecar's path and the content hash of the art it was rendered from, so a
257/// later reconcile rewrites only on a genuine content change (HARDENING H1: a
258/// most-played flip that yields the same art hash is a no-op). Kept relational
259/// (two explicit slots) so it migrates cleanly to a SQLite `album_art` table.
260#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(default)]
262pub struct AlbumArt {
263    /// The album's static `folder.jpg`, sourced from the most-played variant.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub folder_jpg: Option<ArtifactState>,
266    /// The album's animated `cover.webp`, from the first-created animated variant.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub folder_webp: Option<ArtifactState>,
269    /// The album's raw `cover.mp4`: the same variant's `video_cover_url` kept
270    /// verbatim (no transcode).
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub folder_mp4: Option<ArtifactState>,
273}
274
275impl AlbumArt {
276    /// The stored state for one folder-art `kind`, if present. Per-clip and
277    /// library kinds have no album slot and map to `None`.
278    pub fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
279        match kind {
280            ArtifactKind::FolderJpg => self.folder_jpg.as_ref(),
281            ArtifactKind::FolderWebp => self.folder_webp.as_ref(),
282            ArtifactKind::FolderMp4 => self.folder_mp4.as_ref(),
283            ArtifactKind::CoverJpg
284            | ArtifactKind::CoverWebp
285            | ArtifactKind::DetailsTxt
286            | ArtifactKind::LyricsTxt
287            | ArtifactKind::Lrc
288            | ArtifactKind::VideoMp4
289            | ArtifactKind::Playlist => None,
290        }
291    }
292
293    /// Set (or clear, with `None`) the state for one folder-art `kind`.
294    ///
295    /// The executor calls this after a folder-art write (with the new state) or
296    /// delete (with `None`), so the kind-to-slot mapping lives in one place.
297    /// Non-album kinds have no slot here and are no-ops.
298    pub fn set(&mut self, kind: ArtifactKind, state: Option<ArtifactState>) {
299        match kind {
300            ArtifactKind::FolderJpg => self.folder_jpg = state,
301            ArtifactKind::FolderWebp => self.folder_webp = state,
302            ArtifactKind::FolderMp4 => self.folder_mp4 = state,
303            ArtifactKind::CoverJpg
304            | ArtifactKind::CoverWebp
305            | ArtifactKind::DetailsTxt
306            | ArtifactKind::LyricsTxt
307            | ArtifactKind::Lrc
308            | ArtifactKind::VideoMp4
309            | ArtifactKind::Playlist => {}
310        }
311    }
312
313    /// True when the album holds no folder art at all (every slot empty), so the
314    /// store can prune the now-dead album row.
315    pub fn is_empty(&self) -> bool {
316        self.folder_jpg.is_none() && self.folder_webp.is_none() && self.folder_mp4.is_none()
317    }
318}
319
320/// The reconciled `.m3u8` state for one playlist.
321///
322/// A playlist's body is *generated*, not fetched, so unlike per-clip artifacts
323/// its change detection is a single content hash over the full rendered text
324/// (HARDENING B1: name, order, and every member's path/title/duration feed it).
325/// The `path` is the sidecar's library-relative location, tracked so a rename
326/// (a playlist renamed on Suno) is detected and the old file removed. Kept as a
327/// flat row so it migrates cleanly to a SQLite `playlists` table.
328#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(default)]
330pub struct PlaylistState {
331    /// The playlist's display name at the time it was last written.
332    pub name: String,
333    /// The `.m3u8` file's library-relative path (`<sanitised name>.m3u8`).
334    pub path: String,
335    /// The content hash of the rendered `.m3u8` this row was written from.
336    pub hash: String,
337}
338
339/// 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        self.edges.sort_by(|a, b| {
823            a.child_id
824                .cmp(&b.child_id)
825                .then(a.ordinal.cmp(&b.ordinal))
826                .then(a.parent_id.cmp(&b.parent_id))
827                .then(a.edge_type.cmp(&b.edge_type))
828                .then(a.role.cmp(&b.role))
829        });
830        self.rebuild_edge_index();
831
832        for (child_id, info) in &resolution.roots {
833            self.upsert_cache(child_id, info, now);
834        }
835        self.refresh_eligible_roots();
836    }
837
838    /// The persisted `child_id -> parent_id` map from the active primary edges
839    /// (each clip's ordinal-0 lineage parent), for seeding
840    /// [`resolve_roots`](crate::resolve_roots).
841    ///
842    /// This lets a resolution walk hop through an ancestor whose clip is absent
843    /// this run (an intermediate remix, or one Suno has purged) using the link
844    /// captured on an earlier run, instead of self-rooting. It is resolution
845    /// input only: these ids are never download candidates.
846    pub fn archived_parents(&self) -> HashMap<String, String> {
847        self.edges
848            .iter()
849            .filter(|edge| {
850                edge.role == EdgeRole::Primary
851                    && edge.ordinal == 0
852                    && edge.status == EdgeStatus::Active
853            })
854            .map(|edge| (edge.child_id.clone(), edge.parent_id.clone()))
855            .collect()
856    }
857
858    /// Insert or refresh the node for `clip`. `first_seen_at` and `status` are
859    /// set once on insert; everything else is refreshed to the latest sighting.
860    fn upsert_node(&mut self, clip: &Clip, now: &str) {
861        let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
862            first_seen_at: now.to_owned(),
863            ..Node::default()
864        });
865        node.title = clip.title.clone();
866        node.created_at = clip.created_at.clone();
867        node.clip_type = clip.clip_type.clone();
868        node.task = clip.task.clone();
869        node.is_remix = clip.is_remix;
870        node.is_trashed = clip.is_trashed;
871        node.last_seen_at = now.to_owned();
872    }
873
874    /// Insert or refresh the edge from `child_id` to `edge.parent_id`, keyed by
875    /// `(child_id, parent_id, edge_type, role, ordinal)`.
876    fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
877        let edge_type = edge_type_slug(edge.edge_type);
878        let key = EdgeKey::new(
879            child_id,
880            &edge.parent_id,
881            edge_type,
882            edge.role,
883            edge.ordinal,
884        );
885        if let Some(&index) = self.edge_index.get(&key) {
886            let existing = &mut self.edges[index];
887            existing.source_field = edge.source_field.to_owned();
888            existing.status = EdgeStatus::Active;
889            existing.last_seen_at = now.to_owned();
890        } else {
891            self.edges.push(StoredEdge {
892                child_id: child_id.to_owned(),
893                parent_id: edge.parent_id.clone(),
894                edge_type: edge_type.to_owned(),
895                role: edge.role,
896                source_field: edge.source_field.to_owned(),
897                ordinal: edge.ordinal,
898                status: EdgeStatus::Active,
899                first_seen_at: now.to_owned(),
900                last_seen_at: now.to_owned(),
901            });
902            self.edge_index.insert(key, self.edges.len() - 1);
903        }
904    }
905
906    fn rebuild_edge_index(&mut self) {
907        self.edge_index.clear();
908        for (index, edge) in self.edges.iter().enumerate() {
909            self.edge_index
910                .entry(EdgeKey::from_stored(edge))
911                .or_insert(index);
912        }
913    }
914
915    /// Fold one clip's root resolution into the cache, monotonically.
916    ///
917    /// A [`Resolved`](ResolveStatus::Resolved) root always wins. A non-resolved
918    /// outcome (external, unresolved, cycle) never overwrites an existing
919    /// resolved root — a transient gap-fill miss must not downgrade a good
920    /// album. Otherwise the last-known non-resolved status is recorded.
921    fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
922        if info.status != ResolveStatus::Resolved
923            && self
924                .resolution_cache
925                .get(child_id)
926                .is_some_and(|entry| entry.status == ResolveStatus::Resolved)
927        {
928            return;
929        }
930        self.resolution_cache.insert(
931            child_id.to_owned(),
932            CacheEntry {
933                root_id: info.root_id.clone(),
934                status: info.status,
935                algorithm_version: 1,
936                computed_at: now.to_owned(),
937            },
938        );
939    }
940}
941
942/// The stable on-disk slug for an [`EdgeType`].
943fn edge_type_slug(edge_type: EdgeType) -> &'static str {
944    match edge_type {
945        EdgeType::Cover => "cover",
946        EdgeType::Remaster => "remaster",
947        EdgeType::SpeedEdit => "speed_edit",
948        EdgeType::Edit => "edit",
949        EdgeType::Extend => "extend",
950        EdgeType::SectionReplace => "section_replace",
951        EdgeType::Stitch => "stitch",
952        EdgeType::Derived => "derived",
953        EdgeType::Uploaded => "uploaded",
954    }
955}
956
957#[cfg(test)]
958mod tests {
959    use super::*;
960    use std::collections::HashMap;
961
962    /// A clean three-clip chain: cover -> remaster -> gen root, all present.
963    fn chain_clips() -> Vec<Clip> {
964        vec![
965            Clip {
966                id: "c".into(),
967                title: "Cover".into(),
968                clip_type: "gen".into(),
969                task: "cover".into(),
970                created_at: "t2".into(),
971                cover_clip_id: "b".into(),
972                edited_clip_id: "b".into(),
973                ..Default::default()
974            },
975            Clip {
976                id: "b".into(),
977                title: "Remaster".into(),
978                clip_type: "upsample".into(),
979                task: "upsample".into(),
980                created_at: "t1".into(),
981                upsample_clip_id: "a".into(),
982                edited_clip_id: "a".into(),
983                ..Default::default()
984            },
985            Clip {
986                id: "a".into(),
987                title: "Root".into(),
988                clip_type: "gen".into(),
989                created_at: "t0".into(),
990                ..Default::default()
991            },
992        ]
993    }
994
995    /// The matching resolution: every clip roots at `a`, all resolved.
996    fn chain_resolution() -> Resolution {
997        let mut roots = HashMap::new();
998        for id in ["a", "b", "c"] {
999            roots.insert(
1000                id.to_owned(),
1001                RootInfo {
1002                    root_id: "a".into(),
1003                    root_title: "Root".into(),
1004                    status: ResolveStatus::Resolved,
1005                },
1006            );
1007        }
1008        Resolution {
1009            roots,
1010            gap_filled: Vec::new(),
1011            bridges: Vec::new(),
1012        }
1013    }
1014
1015    fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
1016        store
1017            .edges
1018            .iter()
1019            .find(|e| e.child_id == child && e.parent_id == parent)
1020            .expect("edge should exist")
1021    }
1022
1023    #[test]
1024    fn new_store_is_empty_and_versioned() {
1025        let store = LineageStore::new();
1026        assert!(store.is_empty());
1027        assert_eq!(store.len(), 0);
1028        assert_eq!(store.schema_version, 1);
1029    }
1030
1031    #[test]
1032    fn update_populates_nodes_edges_and_cache() {
1033        let mut store = LineageStore::new();
1034        store.update(&chain_clips(), &chain_resolution(), "now");
1035
1036        // A node per clip, dated and typed from the clip.
1037        assert_eq!(store.len(), 3);
1038        let cover = store.node("c").unwrap();
1039        assert_eq!(cover.title, "Cover");
1040        assert_eq!(cover.clip_type, "gen");
1041        assert_eq!(cover.task, "cover");
1042        assert_eq!(cover.created_at, "t2");
1043        assert_eq!(cover.status, NodeStatus::Observed);
1044        assert!(!cover.is_trashed);
1045        assert_eq!(cover.first_seen_at, "now");
1046        assert_eq!(cover.last_seen_at, "now");
1047
1048        // One primary edge per non-root clip; the root emits none.
1049        assert_eq!(store.edges.len(), 2);
1050        let cb = edge(&store, "c", "b");
1051        assert_eq!(cb.edge_type, "cover");
1052        assert_eq!(cb.role, EdgeRole::Primary);
1053        assert_eq!(cb.ordinal, 0);
1054        assert_eq!(cb.source_field, "cover_clip_id");
1055        assert_eq!(cb.status, EdgeStatus::Active);
1056        let ba = edge(&store, "b", "a");
1057        assert_eq!(ba.edge_type, "remaster");
1058        assert!(!store.edges.iter().any(|e| e.child_id == "a"));
1059
1060        // The cache roots every clip at `a`, resolved.
1061        for id in ["a", "b", "c"] {
1062            let cached = store.get_root(id).unwrap();
1063            assert_eq!(cached.root_id, "a");
1064            assert_eq!(cached.status, ResolveStatus::Resolved);
1065            assert_eq!(cached.algorithm_version, 1);
1066        }
1067    }
1068
1069    #[test]
1070    fn update_persists_edges_for_gap_filled_ancestors() {
1071        // A gap-filled intermediate carries its own parent pointer; update()
1072        // must record ITS edge (not only the input clips'), so the stored graph
1073        // stays connected and a later run resolves through it without a fetch.
1074        let child = Clip {
1075            id: "child".into(),
1076            title: "Cover".into(),
1077            clip_type: "gen".into(),
1078            task: "cover".into(),
1079            cover_clip_id: "mid".into(),
1080            edited_clip_id: "mid".into(),
1081            ..Default::default()
1082        };
1083        let mid = Clip {
1084            id: "mid".into(),
1085            title: "Mid".into(),
1086            clip_type: "gen".into(),
1087            task: "cover".into(),
1088            cover_clip_id: "root".into(),
1089            edited_clip_id: "root".into(),
1090            ..Default::default()
1091        };
1092        let mut roots = HashMap::new();
1093        roots.insert(
1094            "child".to_owned(),
1095            RootInfo {
1096                root_id: "root".into(),
1097                root_title: "Original".into(),
1098                status: ResolveStatus::Resolved,
1099            },
1100        );
1101        let resolution = Resolution {
1102            roots,
1103            gap_filled: vec![mid],
1104            bridges: Vec::new(),
1105        };
1106        let mut store = LineageStore::new();
1107        store.update(std::slice::from_ref(&child), &resolution, "now");
1108
1109        // The gap-filled ancestor's own edge is persisted (pre-fix it was not).
1110        let mid_edge = edge(&store, "mid", "root");
1111        assert_eq!(mid_edge.role, EdgeRole::Primary);
1112        assert_eq!(mid_edge.ordinal, 0);
1113        // Both hops are now reachable from the archive for a later resolve.
1114        let archived = store.archived_parents();
1115        assert_eq!(archived.get("child").map(String::as_str), Some("mid"));
1116        assert_eq!(archived.get("mid").map(String::as_str), Some("root"));
1117    }
1118
1119    #[test]
1120    fn update_persists_bridges_as_edges() {
1121        // A parent-endpoint bridge has no clip of its own, so it is persisted
1122        // directly as a primary edge to keep that hop durable.
1123        let child = Clip {
1124            id: "child".into(),
1125            title: "Cover".into(),
1126            clip_type: "gen".into(),
1127            task: "cover".into(),
1128            cover_clip_id: "gone".into(),
1129            edited_clip_id: "gone".into(),
1130            ..Default::default()
1131        };
1132        let mut roots = HashMap::new();
1133        roots.insert(
1134            "child".to_owned(),
1135            RootInfo {
1136                root_id: "found".into(),
1137                root_title: String::new(),
1138                status: ResolveStatus::External,
1139            },
1140        );
1141        let resolution = Resolution {
1142            roots,
1143            gap_filled: Vec::new(),
1144            bridges: vec![("gone".to_owned(), "found".to_owned())],
1145        };
1146        let mut store = LineageStore::new();
1147        store.update(std::slice::from_ref(&child), &resolution, "now");
1148
1149        let bridged = edge(&store, "gone", "found");
1150        assert_eq!(bridged.source_field, "parent_endpoint");
1151        assert_eq!(bridged.role, EdgeRole::Primary);
1152        assert_eq!(bridged.ordinal, 0);
1153        assert_eq!(
1154            store.archived_parents().get("gone").map(String::as_str),
1155            Some("found")
1156        );
1157    }
1158
1159    #[test]
1160    fn archived_parents_maps_children_to_primary_parents_only() {
1161        let mut store = LineageStore::new();
1162        store.update(&chain_clips(), &chain_resolution(), "now");
1163        let archived = store.archived_parents();
1164        assert_eq!(archived.get("c").map(String::as_str), Some("b"));
1165        assert_eq!(archived.get("b").map(String::as_str), Some("a"));
1166        assert!(
1167            !archived.contains_key("a"),
1168            "a root has no primary parent edge"
1169        );
1170    }
1171
1172    #[test]
1173    fn album_for_id_matches_context_for_and_handles_unknown() {
1174        let mut store = LineageStore::new();
1175        store.update(&chain_clips(), &chain_resolution(), "now");
1176
1177        // A child folds under its differently-titled root, agreeing with the
1178        // live-clip rule via context_for.
1179        assert_eq!(store.album_for_id("c"), "Root");
1180        let cover = &chain_clips()[0];
1181        assert_eq!(
1182            store.album_for_id("c"),
1183            store.context_for(cover).album(&cover.title)
1184        );
1185        // The root folders under its own title.
1186        assert_eq!(store.album_for_id("a"), "Root");
1187        // An id absent from the store folds to an empty own title.
1188        assert_eq!(store.album_for_id("missing"), "");
1189    }
1190
1191    #[test]
1192    fn serde_roundtrip_preserves_a_relational_shape() {
1193        let mut store = LineageStore::new();
1194        store.update(&chain_clips(), &chain_resolution(), "now");
1195
1196        let json = serde_json::to_string(&store).unwrap();
1197        let back: LineageStore = serde_json::from_str(&json).unwrap();
1198        assert_eq!(store, back);
1199
1200        let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1201        assert_eq!(value.get("schema_version").unwrap(), 1);
1202        assert!(value.get("nodes").unwrap().is_object());
1203        assert!(value.get("edges").unwrap().is_array());
1204        assert!(value.get("resolution_cache").unwrap().is_object());
1205        assert!(value.get("edge_index").is_none());
1206
1207        // Relational, not adjacency: a node carries no edges/parent of its own,
1208        // and an edge is a flat row keyed by child and parent.
1209        let node = value.get("nodes").unwrap().get("c").unwrap();
1210        assert!(node.get("edges").is_none());
1211        assert!(node.get("parent_id").is_none());
1212        let first_edge = value.get("edges").unwrap().get(0).unwrap();
1213        assert!(first_edge.get("child_id").is_some());
1214        assert!(first_edge.get("parent_id").is_some());
1215    }
1216
1217    #[test]
1218    fn album_overrides_are_runtime_only_and_never_persist() {
1219        // Overrides come from config each run, so they must not serialise into
1220        // the durable graph or survive a round-trip (they would then outlive the
1221        // config entry that set them).
1222        let mut store = LineageStore::new();
1223        store.update(&chain_clips(), &chain_resolution(), "now");
1224        store.set_album_overrides(
1225            [("a".to_owned(), "Preferred".to_owned())]
1226                .into_iter()
1227                .collect(),
1228        );
1229
1230        let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1231        assert!(value.get("album_overrides").is_none());
1232
1233        let json = serde_json::to_string(&store).unwrap();
1234        let back: LineageStore = serde_json::from_str(&json).unwrap();
1235        assert!(back.album_overrides.is_empty());
1236        assert_eq!(back.album_for_id("c"), "Root");
1237    }
1238
1239    #[test]
1240    fn update_is_idempotent_bar_last_seen() {
1241        let clips = chain_clips();
1242        let resolution = chain_resolution();
1243        let mut store = LineageStore::new();
1244        store.update(&clips, &resolution, "first");
1245        let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
1246        let edge_count = store.edges.len();
1247
1248        store.update(&clips, &resolution, "second");
1249
1250        // No new nodes, edges, or cache rows: the second run only refreshes.
1251        assert_eq!(
1252            store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
1253            node_ids
1254        );
1255        assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
1256        assert_eq!(store.resolution_cache.len(), 3);
1257
1258        // first_seen_at sticks; last_seen_at advances.
1259        let cover = store.node("c").unwrap();
1260        assert_eq!(cover.first_seen_at, "first");
1261        assert_eq!(cover.last_seen_at, "second");
1262        let cb = edge(&store, "c", "b");
1263        assert_eq!(cb.first_seen_at, "first");
1264        assert_eq!(cb.last_seen_at, "second");
1265        // Root ids are stable across the re-run.
1266        assert_eq!(store.get_root("c").unwrap().root_id, "a");
1267    }
1268
1269    #[test]
1270    fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
1271        let mut store = LineageStore::new();
1272        store.update(&chain_clips(), &chain_resolution(), "first");
1273        assert_eq!(store.get_root("c").unwrap().status, ResolveStatus::Resolved);
1274
1275        // A later run where `c` fails to resolve (a transient gap-fill miss)
1276        // and a brand-new clip `d` that only reaches an external boundary.
1277        let child = Clip {
1278            id: "c".into(),
1279            title: "Cover".into(),
1280            clip_type: "gen".into(),
1281            task: "cover".into(),
1282            cover_clip_id: "b".into(),
1283            edited_clip_id: "b".into(),
1284            ..Default::default()
1285        };
1286        let mut roots = HashMap::new();
1287        roots.insert(
1288            "c".to_owned(),
1289            RootInfo {
1290                root_id: "elsewhere".into(),
1291                root_title: String::new(),
1292                status: ResolveStatus::External,
1293            },
1294        );
1295        roots.insert(
1296            "d".to_owned(),
1297            RootInfo {
1298                root_id: "boundary".into(),
1299                root_title: String::new(),
1300                status: ResolveStatus::External,
1301            },
1302        );
1303        let resolution = Resolution {
1304            roots,
1305            gap_filled: Vec::new(),
1306            bridges: Vec::new(),
1307        };
1308        store.update(&[child], &resolution, "second");
1309
1310        // The resolved root of `c` is kept, not downgraded.
1311        let cached = store.get_root("c").unwrap();
1312        assert_eq!(cached.root_id, "a");
1313        assert_eq!(cached.status, ResolveStatus::Resolved);
1314        assert_eq!(cached.computed_at, "first");
1315        // A never-resolved clip records its last-known non-resolved status.
1316        let d = store.get_root("d").unwrap();
1317        assert_eq!(d.root_id, "boundary");
1318        assert_eq!(d.status, ResolveStatus::External);
1319    }
1320
1321    #[test]
1322    fn gap_filled_trashed_ancestor_is_a_durable_node() {
1323        // The trashed ancestor is not among `clips`; it arrives only via the
1324        // resolution's gap_filled set, yet must be archived as a node so its
1325        // lineage survives Suno's purge (HARDENING H4 / L2).
1326        let child = Clip {
1327            id: "c".into(),
1328            title: "Cover".into(),
1329            clip_type: "gen".into(),
1330            task: "cover".into(),
1331            cover_clip_id: "t".into(),
1332            edited_clip_id: "t".into(),
1333            ..Default::default()
1334        };
1335        let trashed = Clip {
1336            id: "t".into(),
1337            title: "Trashed Original".into(),
1338            clip_type: "gen".into(),
1339            is_trashed: true,
1340            ..Default::default()
1341        };
1342        let mut roots = HashMap::new();
1343        roots.insert(
1344            "c".to_owned(),
1345            RootInfo {
1346                root_id: "t".into(),
1347                root_title: "Trashed Original".into(),
1348                status: ResolveStatus::Resolved,
1349            },
1350        );
1351        let resolution = Resolution {
1352            roots,
1353            gap_filled: vec![trashed],
1354            bridges: Vec::new(),
1355        };
1356        store_update_and_assert_trashed(child, resolution);
1357    }
1358
1359    fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
1360        let mut store = LineageStore::new();
1361        store.update(&[child], &resolution, "now");
1362
1363        let node = store
1364            .node("t")
1365            .expect("trashed ancestor should be archived");
1366        assert!(node.is_trashed);
1367        assert_eq!(node.title, "Trashed Original");
1368        // The child roots at the trashed ancestor.
1369        assert_eq!(store.get_root("c").unwrap().root_id, "t");
1370    }
1371
1372    #[test]
1373    fn partial_json_loads_with_defaults() {
1374        // An older/partial file missing whole collections and per-row fields
1375        // still loads: container and row defaults fill the gaps.
1376        let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
1377        let store: LineageStore = serde_json::from_str(json).unwrap();
1378        assert_eq!(store.schema_version, 1);
1379        let node = store.node("x").unwrap();
1380        assert_eq!(node.title, "Kept");
1381        assert_eq!(node.status, NodeStatus::Observed);
1382        assert_eq!(store.edges[0].status, EdgeStatus::Active);
1383        assert!(store.resolution_cache.is_empty());
1384        // The album-art collection is additive: a store written before folder
1385        // art existed loads with no albums and no folder art.
1386        assert!(store.albums.is_empty());
1387        assert!(store.album_art("x").is_none());
1388        // The playlist collection is likewise additive: absent in an older
1389        // store, it defaults empty (HARDENING B2: no stored playlist means no
1390        // reconcile ever treats one as stale).
1391        assert!(store.playlists.is_empty());
1392        assert!(store.playlist("x").is_none());
1393    }
1394
1395    #[test]
1396    fn album_art_roundtrips_and_reads_by_kind() {
1397        let mut store = LineageStore::new();
1398        store.albums.insert(
1399            "root-1".to_owned(),
1400            AlbumArt {
1401                folder_jpg: Some(ArtifactState {
1402                    path: "alice/Album/folder.jpg".to_owned(),
1403                    hash: "jpg-h".to_owned(),
1404                }),
1405                folder_webp: Some(ArtifactState {
1406                    path: "alice/Album/cover.webp".to_owned(),
1407                    hash: "webp-h".to_owned(),
1408                }),
1409                folder_mp4: Some(ArtifactState {
1410                    path: "alice/Album/cover.mp4".to_owned(),
1411                    hash: "mp4-h".to_owned(),
1412                }),
1413            },
1414        );
1415
1416        let json = serde_json::to_string(&store).unwrap();
1417        let back: LineageStore = serde_json::from_str(&json).unwrap();
1418        assert_eq!(store, back);
1419
1420        // The serialised shape is a relational `albums` map keyed by root id.
1421        let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1422        let album = value.get("albums").unwrap().get("root-1").unwrap();
1423        assert_eq!(
1424            album.get("folder_jpg").unwrap().get("hash").unwrap(),
1425            "jpg-h"
1426        );
1427
1428        let art = back.album_art("root-1").unwrap();
1429        assert_eq!(
1430            art.artifact(ArtifactKind::FolderJpg).unwrap().path,
1431            "alice/Album/folder.jpg"
1432        );
1433        assert_eq!(
1434            art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
1435            "webp-h"
1436        );
1437        assert_eq!(art.artifact(ArtifactKind::FolderMp4).unwrap().hash, "mp4-h");
1438        // A per-clip kind has no album slot.
1439        assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
1440    }
1441
1442    #[test]
1443    fn empty_album_art_omits_slots_when_serialised() {
1444        // An all-`None` AlbumArt round-trips and writes an empty object, so the
1445        // absent-slot default holds both ways.
1446        let empty = AlbumArt::default();
1447        assert!(empty.is_empty());
1448        let value = serde_json::to_value(&empty).unwrap();
1449        assert!(value.get("folder_jpg").is_none());
1450        assert!(value.get("folder_webp").is_none());
1451        let back: AlbumArt = serde_json::from_str("{}").unwrap();
1452        assert_eq!(back, empty);
1453    }
1454
1455    #[test]
1456    fn set_album_artifact_upserts_then_prunes_when_emptied() {
1457        let mut store = LineageStore::new();
1458        let jpg = ArtifactState {
1459            path: "a/folder.jpg".to_owned(),
1460            hash: "h1".to_owned(),
1461        };
1462        store.set_album_artifact("root-1", ArtifactKind::FolderJpg, Some(jpg.clone()));
1463        assert_eq!(store.album_art("root-1").unwrap().folder_jpg, Some(jpg));
1464
1465        // Clearing the only slot prunes the whole album row (no dead entries).
1466        store.set_album_artifact("root-1", ArtifactKind::FolderJpg, None);
1467        assert!(store.album_art("root-1").is_none());
1468        assert!(store.albums.is_empty());
1469    }
1470
1471    #[test]
1472    fn album_row_survives_until_the_last_slot_including_folder_mp4_is_cleared() {
1473        // Regression: `is_empty` must count every slot. A `both`-retention album
1474        // owns folder_webp + folder_mp4; clearing folder_webp first must NOT
1475        // prune the row while folder_mp4 is still stored, or the later cover.mp4
1476        // delete would lose its store entry and never retry on failure.
1477        let mut store = LineageStore::new();
1478        let state = |p: &str| ArtifactState {
1479            path: p.to_owned(),
1480            hash: "h".to_owned(),
1481        };
1482        store.set_album_artifact(
1483            "root-1",
1484            ArtifactKind::FolderWebp,
1485            Some(state("a/cover.webp")),
1486        );
1487        store.set_album_artifact(
1488            "root-1",
1489            ArtifactKind::FolderMp4,
1490            Some(state("a/cover.mp4")),
1491        );
1492
1493        // FolderWebp is cleared first (its kind sorts before FolderMp4); the row
1494        // must stay because the raw cover is still tracked.
1495        store.set_album_artifact("root-1", ArtifactKind::FolderWebp, None);
1496        let art = store
1497            .album_art("root-1")
1498            .expect("row kept while folder_mp4 remains");
1499        assert!(!art.is_empty());
1500        assert!(art.folder_mp4.is_some());
1501
1502        // Clearing the last slot finally prunes the row.
1503        store.set_album_artifact("root-1", ArtifactKind::FolderMp4, None);
1504        assert!(store.album_art("root-1").is_none());
1505        assert!(store.albums.is_empty());
1506    }
1507
1508    #[test]
1509    fn playlist_state_roundtrips_by_id() {
1510        let mut store = LineageStore::new();
1511        store.playlists.insert(
1512            "pl1".to_owned(),
1513            PlaylistState {
1514                name: "Road Trip".to_owned(),
1515                path: "Road Trip.m3u8".to_owned(),
1516                hash: "abc123".to_owned(),
1517            },
1518        );
1519
1520        let json = serde_json::to_string(&store).unwrap();
1521        let back: LineageStore = serde_json::from_str(&json).unwrap();
1522        assert_eq!(store, back);
1523
1524        // The serialised shape is a relational `playlists` map keyed by id.
1525        let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1526        let pl = value.get("playlists").unwrap().get("pl1").unwrap();
1527        assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
1528        assert_eq!(pl.get("hash").unwrap(), "abc123");
1529
1530        let stored = back.playlist("pl1").unwrap();
1531        assert_eq!(stored.name, "Road Trip");
1532        assert_eq!(stored.hash, "abc123");
1533    }
1534
1535    #[test]
1536    fn set_playlist_upserts_then_clears() {
1537        let mut store = LineageStore::new();
1538        let state = PlaylistState {
1539            name: "Mix".to_owned(),
1540            path: "Mix.m3u8".to_owned(),
1541            hash: "h1".to_owned(),
1542        };
1543        store.set_playlist("pl1", Some(state.clone()));
1544        assert_eq!(store.playlist("pl1"), Some(&state));
1545
1546        // A rewrite replaces the row in place.
1547        let renamed = PlaylistState {
1548            name: "Mix v2".to_owned(),
1549            path: "Mix v2.m3u8".to_owned(),
1550            hash: "h2".to_owned(),
1551        };
1552        store.set_playlist("pl1", Some(renamed.clone()));
1553        assert_eq!(store.playlist("pl1"), Some(&renamed));
1554
1555        // Clearing removes the row so no dangling entry survives a delete.
1556        store.set_playlist("pl1", None);
1557        assert!(store.playlist("pl1").is_none());
1558        assert!(store.playlists.is_empty());
1559    }
1560
1561    #[test]
1562    fn context_for_roots_a_remix_at_its_stored_ancestor() {
1563        let mut store = LineageStore::new();
1564        store.update(&chain_clips(), &chain_resolution(), "now");
1565
1566        let child = &chain_clips()[0]; // "c", a cover of "b"
1567        let ctx = store.context_for(child);
1568        assert_eq!(ctx.root_id, "a");
1569        assert_eq!(ctx.root_title, "Root");
1570        assert_eq!(ctx.parent_id, "b");
1571        assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1572        assert_eq!(ctx.status, ResolveStatus::Resolved);
1573        // The remix folders under its resolved root's album.
1574        assert_eq!(ctx.album("Cover"), "Root");
1575    }
1576
1577    #[test]
1578    fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
1579        let mut store = LineageStore::new();
1580        store.update(&chain_clips(), &chain_resolution(), "now");
1581
1582        let root = &chain_clips()[2]; // "a"
1583        let ctx = store.context_for(root);
1584        assert_eq!(ctx.root_id, "a");
1585        assert_eq!(ctx.root_title, "Root");
1586        assert_eq!(ctx.parent_id, "");
1587        assert_eq!(ctx.edge_type, None);
1588        assert_eq!(ctx.album("Root"), "Root");
1589    }
1590
1591    #[test]
1592    fn context_for_tags_the_root_year_across_a_calendar_boundary() {
1593        // A December root with a January revision: both tag the root's year, so
1594        // the album groups under one year even across the boundary.
1595        let clips = vec![
1596            Clip {
1597                id: "child".into(),
1598                title: "Revision".into(),
1599                clip_type: "gen".into(),
1600                task: "cover".into(),
1601                created_at: "2024-01-02T08:00:00Z".into(),
1602                cover_clip_id: "root".into(),
1603                edited_clip_id: "root".into(),
1604                ..Default::default()
1605            },
1606            Clip {
1607                id: "root".into(),
1608                title: "Origin".into(),
1609                clip_type: "gen".into(),
1610                created_at: "2023-12-30T23:00:00Z".into(),
1611                ..Default::default()
1612            },
1613        ];
1614        let mut roots = HashMap::new();
1615        for id in ["child", "root"] {
1616            roots.insert(
1617                id.to_owned(),
1618                RootInfo {
1619                    root_id: "root".into(),
1620                    root_title: "Origin".into(),
1621                    status: ResolveStatus::Resolved,
1622                },
1623            );
1624        }
1625        let resolution = Resolution {
1626            roots,
1627            gap_filled: Vec::new(),
1628            bridges: Vec::new(),
1629        };
1630        let mut store = LineageStore::new();
1631        store.update(&clips, &resolution, "now");
1632
1633        let child_ctx = store.context_for(&clips[0]);
1634        assert_eq!(child_ctx.root_id, "root");
1635        assert_eq!(child_ctx.root_date, "2023-12-30T23:00:00Z");
1636        // The January child tags the December root's year, not its own 2024.
1637        assert_eq!(child_ctx.year(&clips[0].created_at), "2023");
1638
1639        // The root tags its own year (the same year).
1640        let root_ctx = store.context_for(&clips[1]);
1641        assert_eq!(root_ctx.year(&clips[1].created_at), "2023");
1642    }
1643
1644    #[test]
1645    fn context_for_an_unknown_clip_is_self_rooted() {
1646        let store = LineageStore::new();
1647        let orphan = Clip {
1648            id: "z".into(),
1649            title: "Lonely".into(),
1650            ..Default::default()
1651        };
1652        let ctx = store.context_for(&orphan);
1653        assert_eq!(ctx.root_id, "z");
1654        assert_eq!(ctx.root_title, "Lonely");
1655        assert_eq!(ctx.parent_id, "");
1656        assert_eq!(ctx.status, ResolveStatus::Resolved);
1657    }
1658
1659    #[test]
1660    fn context_for_retains_a_purged_ancestor_album() {
1661        // The trashed ancestor arrives only via gap_filled, yet a later run
1662        // whose resolver failed (modelled here by simply not re-updating) must
1663        // still root the child at the archived ancestor with its stored title
1664        // (HARDENING H3).
1665        let child = Clip {
1666            id: "c".into(),
1667            title: "Cover".into(),
1668            clip_type: "gen".into(),
1669            task: "cover".into(),
1670            cover_clip_id: "t".into(),
1671            edited_clip_id: "t".into(),
1672            ..Default::default()
1673        };
1674        let trashed = Clip {
1675            id: "t".into(),
1676            title: "Trashed Original".into(),
1677            clip_type: "gen".into(),
1678            is_trashed: true,
1679            ..Default::default()
1680        };
1681        let mut roots = HashMap::new();
1682        roots.insert(
1683            "c".to_owned(),
1684            RootInfo {
1685                root_id: "t".into(),
1686                root_title: "Trashed Original".into(),
1687                status: ResolveStatus::Resolved,
1688            },
1689        );
1690        let resolution = Resolution {
1691            roots,
1692            gap_filled: vec![trashed],
1693            bridges: Vec::new(),
1694        };
1695        let mut store = LineageStore::new();
1696        store.update(std::slice::from_ref(&child), &resolution, "now");
1697
1698        let ctx = store.context_for(&child);
1699        assert_eq!(ctx.root_id, "t");
1700        assert_eq!(ctx.root_title, "Trashed Original");
1701        assert_eq!(ctx.album("Cover"), "Trashed Original");
1702    }
1703
1704    #[test]
1705    fn colliding_root_titles_flags_only_shared_distinct_roots() {
1706        // Two distinct roots share the title "Break Through"; a third root is
1707        // unique; a child of a shared root does not add a spurious distinct root.
1708        let clips = vec![
1709            Clip {
1710                id: "r1".into(),
1711                title: "Break Through".into(),
1712                clip_type: "gen".into(),
1713                ..Default::default()
1714            },
1715            Clip {
1716                id: "r2".into(),
1717                title: "Break Through".into(),
1718                clip_type: "gen".into(),
1719                ..Default::default()
1720            },
1721            Clip {
1722                id: "r3".into(),
1723                title: "Solo".into(),
1724                clip_type: "gen".into(),
1725                ..Default::default()
1726            },
1727            Clip {
1728                id: "c1".into(),
1729                title: "Break Through".into(),
1730                clip_type: "gen".into(),
1731                task: "cover".into(),
1732                cover_clip_id: "r1".into(),
1733                edited_clip_id: "r1".into(),
1734                ..Default::default()
1735            },
1736        ];
1737        let mut roots = HashMap::new();
1738        for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1739            let title = if root == "r3" {
1740                "Solo"
1741            } else {
1742                "Break Through"
1743            };
1744            roots.insert(
1745                id.to_owned(),
1746                RootInfo {
1747                    root_id: root.into(),
1748                    root_title: title.into(),
1749                    status: ResolveStatus::Resolved,
1750                },
1751            );
1752        }
1753        let resolution = Resolution {
1754            roots,
1755            gap_filled: Vec::new(),
1756            bridges: Vec::new(),
1757        };
1758        let mut store = LineageStore::new();
1759        store.update(&clips, &resolution, "now");
1760
1761        let colliding = store.colliding_root_titles();
1762        assert!(colliding.contains("Break Through"));
1763        assert!(!colliding.contains("Solo"));
1764        assert_eq!(colliding.len(), 1);
1765    }
1766
1767    /// Build the two-distinct-root store used by the disambiguation tests: `r1`
1768    /// and `r2` are separate `gen` roots titled `t1`/`t2`.
1769    fn two_root_store(t1: &str, t2: &str) -> LineageStore {
1770        let clips = vec![
1771            Clip {
1772                id: "r1".into(),
1773                title: t1.into(),
1774                clip_type: "gen".into(),
1775                ..Default::default()
1776            },
1777            Clip {
1778                id: "r2".into(),
1779                title: t2.into(),
1780                clip_type: "gen".into(),
1781                ..Default::default()
1782            },
1783        ];
1784        let mut roots = HashMap::new();
1785        roots.insert(
1786            "r1".to_owned(),
1787            RootInfo {
1788                root_id: "r1".into(),
1789                root_title: t1.into(),
1790                status: ResolveStatus::Resolved,
1791            },
1792        );
1793        roots.insert(
1794            "r2".to_owned(),
1795            RootInfo {
1796                root_id: "r2".into(),
1797                root_title: t2.into(),
1798                status: ResolveStatus::Resolved,
1799            },
1800        );
1801        let mut store = LineageStore::new();
1802        store.update(
1803            &clips,
1804            &Resolution {
1805                roots,
1806                gap_filled: Vec::new(),
1807                bridges: Vec::new(),
1808            },
1809            "now",
1810        );
1811        store
1812    }
1813
1814    #[test]
1815    fn album_override_flows_into_context_tag_hash_and_index() {
1816        // Override the lineage root's album name; every album-bearing surface
1817        // (the resolved context, the ALBUM tag, the change hash, and the
1818        // id-only index) must reflect the preferred name from one source.
1819        let clips = chain_clips();
1820        let mut store = LineageStore::new();
1821        store.update(&clips, &chain_resolution(), "now");
1822
1823        let cover = &clips[0]; // "Cover", rooted at "a" ("Root")
1824        let before_hash = crate::hash::meta_hash(cover, &store.context_for(cover));
1825
1826        store.set_album_overrides(
1827            [("a".to_owned(), "Preferred Name".to_owned())]
1828                .into_iter()
1829                .collect(),
1830        );
1831
1832        // Every clip in the lineage now folders under the preferred album.
1833        for id in ["a", "b", "c"] {
1834            let clip = clips.iter().find(|c| c.id == id).unwrap();
1835            let ctx = store.context_for(clip);
1836            assert_eq!(ctx.album(&clip.title), "Preferred Name");
1837            assert_eq!(store.album_for_id(id), "Preferred Name");
1838        }
1839
1840        // The ALBUM tag follows the override.
1841        let ctx = store.context_for(cover);
1842        let meta = crate::tag::TrackMetadata::from_clip(cover, &ctx);
1843        assert_eq!(meta.album, "Preferred Name");
1844
1845        // The change hash shifts, so reconcile retags the file in place.
1846        let after_hash = crate::hash::meta_hash(cover, &ctx);
1847        assert_ne!(before_hash, after_hash);
1848    }
1849
1850    #[test]
1851    fn empty_album_override_is_ignored() {
1852        // A blank value must never blank an album; the derived title stands.
1853        let clips = chain_clips();
1854        let mut store = LineageStore::new();
1855        store.update(&clips, &chain_resolution(), "now");
1856        store.set_album_overrides([("a".to_owned(), "   ".to_owned())].into_iter().collect());
1857        assert_eq!(store.album_for_id("c"), "Root");
1858    }
1859
1860    #[test]
1861    fn album_override_creates_a_collision_that_disambiguates() {
1862        // Two uniquely titled roots collide once one is renamed onto the other.
1863        let mut store = two_root_store("Alpha", "Beta");
1864        assert!(store.colliding_root_titles().is_empty());
1865
1866        store.set_album_overrides(
1867            [("r2".to_owned(), "Alpha".to_owned())]
1868                .into_iter()
1869                .collect(),
1870        );
1871        let colliding = store.colliding_root_titles();
1872        assert!(colliding.contains("Alpha"));
1873        assert_eq!(colliding.len(), 1);
1874    }
1875
1876    #[test]
1877    fn album_override_resolves_a_natural_collision() {
1878        // Two roots share a title; renaming one apart settles the collision.
1879        let mut store = two_root_store("Break Through", "Break Through");
1880        assert!(store.colliding_root_titles().contains("Break Through"));
1881
1882        store.set_album_overrides(
1883            [("r2".to_owned(), "Second Wind".to_owned())]
1884                .into_iter()
1885                .collect(),
1886        );
1887        assert!(store.colliding_root_titles().is_empty());
1888    }
1889
1890    /// Insert a cache-only root: an entry in the resolution cache whose root_id
1891    /// has NO backing node (an external or not-yet-archived root). Such a root
1892    /// still folders under a configured override, so it must be visible to
1893    /// collision detection.
1894    fn insert_cache_only_root(store: &mut LineageStore, root_id: &str) {
1895        store.resolution_cache.insert(
1896            root_id.to_owned(),
1897            CacheEntry {
1898                root_id: root_id.to_owned(),
1899                status: ResolveStatus::External,
1900                algorithm_version: 1,
1901                computed_at: "now".to_owned(),
1902            },
1903        );
1904        // Direct cache mutation bypasses `update`, so mirror what a real run does
1905        // after loading: refresh the derived eligible-root set.
1906        store.refresh_eligible_roots();
1907    }
1908
1909    #[test]
1910    fn override_on_node_less_root_collides_with_a_real_root() {
1911        // A node-less (cache-only) root overridden onto a real root's title must
1912        // be flagged as colliding, so both albums get the [root_id8] suffix and
1913        // two distinct roots never share one folder.
1914        let mut store = LineageStore::new();
1915        store.update(
1916            std::slice::from_ref(&Clip {
1917                id: "realroot".into(),
1918                title: "Shared".into(),
1919                clip_type: "gen".into(),
1920                ..Default::default()
1921            }),
1922            &Resolution {
1923                roots: [(
1924                    "realroot".to_owned(),
1925                    RootInfo {
1926                        root_id: "realroot".into(),
1927                        root_title: "Shared".into(),
1928                        status: ResolveStatus::Resolved,
1929                    },
1930                )]
1931                .into_iter()
1932                .collect(),
1933                gap_filled: Vec::new(),
1934                bridges: Vec::new(),
1935            },
1936            "now",
1937        );
1938        insert_cache_only_root(&mut store, "extroot");
1939        store.set_album_overrides(
1940            [("extroot".to_owned(), "Shared".to_owned())]
1941                .into_iter()
1942                .collect(),
1943        );
1944
1945        let colliding = store.colliding_root_titles();
1946        assert!(
1947            colliding.contains("Shared"),
1948            "a node-less overridden root must still be seen by collision detection"
1949        );
1950    }
1951
1952    #[test]
1953    fn two_node_less_roots_overridden_to_same_name_collide() {
1954        let mut store = LineageStore::new();
1955        insert_cache_only_root(&mut store, "extone");
1956        insert_cache_only_root(&mut store, "exttwo");
1957        store.set_album_overrides(
1958            [
1959                ("extone".to_owned(), "Shared".to_owned()),
1960                ("exttwo".to_owned(), "Shared".to_owned()),
1961            ]
1962            .into_iter()
1963            .collect(),
1964        );
1965        assert!(store.colliding_root_titles().contains("Shared"));
1966    }
1967
1968    #[test]
1969    fn colliding_node_less_overrides_keep_album_art_paths_distinct() {
1970        // End-to-end guard: two node-less roots overridden to one name must not
1971        // collapse their album-art (folder.jpg) onto a single shared path. The
1972        // colliding set drives naming to append [root_id8], giving each root its
1973        // own album folder and so its own folder.jpg.
1974        let mut store = LineageStore::new();
1975        insert_cache_only_root(&mut store, "aaaaaaaa-root-one");
1976        insert_cache_only_root(&mut store, "bbbbbbbb-root-two");
1977        store.set_album_overrides(
1978            [
1979                ("aaaaaaaa-root-one".to_owned(), "Shared".to_owned()),
1980                ("bbbbbbbb-root-two".to_owned(), "Shared".to_owned()),
1981            ]
1982            .into_iter()
1983            .collect(),
1984        );
1985        let colliding = store.colliding_root_titles();
1986
1987        let clip_of = |id: &str| Clip {
1988            id: id.to_owned(),
1989            title: "Track".to_owned(),
1990            display_name: "alice".to_owned(),
1991            image_large_url: "https://art.example/large.jpg".to_owned(),
1992            ..Default::default()
1993        };
1994        let ctx_of = |root_id: &str| LineageContext {
1995            root_id: root_id.to_owned(),
1996            root_title: "Shared".to_owned(),
1997            root_date: String::new(),
1998            parent_id: String::new(),
1999            edge_type: None,
2000            status: ResolveStatus::Resolved,
2001        };
2002        let clip_a = clip_of("clipaaaa-1111");
2003        let clip_b = clip_of("clipbbbb-2222");
2004        let ctx_a = ctx_of("aaaaaaaa-root-one");
2005        let ctx_b = ctx_of("bbbbbbbb-root-two");
2006        let requests = [
2007            crate::naming::NamingRequest {
2008                clip: &clip_a,
2009                lineage: &ctx_a,
2010            },
2011            crate::naming::NamingRequest {
2012                clip: &clip_b,
2013                lineage: &ctx_b,
2014            },
2015        ];
2016        let names = crate::naming::render_clip_names(
2017            &requests,
2018            &crate::naming::NamingConfig::default(),
2019            &colliding,
2020        );
2021
2022        let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
2023            crate::reconcile::Desired {
2024                clip: clip.clone(),
2025                lineage: ctx.clone(),
2026                path: format!("{}.flac", name.relative_path.to_string_lossy()),
2027                format: crate::AudioFormat::Flac,
2028                meta_hash: String::new(),
2029                art_hash: String::new(),
2030                modes: vec![crate::reconcile::SourceMode::Mirror],
2031                trashed: false,
2032                private: false,
2033                artifacts: Vec::new(),
2034                stems: None,
2035            }
2036        };
2037        let desired = vec![
2038            desired_of(&clip_a, &ctx_a, &names[0]),
2039            desired_of(&clip_b, &ctx_b, &names[1]),
2040        ];
2041
2042        let albums = crate::reconcile::album_desired(&desired, false, false);
2043        assert_eq!(albums.len(), 2, "each distinct root is its own album");
2044        let jpg_paths: Vec<String> = albums
2045            .iter()
2046            .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
2047            .collect();
2048        assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
2049        assert_ne!(
2050            jpg_paths[0], jpg_paths[1],
2051            "colliding roots must not share one folder.jpg path"
2052        );
2053    }
2054
2055    #[test]
2056    fn override_on_uncached_selected_root_is_ignored_and_keeps_albums_distinct() {
2057        // Residual-bug guard. On a resolution-FAILED run (no store.update), a
2058        // newly-listed clip is uncached, so context_for falls back to a
2059        // self-root (root_id = clip.id) that colliding_root_titles cannot see.
2060        // An override on that id must NOT apply this run, or the clip would
2061        // render/tag under a stored root's title with no [root_id8] suffix and
2062        // collapse two distinct albums onto one folder (and one folder.jpg).
2063        let mut store = LineageStore::new();
2064        store.update(
2065            std::slice::from_ref(&Clip {
2066                id: "realroot".into(),
2067                title: "Shared".into(),
2068                clip_type: "gen".into(),
2069                ..Default::default()
2070            }),
2071            &Resolution {
2072                roots: [(
2073                    "realroot".to_owned(),
2074                    RootInfo {
2075                        root_id: "realroot".into(),
2076                        root_title: "Shared".into(),
2077                        status: ResolveStatus::Resolved,
2078                    },
2079                )]
2080                .into_iter()
2081                .collect(),
2082                gap_filled: Vec::new(),
2083                bridges: Vec::new(),
2084            },
2085            "now",
2086        );
2087        // A newly-listed clip that failed to resolve this run: it is NOT in the
2088        // cache, and config overrides its id onto the stored root's title.
2089        let new_clip = Clip {
2090            id: "newnewnew-9999".into(),
2091            title: "Solo Track".into(),
2092            display_name: "alice".into(),
2093            image_large_url: "https://art.example/large.jpg".into(),
2094            ..Default::default()
2095        };
2096        store.set_album_overrides(
2097            [("newnewnew-9999".to_owned(), "Shared".to_owned())]
2098                .into_iter()
2099                .collect(),
2100        );
2101
2102        // The uncached clip folders under its OWN title, not the override.
2103        let new_ctx = store.context_for(&new_clip);
2104        assert_eq!(new_ctx.root_id, "newnewnew-9999");
2105        assert_eq!(new_ctx.album(&new_clip.title), "Solo Track");
2106
2107        // Collision detection is unchanged: the stored root stands alone.
2108        assert!(store.colliding_root_titles().is_empty());
2109
2110        // Album-art paths for the two distinct roots stay distinct.
2111        let real_clip = Clip {
2112            id: "realroot".into(),
2113            title: "Shared".into(),
2114            display_name: "alice".into(),
2115            image_large_url: "https://art.example/large.jpg".into(),
2116            ..Default::default()
2117        };
2118        let real_ctx = store.context_for(&real_clip);
2119        let colliding = store.colliding_root_titles();
2120        let requests = [
2121            crate::naming::NamingRequest {
2122                clip: &real_clip,
2123                lineage: &real_ctx,
2124            },
2125            crate::naming::NamingRequest {
2126                clip: &new_clip,
2127                lineage: &new_ctx,
2128            },
2129        ];
2130        let names = crate::naming::render_clip_names(
2131            &requests,
2132            &crate::naming::NamingConfig::default(),
2133            &colliding,
2134        );
2135        let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
2136            crate::reconcile::Desired {
2137                clip: clip.clone(),
2138                lineage: ctx.clone(),
2139                path: format!("{}.flac", name.relative_path.to_string_lossy()),
2140                format: crate::AudioFormat::Flac,
2141                meta_hash: String::new(),
2142                art_hash: String::new(),
2143                modes: vec![crate::reconcile::SourceMode::Mirror],
2144                trashed: false,
2145                private: false,
2146                artifacts: Vec::new(),
2147                stems: None,
2148            }
2149        };
2150        let desired = vec![
2151            desired_of(&real_clip, &real_ctx, &names[0]),
2152            desired_of(&new_clip, &new_ctx, &names[1]),
2153        ];
2154        let albums = crate::reconcile::album_desired(&desired, false, false);
2155        let jpg_paths: Vec<String> = albums
2156            .iter()
2157            .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
2158            .collect();
2159        assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
2160        assert_ne!(
2161            jpg_paths[0], jpg_paths[1],
2162            "an uncached override must not collapse two albums onto one path"
2163        );
2164    }
2165
2166    #[test]
2167    fn override_on_gap_filled_root_applies_to_children_and_collides() {
2168        // Round-3 regression. A gap-filled/archived root is a cache VALUE for its
2169        // children but never a cache KEY (see
2170        // resolve_roots_returns_gap_filled_ancestors_for_archival). An override
2171        // keyed by such a root must still apply to its children AND participate
2172        // in collision detection, so gating override-application on the cache
2173        // VALUE set (not the key set) is essential.
2174        let child = Clip {
2175            id: "childclip".into(),
2176            title: "Cover".into(),
2177            clip_type: "gen".into(),
2178            task: "cover".into(),
2179            cover_clip_id: "gaproot".into(),
2180            edited_clip_id: "gaproot".into(),
2181            ..Default::default()
2182        };
2183        let other_root = Clip {
2184            id: "otherroot".into(),
2185            title: "Preferred".into(),
2186            clip_type: "gen".into(),
2187            ..Default::default()
2188        };
2189        let gap_ancestor = Clip {
2190            id: "gaproot".into(),
2191            title: "Working Title".into(),
2192            clip_type: "gen".into(),
2193            ..Default::default()
2194        };
2195        let mut roots = HashMap::new();
2196        roots.insert(
2197            "childclip".to_owned(),
2198            RootInfo {
2199                root_id: "gaproot".into(),
2200                root_title: "Working Title".into(),
2201                status: ResolveStatus::Resolved,
2202            },
2203        );
2204        roots.insert(
2205            "otherroot".to_owned(),
2206            RootInfo {
2207                root_id: "otherroot".into(),
2208                root_title: "Preferred".into(),
2209                status: ResolveStatus::Resolved,
2210            },
2211        );
2212        let mut store = LineageStore::new();
2213        store.update(
2214            &[child.clone(), other_root],
2215            &Resolution {
2216                roots,
2217                gap_filled: vec![gap_ancestor],
2218                bridges: Vec::new(),
2219            },
2220            "now",
2221        );
2222        // "gaproot" is a node and a cache value, but NOT a cache key.
2223        assert!(store.node("gaproot").is_some());
2224        assert!(!store.resolution_cache.contains_key("gaproot"));
2225
2226        store.set_album_overrides(
2227            [("gaproot".to_owned(), "Preferred".to_owned())]
2228                .into_iter()
2229                .collect(),
2230        );
2231
2232        // The override on the gap-filled root reaches its child (would be
2233        // ignored under a cache-KEY gate).
2234        assert_eq!(store.context_for(&child).album(&child.title), "Preferred");
2235        assert_eq!(store.album_for_id("childclip"), "Preferred");
2236
2237        // And it participates in collision detection: two distinct roots now
2238        // resolve to "Preferred", so it is flagged.
2239        assert!(store.colliding_root_titles().contains("Preferred"));
2240    }
2241
2242    #[test]
2243    fn eligible_root_set_is_exactly_the_cache_value_domain() {
2244        // Tie-together guard: the set effective_root_title gates overrides on is
2245        // literally the set colliding_root_titles groups over (both read
2246        // eligible_root_ids), and refresh_eligible_roots computes it as the
2247        // non-empty root_id VALUES of the cache. If these drift, an override
2248        // could apply where a collision is invisible (or vice versa).
2249        let child = Clip {
2250            id: "childclip".into(),
2251            title: "Cover".into(),
2252            clip_type: "gen".into(),
2253            task: "cover".into(),
2254            cover_clip_id: "gaproot".into(),
2255            edited_clip_id: "gaproot".into(),
2256            ..Default::default()
2257        };
2258        let mut roots = HashMap::new();
2259        roots.insert(
2260            "childclip".to_owned(),
2261            RootInfo {
2262                root_id: "gaproot".into(),
2263                root_title: "Working Title".into(),
2264                status: ResolveStatus::Resolved,
2265            },
2266        );
2267        let mut store = LineageStore::new();
2268        store.update(
2269            std::slice::from_ref(&child),
2270            &Resolution {
2271                roots,
2272                gap_filled: vec![Clip {
2273                    id: "gaproot".into(),
2274                    title: "Working Title".into(),
2275                    clip_type: "gen".into(),
2276                    ..Default::default()
2277                }],
2278                bridges: Vec::new(),
2279            },
2280            "now",
2281        );
2282
2283        let expected: std::collections::HashSet<String> = store
2284            .resolution_cache
2285            .values()
2286            .map(|entry| entry.root_id.clone())
2287            .filter(|root_id| !root_id.is_empty())
2288            .collect();
2289        assert_eq!(*store.eligible_root_ids_for_test(), expected);
2290        // The gap-filled root is in the domain (a value), not because it is a key.
2291        assert!(store.eligible_root_ids_for_test().contains("gaproot"));
2292        assert!(!store.resolution_cache.contains_key("gaproot"));
2293    }
2294
2295    fn owner(id: &str, name: &str) -> Owner {
2296        Owner {
2297            user_id: id.to_owned(),
2298            display_name: name.to_owned(),
2299        }
2300    }
2301
2302    #[test]
2303    fn refresh_display_name_only_when_changed_and_never_when_unpinned() {
2304        let mut store = LineageStore::new();
2305        // Unpinned: nothing to refresh.
2306        assert!(!store.refresh_display_name("Alice"));
2307        assert!(store.owner().is_none());
2308
2309        store.pin_owner(owner("user_a", "Alice"));
2310        // Same name is a no-op.
2311        assert!(!store.refresh_display_name("Alice"));
2312        // A changed name updates and reports the change.
2313        assert!(store.refresh_display_name("Alice Cooper"));
2314        assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
2315        // The user id is left untouched.
2316        assert_eq!(store.owner().unwrap().user_id, "user_a");
2317    }
2318
2319    #[test]
2320    fn owner_gate_covers_the_full_matrix() {
2321        let alice = owner("user_a", "Alice");
2322
2323        // Unpinned defers to first-use, regardless of the flag.
2324        assert_eq!(owner_gate(None, None, "user_a", false), OwnerGate::FirstUse);
2325        assert_eq!(owner_gate(None, None, "user_a", true), OwnerGate::FirstUse);
2326
2327        // A matching owner proceeds.
2328        assert_eq!(
2329            owner_gate(Some(&alice), None, "user_a", false),
2330            OwnerGate::Proceed
2331        );
2332
2333        // A differing owner aborts without the flag, re-pins with it.
2334        assert_eq!(
2335            owner_gate(Some(&alice), None, "user_b", false),
2336            OwnerGate::AbortMismatch
2337        );
2338        assert_eq!(
2339            owner_gate(Some(&alice), None, "user_b", true),
2340            OwnerGate::Repin
2341        );
2342
2343        // A configured id that differs ALWAYS aborts, even with the flag and
2344        // even on a first-use (unpinned) library.
2345        assert_eq!(
2346            owner_gate(Some(&alice), Some("user_c"), "user_a", true),
2347            OwnerGate::AbortConfigMismatch
2348        );
2349        assert_eq!(
2350            owner_gate(None, Some("user_c"), "user_a", true),
2351            OwnerGate::AbortConfigMismatch
2352        );
2353        // A configured id that matches does not interfere.
2354        assert_eq!(
2355            owner_gate(Some(&alice), Some("user_a"), "user_a", false),
2356            OwnerGate::Proceed
2357        );
2358
2359        // Only Repin is additive.
2360        assert!(OwnerGate::Repin.is_additive());
2361        for gate in [
2362            OwnerGate::AbortConfigMismatch,
2363            OwnerGate::AbortMismatch,
2364            OwnerGate::Proceed,
2365            OwnerGate::FirstUse,
2366        ] {
2367            assert!(!gate.is_additive());
2368        }
2369    }
2370
2371    #[test]
2372    fn update_after_roundtrip_rebuilds_edge_index_without_duplicates() {
2373        let clips = chain_clips();
2374        let resolution = chain_resolution();
2375
2376        let mut store = LineageStore::new();
2377        store.update(&clips, &resolution, "first");
2378
2379        let json = serde_json::to_string(&store).unwrap();
2380        let mut store: LineageStore = serde_json::from_str(&json).unwrap();
2381
2382        store.update(&clips, &resolution, "second");
2383
2384        assert_eq!(store.edges.len(), 2);
2385        let cb = edge(&store, "c", "b");
2386        assert_eq!(cb.first_seen_at, "first");
2387        assert_eq!(cb.last_seen_at, "second");
2388        let ba = edge(&store, "b", "a");
2389        assert_eq!(ba.first_seen_at, "first");
2390        assert_eq!(ba.last_seen_at, "second");
2391    }
2392
2393    #[test]
2394    fn adopt_decision_covers_every_branch() {
2395        let owned: BTreeSet<&str> = ["c1", "c2"].into_iter().collect();
2396        let empty: BTreeSet<&str> = BTreeSet::new();
2397
2398        // Empty library adopts outright regardless of the listing or the flag.
2399        assert_eq!(
2400            adopt_decision(&["x", "y"], &empty, true, false),
2401            AdoptDecision::PinFresh
2402        );
2403        // Non-empty but not enumerated: cannot confirm, so leave it unpinned.
2404        assert_eq!(
2405            adopt_decision(&["c1"], &owned, false, false),
2406            AdoptDecision::SkipPin
2407        );
2408        assert_eq!(
2409            adopt_decision(&["c1"], &owned, false, true),
2410            AdoptDecision::SkipPin
2411        );
2412        // Enumerated with overlap: same account, adopt in normal mode.
2413        assert_eq!(
2414            adopt_decision(&["c1", "z"], &owned, true, false),
2415            AdoptDecision::PinAdopt
2416        );
2417        // Enumerated with no overlap: refuse without the flag, force-adopt with.
2418        assert_eq!(
2419            adopt_decision(&["z1", "z2"], &owned, true, false),
2420            AdoptDecision::Abort
2421        );
2422        assert_eq!(
2423            adopt_decision(&["z1", "z2"], &owned, true, true),
2424            AdoptDecision::AdoptForced
2425        );
2426
2427        // Only the forced adoption is additive.
2428        assert!(AdoptDecision::AdoptForced.is_additive());
2429        for decision in [
2430            AdoptDecision::PinFresh,
2431            AdoptDecision::PinAdopt,
2432            AdoptDecision::Abort,
2433            AdoptDecision::SkipPin,
2434        ] {
2435            assert!(!decision.is_additive());
2436        }
2437    }
2438
2439    #[test]
2440    fn older_store_without_owner_loads_as_none_and_pinned_roundtrips() {
2441        // A store written before the owner field existed loads with owner None.
2442        let json = r#"{"nodes":{},"edges":[]}"#;
2443        let store: LineageStore = serde_json::from_str(json).unwrap();
2444        assert!(store.owner().is_none());
2445        // An unpinned store omits the field entirely (skip_serializing_if).
2446        let value = serde_json::to_value(&store).unwrap();
2447        assert!(value.get("owner").is_none());
2448
2449        // A pinned store round-trips and serialises the owner.
2450        let mut pinned = LineageStore::new();
2451        pinned.pin_owner(owner("user_a", "Alice"));
2452        let back: LineageStore =
2453            serde_json::from_str(&serde_json::to_string(&pinned).unwrap()).unwrap();
2454        assert_eq!(back, pinned);
2455        assert_eq!(back.owner().unwrap().user_id, "user_a");
2456    }
2457
2458    #[test]
2459    fn on_disk_slugs_are_byte_identical_to_the_legacy_string_literals() {
2460        // The typed enums must serialize to the SAME slug strings as the old
2461        // hand-written literals. Any change here would corrupt existing stores.
2462        let mut store = LineageStore::new();
2463        store.update(&chain_clips(), &chain_resolution(), "now");
2464
2465        let value = serde_json::to_value(&store).unwrap();
2466        let edges = value.get("edges").unwrap().as_array().unwrap();
2467
2468        // There are two edges: c->b (cover/primary) and b->a (remaster/primary).
2469        let primary_edge = edges
2470            .iter()
2471            .find(|e| e.get("child_id").unwrap() == "c")
2472            .unwrap();
2473        assert_eq!(primary_edge.get("role").unwrap(), "primary");
2474        assert_eq!(primary_edge.get("status").unwrap(), "active");
2475        assert_eq!(primary_edge.get("edge_type").unwrap(), "cover");
2476
2477        // Node status slug.
2478        let node = value.get("nodes").unwrap().get("c").unwrap();
2479        assert_eq!(node.get("status").unwrap(), "observed");
2480
2481        // Cache entry status slug.
2482        let cache = value.get("resolution_cache").unwrap();
2483        assert_eq!(cache.get("a").unwrap().get("status").unwrap(), "resolved");
2484
2485        // A non-resolved status also serialises to the correct slug.
2486        let mut store2 = LineageStore::new();
2487        let child = Clip {
2488            id: "x".into(),
2489            ..Default::default()
2490        };
2491        let mut roots = HashMap::new();
2492        roots.insert(
2493            "x".to_owned(),
2494            RootInfo {
2495                root_id: "ext".into(),
2496                root_title: String::new(),
2497                status: ResolveStatus::External,
2498            },
2499        );
2500        store2.update(
2501            std::slice::from_ref(&child),
2502            &Resolution {
2503                roots,
2504                gap_filled: Vec::new(),
2505                bridges: Vec::new(),
2506            },
2507            "now",
2508        );
2509        let v2 = serde_json::to_value(&store2).unwrap();
2510        assert_eq!(
2511            v2.get("resolution_cache")
2512                .unwrap()
2513                .get("x")
2514                .unwrap()
2515                .get("status")
2516                .unwrap(),
2517            "external"
2518        );
2519    }
2520
2521    #[test]
2522    fn serde_roundtrip_is_byte_identical() {
2523        // The typed enums must not change the wire format: a store serialised
2524        // and then re-serialised must produce the same bytes.
2525        let mut store = LineageStore::new();
2526        store.update(&chain_clips(), &chain_resolution(), "now");
2527
2528        let first = serde_json::to_string(&store).unwrap();
2529        let back: LineageStore = serde_json::from_str(&first).unwrap();
2530        let second = serde_json::to_string(&back).unwrap();
2531        assert_eq!(first, second, "round-trip must be byte-identical");
2532    }
2533
2534    #[test]
2535    fn existing_string_form_json_deserialises_correctly() {
2536        // Existing on-disk stores use the plain slug strings; the typed enums
2537        // must still parse them correctly after this refactor.
2538        let json = r#"{
2539            "nodes": {"a": {"title": "Root", "status": "observed"}},
2540            "edges": [{"child_id": "b", "parent_id": "a", "role": "primary", "status": "active", "edge_type": "cover"}],
2541            "resolution_cache": {"b": {"root_id": "a", "status": "resolved"}}
2542        }"#;
2543        let store: LineageStore = serde_json::from_str(json).unwrap();
2544        assert_eq!(store.node("a").unwrap().status, NodeStatus::Observed);
2545        assert_eq!(store.edges[0].role, EdgeRole::Primary);
2546        assert_eq!(store.edges[0].status, EdgeStatus::Active);
2547        assert_eq!(store.get_root("b").unwrap().status, ResolveStatus::Resolved);
2548        // archived_parents uses typed comparison: the loaded edge must be returned.
2549        let archived = store.archived_parents();
2550        assert_eq!(archived.get("b").map(String::as_str), Some("a"));
2551    }
2552}