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};
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, PartialEq, Serialize, Deserialize)]
37#[serde(default)]
38pub struct LineageStore {
39    /// On-disk schema version, so a future migration can branch on it.
40    pub schema_version: u32,
41    /// Every clip ever seen (including trashed ancestors), keyed by clip id.
42    pub nodes: BTreeMap<String, Node>,
43    /// Every observed parent link, as a flat relational list.
44    pub edges: Vec<StoredEdge>,
45    /// The last resolved (or last-known) root per clip, keyed by clip id.
46    pub resolution_cache: BTreeMap<String, CacheEntry>,
47    /// The reconciled folder-art state per album, keyed by the album's stable
48    /// root id (HARDENING H2). Additive: absent in older stores, defaults empty.
49    pub albums: BTreeMap<String, AlbumArt>,
50    /// The reconciled `.m3u8` state per playlist, keyed by the playlist's Suno
51    /// id (the synthetic `"liked"` id for the liked feed). Additive: absent in
52    /// older stores, defaults empty.
53    pub playlists: BTreeMap<String, PlaylistState>,
54    /// The Suno account this library is pinned to (trust-on-first-use). Absent
55    /// in older stores and in a fresh library until the first run adopts it.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub owner: Option<Owner>,
58}
59
60impl Default for LineageStore {
61    fn default() -> Self {
62        Self {
63            schema_version: 1,
64            nodes: BTreeMap::new(),
65            edges: Vec::new(),
66            resolution_cache: BTreeMap::new(),
67            albums: BTreeMap::new(),
68            playlists: BTreeMap::new(),
69            owner: None,
70        }
71    }
72}
73
74/// The Suno account a library belongs to, pinned on first use.
75///
76/// The identity guard pins a library to the account it is first synced against
77/// and refuses to run it against a different account, so a mistyped or swapped
78/// token can never make one account's clips look absent from source and delete
79/// another account's files. `user_id` is the stable identity; `display_name`
80/// is cosmetic (for messages) and refreshed opportunistically on a match.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct Owner {
83    pub user_id: String,
84    pub display_name: String,
85}
86
87/// The verdict of comparing an authenticated account against a library's owner.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum OwnerCheck {
90    /// The library is not pinned yet, so it can be adopted (trust-on-first-use).
91    FirstUse,
92    /// The authenticated account owns this library.
93    Match,
94    /// The authenticated account differs from the pinned owner.
95    Mismatch,
96}
97
98/// The PHASE 1 identity verdict: whether an authenticated account may run
99/// against a library, computed with no network (see [`owner_gate`]).
100///
101/// This is the composition that gates deletion, kept pure so the full matrix
102/// (including the lock-in cases where a configured id or the owner pin refuses
103/// even when `--allow-account-change` is set) is unit-tested here rather than
104/// inline in the CLI.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum OwnerGate {
107    /// A configured `account_id` differs from the authenticated id: always
108    /// refuse, regardless of `--allow-account-change`.
109    AbortConfigMismatch,
110    /// The pinned owner differs and re-pinning was not permitted: refuse.
111    AbortMismatch,
112    /// The pinned owner differs but re-pinning was permitted: pin the new owner
113    /// and run additively (no deletions this invocation).
114    Repin,
115    /// The authenticated account owns this library: proceed (the caller then
116    /// refreshes the pinned display name).
117    Proceed,
118    /// The library is not pinned yet: defer to the PHASE 2 adoption decision.
119    FirstUse,
120}
121
122impl OwnerGate {
123    /// Whether this outcome forces an additive (no-deletion) run.
124    pub fn is_additive(self) -> bool {
125        matches!(self, OwnerGate::Repin)
126    }
127}
128
129/// Decide whether an authenticated account may run against a library (PHASE 1).
130///
131/// A configured `account_id` that differs always aborts, even with
132/// `allow_change` set, because it is an explicit operator assertion. Otherwise
133/// an unpinned library defers to first-use adoption, a matching owner proceeds,
134/// and a differing owner either re-pins (when `allow_change`) or aborts.
135pub fn owner_gate(
136    store_owner: Option<&Owner>,
137    configured_id: Option<&str>,
138    authed_user_id: &str,
139    allow_change: bool,
140) -> OwnerGate {
141    if let Some(configured) = configured_id
142        && configured != authed_user_id
143    {
144        return OwnerGate::AbortConfigMismatch;
145    }
146    match store_owner {
147        None => OwnerGate::FirstUse,
148        Some(owner) if owner.user_id == authed_user_id => OwnerGate::Proceed,
149        Some(_) if allow_change => OwnerGate::Repin,
150        Some(_) => OwnerGate::AbortMismatch,
151    }
152}
153
154/// The PHASE 2 first-use adoption decision for a not-yet-pinned library.
155///
156/// Computed by [`adopt_decision`] from the account's listed clip ids, the
157/// library's already-owned clip ids, whether the listing is complete, and
158/// whether `--allow-account-change` was passed.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum AdoptDecision {
161    /// The destination holds no clips yet: pin it as a fresh library (normal
162    /// mode; a fresh library has nothing to delete).
163    PinFresh,
164    /// A complete listing overlaps the existing library: same account, pin it
165    /// (normal mode).
166    PinAdopt,
167    /// A complete listing shares nothing with the existing library but
168    /// `--allow-account-change` was passed: adopt it and run additively.
169    AdoptForced,
170    /// A complete listing shares nothing with the existing library and no
171    /// override was passed: refuse.
172    Abort,
173    /// A narrowed (incomplete) listing cannot confirm identity: do not pin.
174    SkipPin,
175}
176
177impl AdoptDecision {
178    /// Whether this outcome forces an additive (no-deletion) run.
179    pub fn is_additive(self) -> bool {
180        matches!(self, AdoptDecision::AdoptForced)
181    }
182}
183
184/// Decide how to adopt a not-yet-pinned library from this run's listing.
185///
186/// An empty library is adopted outright; otherwise identity is confirmed by an
187/// overlap between the authenticated account's `listed` clip ids and the
188/// library's `owned` clip ids, but only on a fully `enumerated` listing. A
189/// complete listing with no overlap is a different (or wiped) account: it
190/// refuses, unless `allow_change` opts into a forced additive adoption. A
191/// narrowed listing (a `--limit`/`--since` run, where deletion is disabled
192/// anyway) cannot confirm identity, so the library is left unpinned.
193pub fn adopt_decision(
194    listed: &[&str],
195    owned: &BTreeSet<&str>,
196    enumerated: bool,
197    allow_change: bool,
198) -> AdoptDecision {
199    if owned.is_empty() {
200        return AdoptDecision::PinFresh;
201    }
202    if !enumerated {
203        return AdoptDecision::SkipPin;
204    }
205    if listed.iter().any(|id| owned.contains(id)) {
206        AdoptDecision::PinAdopt
207    } else if allow_change {
208        AdoptDecision::AdoptForced
209    } else {
210        AdoptDecision::Abort
211    }
212}
213
214/// The reconciled folder-art state for one album (one stable root id).
215///
216/// Folder art is album-scoped, not per-clip, so it lives here rather than on a
217/// [`ManifestEntry`](crate::manifest::ManifestEntry). Each slot records the
218/// sidecar's path and the content hash of the art it was rendered from, so a
219/// later reconcile rewrites only on a genuine content change (HARDENING H1: a
220/// most-played flip that yields the same art hash is a no-op). Kept relational
221/// (two explicit slots) so it migrates cleanly to a SQLite `album_art` table.
222#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(default)]
224pub struct AlbumArt {
225    /// The album's static `folder.jpg`, sourced from the most-played variant.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub folder_jpg: Option<ArtifactState>,
228    /// The album's animated `cover.webp`, from the first-created animated variant.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub folder_webp: Option<ArtifactState>,
231}
232
233impl AlbumArt {
234    /// The stored state for one folder-art `kind`, if present. Per-clip and
235    /// library kinds have no album slot and map to `None`.
236    pub fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
237        match kind {
238            ArtifactKind::FolderJpg => self.folder_jpg.as_ref(),
239            ArtifactKind::FolderWebp => self.folder_webp.as_ref(),
240            ArtifactKind::CoverJpg
241            | ArtifactKind::CoverWebp
242            | ArtifactKind::DetailsTxt
243            | ArtifactKind::LyricsTxt
244            | ArtifactKind::Lrc
245            | ArtifactKind::VideoMp4
246            | ArtifactKind::Playlist => None,
247        }
248    }
249
250    /// Set (or clear, with `None`) the state for one folder-art `kind`.
251    ///
252    /// The executor calls this after a folder-art write (with the new state) or
253    /// delete (with `None`), so the kind-to-slot mapping lives in one place.
254    /// Non-album kinds have no slot here and are no-ops.
255    pub fn set(&mut self, kind: ArtifactKind, state: Option<ArtifactState>) {
256        match kind {
257            ArtifactKind::FolderJpg => self.folder_jpg = state,
258            ArtifactKind::FolderWebp => self.folder_webp = state,
259            ArtifactKind::CoverJpg
260            | ArtifactKind::CoverWebp
261            | ArtifactKind::DetailsTxt
262            | ArtifactKind::LyricsTxt
263            | ArtifactKind::Lrc
264            | ArtifactKind::VideoMp4
265            | ArtifactKind::Playlist => {}
266        }
267    }
268
269    /// True when the album holds no folder art at all (both slots empty), so the
270    /// store can prune the now-dead album row.
271    pub fn is_empty(&self) -> bool {
272        self.folder_jpg.is_none() && self.folder_webp.is_none()
273    }
274}
275
276/// The reconciled `.m3u8` state for one playlist.
277///
278/// A playlist's body is *generated*, not fetched, so unlike per-clip artifacts
279/// its change detection is a single content hash over the full rendered text
280/// (HARDENING B1: name, order, and every member's path/title/duration feed it).
281/// The `path` is the sidecar's library-relative location, tracked so a rename
282/// (a playlist renamed on Suno) is detected and the old file removed. Kept as a
283/// flat row so it migrates cleanly to a SQLite `playlists` table.
284#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(default)]
286pub struct PlaylistState {
287    /// The playlist's display name at the time it was last written.
288    pub name: String,
289    /// The `.m3u8` file's library-relative path (`<sanitised name>.m3u8`).
290    pub path: String,
291    /// The content hash of the rendered `.m3u8` this row was written from.
292    pub hash: String,
293}
294
295/// One clip in the graph. Mirrors the fields lineage needs to survive a purge:
296/// enough to name and date the clip long after Suno deletes it.
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298#[serde(default)]
299pub struct Node {
300    pub title: String,
301    pub created_at: String,
302    pub clip_type: String,
303    pub task: String,
304    pub is_remix: bool,
305    pub is_trashed: bool,
306    /// Lifecycle marker; `"observed"` for a clip seen from the feed or gap-fill.
307    pub status: String,
308    pub first_seen_at: String,
309    pub last_seen_at: String,
310}
311
312impl Default for Node {
313    fn default() -> Self {
314        Self {
315            title: String::new(),
316            created_at: String::new(),
317            clip_type: String::new(),
318            task: String::new(),
319            is_remix: false,
320            is_trashed: false,
321            status: "observed".to_owned(),
322            first_seen_at: String::new(),
323            last_seen_at: String::new(),
324        }
325    }
326}
327
328/// One parent link, keyed (for upsert) by `(child_id, parent_id, edge_type,
329/// role, ordinal)`. A flat row, not nested under its child, so it maps directly
330/// to a `lineage_edges` table.
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332#[serde(default)]
333pub struct StoredEdge {
334    pub child_id: String,
335    pub parent_id: String,
336    /// Stable lowercase slug, e.g. `"cover"`, `"remaster"`, `"section_replace"`.
337    pub edge_type: String,
338    /// `"primary"` for the rooting parent, `"secondary"` for extra sources.
339    pub role: String,
340    /// The clip field the parent id was read from, e.g. `"cover_clip_id"`.
341    pub source_field: String,
342    /// Position within its role (0 for the primary, then secondaries in order).
343    pub ordinal: u32,
344    /// Lifecycle marker; `"active"` for an edge observed this run.
345    pub status: String,
346    pub first_seen_at: String,
347    pub last_seen_at: String,
348}
349
350impl Default for StoredEdge {
351    fn default() -> Self {
352        Self {
353            child_id: String::new(),
354            parent_id: String::new(),
355            edge_type: String::new(),
356            role: String::new(),
357            source_field: String::new(),
358            ordinal: 0,
359            status: "active".to_owned(),
360            first_seen_at: String::new(),
361            last_seen_at: String::new(),
362        }
363    }
364}
365
366/// A cached root resolution for one clip: the O(1) album lookup, kept monotonic.
367#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
368#[serde(default)]
369pub struct CacheEntry {
370    pub root_id: String,
371    /// `"resolved"`, or a slug of the terminal status (`"external"`, …).
372    pub status: String,
373    pub algorithm_version: u32,
374    pub computed_at: String,
375}
376
377impl LineageStore {
378    /// Create an empty store at the current schema version.
379    pub fn new() -> Self {
380        Self::default()
381    }
382
383    /// The node for `id`, if present.
384    pub fn node(&self, id: &str) -> Option<&Node> {
385        self.nodes.get(id)
386    }
387
388    /// The account this library is pinned to, if any.
389    pub fn owner(&self) -> Option<&Owner> {
390        self.owner.as_ref()
391    }
392
393    /// Compare an authenticated `user_id` against the pinned owner.
394    pub fn owner_check(&self, user_id: &str) -> OwnerCheck {
395        match &self.owner {
396            None => OwnerCheck::FirstUse,
397            Some(owner) if owner.user_id == user_id => OwnerCheck::Match,
398            Some(_) => OwnerCheck::Mismatch,
399        }
400    }
401
402    /// Pin this library to `owner`, replacing any prior pin.
403    pub fn pin_owner(&mut self, owner: Owner) {
404        self.owner = Some(owner);
405    }
406
407    /// Refresh the pinned owner's display name when it has changed, returning
408    /// whether it changed. A no-op when the library is not pinned.
409    pub fn refresh_display_name(&mut self, display_name: &str) -> bool {
410        match &mut self.owner {
411            Some(owner) if owner.display_name != display_name => {
412                owner.display_name = display_name.to_owned();
413                true
414            }
415            _ => false,
416        }
417    }
418
419    /// The cached root resolution for `id`, if present.
420    pub fn get_root(&self, id: &str) -> Option<&CacheEntry> {
421        self.resolution_cache.get(id)
422    }
423
424    /// The reconciled folder-art state for the album rooted at `root_id`.
425    pub fn album_art(&self, root_id: &str) -> Option<&AlbumArt> {
426        self.albums.get(root_id)
427    }
428
429    /// Set (or clear, with `None`) one folder-art `kind` for the album rooted at
430    /// `root_id`.
431    ///
432    /// A set upserts the album row; a clear that empties the row removes it, so
433    /// the store never accumulates dead all-`None` album entries. This is the
434    /// store-level counterpart the CLI persists after the executor mutates the
435    /// [`albums`](Self::albums) map in place.
436    pub fn set_album_artifact(
437        &mut self,
438        root_id: &str,
439        kind: ArtifactKind,
440        state: Option<ArtifactState>,
441    ) {
442        match state {
443            Some(state) => self
444                .albums
445                .entry(root_id.to_owned())
446                .or_default()
447                .set(kind, Some(state)),
448            None => {
449                if let Some(art) = self.albums.get_mut(root_id) {
450                    art.set(kind, None);
451                    if art.is_empty() {
452                        self.albums.remove(root_id);
453                    }
454                }
455            }
456        }
457    }
458
459    /// The reconciled `.m3u8` state for the playlist with `id`, if present.
460    pub fn playlist(&self, id: &str) -> Option<&PlaylistState> {
461        self.playlists.get(id)
462    }
463
464    /// Upsert (with `Some`) or remove (with `None`) the `.m3u8` state for the
465    /// playlist `id`.
466    ///
467    /// This is the store-level counterpart the CLI persists after the executor
468    /// mutates the [`playlists`](Self::playlists) map in place: a write records
469    /// the new state; a delete clears the row so the store never keeps a
470    /// dangling entry for a playlist whose file was removed.
471    pub fn set_playlist(&mut self, id: &str, state: Option<PlaylistState>) {
472        match state {
473            Some(state) => {
474                self.playlists.insert(id.to_owned(), state);
475            }
476            None => {
477                self.playlists.remove(id);
478            }
479        }
480    }
481
482    /// Build a [`LineageContext`] for `clip` from the durable store.
483    ///
484    /// This is the source of truth for every file-affecting lineage decision
485    /// (album folder, embedded tags, the change hash), so a dropped resolution
486    /// call never rewrites the library (HARDENING H3). The root comes from the
487    /// monotonic resolution cache (the clip's own id when the store has no
488    /// better answer) and the root title from that root's archived node, so a
489    /// transient miss keeps the last-known-good album even for a since-purged
490    /// ancestor. The parent edge is read structurally from the clip itself.
491    pub fn context_for(&self, clip: &Clip) -> LineageContext {
492        let cached = self.get_root(&clip.id);
493        let root_id = cached
494            .map(|entry| entry.root_id.clone())
495            .filter(|id| !id.is_empty())
496            .unwrap_or_else(|| clip.id.clone());
497        let root_title = self
498            .node(&root_id)
499            .map(|node| node.title.clone())
500            .unwrap_or_else(|| clip.title.clone());
501        let (parent_id, edge_type) = match immediate_parent(clip) {
502            Some((id, edge)) => (id, Some(edge)),
503            None => (String::new(), None),
504        };
505        let status = cached
506            .map(|entry| status_from_slug(&entry.status))
507            .unwrap_or(ResolveStatus::Resolved);
508        LineageContext {
509            root_id,
510            root_title,
511            parent_id,
512            edge_type,
513            status,
514        }
515    }
516
517    /// The canonical logical album title for a clip identified only by `id`.
518    ///
519    /// The store-side counterpart of `context_for(clip).album(clip.title)` for a
520    /// clip that is not part of the current run (so no live [`Clip`] is on hand).
521    /// The clip's own title and its root come from the archived nodes and the
522    /// monotonic resolution cache, then the same [`LineageContext::album`] rule
523    /// decides whether the clip folders under its root's album or its own title.
524    /// A clip absent from the store folds to a self-root with an empty title.
525    pub fn album_for_id(&self, id: &str) -> String {
526        let own_title = self
527            .node(id)
528            .map(|node| node.title.clone())
529            .unwrap_or_default();
530        let root_id = self
531            .get_root(id)
532            .map(|entry| entry.root_id.clone())
533            .filter(|root| !root.is_empty())
534            .unwrap_or_else(|| id.to_owned());
535        let root_title = self
536            .node(&root_id)
537            .map(|node| node.title.clone())
538            .unwrap_or_else(|| own_title.clone());
539        let context = LineageContext {
540            root_id,
541            root_title,
542            parent_id: String::new(),
543            edge_type: None,
544            status: ResolveStatus::Resolved,
545        };
546        context.album(&own_title)
547    }
548
549    /// The set of root titles shared by more than one distinct root.
550    ///
551    /// Two distinct roots must never share an album folder (two different
552    /// uploads titled "Break Through" exist), so naming appends the short root
553    /// id to the album of any clip whose root title is in this set. It is
554    /// computed from the whole archive — every distinct root in the resolution
555    /// cache paired with its node title — so the decision is stable across runs
556    /// and independent of the current batch: a `--since`/`--limit` slice that
557    /// shows only one of two same-titled roots still disambiguates, instead of
558    /// oscillating between a bare and a suffixed folder.
559    pub fn colliding_root_titles(&self) -> BTreeSet<String> {
560        let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
561        for entry in self.resolution_cache.values() {
562            if entry.root_id.is_empty() {
563                continue;
564            }
565            let Some(node) = self.nodes.get(&entry.root_id) else {
566                continue;
567            };
568            let title = node.title.trim();
569            if title.is_empty() {
570                continue;
571            }
572            roots_by_title
573                .entry(title.to_owned())
574                .or_default()
575                .insert(entry.root_id.clone());
576        }
577        roots_by_title
578            .into_iter()
579            .filter(|(_, roots)| roots.len() > 1)
580            .map(|(title, _)| title)
581            .collect()
582    }
583
584    /// Number of nodes in the graph.
585    pub fn len(&self) -> usize {
586        self.nodes.len()
587    }
588
589    /// True when the graph holds no nodes.
590    pub fn is_empty(&self) -> bool {
591        self.nodes.is_empty()
592    }
593
594    /// Iterate nodes in clip-id order.
595    pub fn iter(&self) -> Iter<'_, String, Node> {
596        self.nodes.iter()
597    }
598
599    /// Fold this run's clips and their [`Resolution`] into the store.
600    ///
601    /// Pure: it takes `now` (an ISO timestamp) from the caller rather than
602    /// reading a clock. Upserts a node for every clip *and* every gap-filled
603    /// ancestor (so trashed ancestors are archived), upserts an edge for every
604    /// [`lineage_edges`] link, and refreshes the monotonic resolution cache.
605    /// `edges` is left sorted so the serialised form is deterministic.
606    pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
607        for clip in clips {
608            self.upsert_node(clip, now);
609        }
610        // Gap-filled ancestors are not download candidates, but their lineage
611        // must be archived before Suno purges them, so they become nodes too.
612        for clip in &resolution.gap_filled {
613            self.upsert_node(clip, now);
614        }
615
616        for clip in clips {
617            for edge in lineage_edges(clip) {
618                self.upsert_edge(&clip.id, &edge, now);
619            }
620        }
621        self.edges.sort_by(|a, b| {
622            a.child_id
623                .cmp(&b.child_id)
624                .then(a.ordinal.cmp(&b.ordinal))
625                .then(a.parent_id.cmp(&b.parent_id))
626                .then(a.edge_type.cmp(&b.edge_type))
627                .then(a.role.cmp(&b.role))
628        });
629
630        for (child_id, info) in &resolution.roots {
631            self.upsert_cache(child_id, info, now);
632        }
633    }
634
635    /// Insert or refresh the node for `clip`. `first_seen_at` and `status` are
636    /// set once on insert; everything else is refreshed to the latest sighting.
637    fn upsert_node(&mut self, clip: &Clip, now: &str) {
638        let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
639            first_seen_at: now.to_owned(),
640            ..Node::default()
641        });
642        node.title = clip.title.clone();
643        node.created_at = clip.created_at.clone();
644        node.clip_type = clip.clip_type.clone();
645        node.task = clip.task.clone();
646        node.is_remix = clip.is_remix;
647        node.is_trashed = clip.is_trashed;
648        node.last_seen_at = now.to_owned();
649    }
650
651    /// Insert or refresh the edge from `child_id` to `edge.parent_id`, keyed by
652    /// `(child_id, parent_id, edge_type, role, ordinal)`.
653    fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
654        let edge_type = edge_type_slug(edge.edge_type);
655        let role = edge_role_slug(edge.role);
656        if let Some(existing) = self.edges.iter_mut().find(|stored| {
657            stored.child_id == child_id
658                && stored.parent_id == edge.parent_id
659                && stored.edge_type == edge_type
660                && stored.role == role
661                && stored.ordinal == edge.ordinal
662        }) {
663            existing.source_field = edge.source_field.to_owned();
664            existing.status = "active".to_owned();
665            existing.last_seen_at = now.to_owned();
666        } else {
667            self.edges.push(StoredEdge {
668                child_id: child_id.to_owned(),
669                parent_id: edge.parent_id.clone(),
670                edge_type: edge_type.to_owned(),
671                role: role.to_owned(),
672                source_field: edge.source_field.to_owned(),
673                ordinal: edge.ordinal,
674                status: "active".to_owned(),
675                first_seen_at: now.to_owned(),
676                last_seen_at: now.to_owned(),
677            });
678        }
679    }
680
681    /// Fold one clip's root resolution into the cache, monotonically.
682    ///
683    /// A [`Resolved`](ResolveStatus::Resolved) root always wins. A non-resolved
684    /// outcome (external, unresolved, cycle) never overwrites an existing
685    /// resolved root — a transient gap-fill miss must not downgrade a good
686    /// album. Otherwise the last-known non-resolved status is recorded.
687    fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
688        if info.status != ResolveStatus::Resolved
689            && self
690                .resolution_cache
691                .get(child_id)
692                .is_some_and(|entry| entry.status == "resolved")
693        {
694            return;
695        }
696        self.resolution_cache.insert(
697            child_id.to_owned(),
698            CacheEntry {
699                root_id: info.root_id.clone(),
700                status: resolve_status_slug(info.status).to_owned(),
701                algorithm_version: 1,
702                computed_at: now.to_owned(),
703            },
704        );
705    }
706}
707
708/// The stable on-disk slug for an [`EdgeType`].
709fn edge_type_slug(edge_type: EdgeType) -> &'static str {
710    match edge_type {
711        EdgeType::Cover => "cover",
712        EdgeType::Remaster => "remaster",
713        EdgeType::SpeedEdit => "speed_edit",
714        EdgeType::Edit => "edit",
715        EdgeType::Extend => "extend",
716        EdgeType::SectionReplace => "section_replace",
717        EdgeType::Stitch => "stitch",
718        EdgeType::Derived => "derived",
719        EdgeType::Uploaded => "uploaded",
720    }
721}
722
723/// The stable on-disk slug for an [`EdgeRole`].
724fn edge_role_slug(role: EdgeRole) -> &'static str {
725    match role {
726        EdgeRole::Primary => "primary",
727        EdgeRole::Secondary => "secondary",
728    }
729}
730
731/// The stable on-disk slug for a [`ResolveStatus`].
732fn resolve_status_slug(status: ResolveStatus) -> &'static str {
733    match status {
734        ResolveStatus::Resolved => "resolved",
735        ResolveStatus::External => "external",
736        ResolveStatus::Unresolved => "unresolved",
737        ResolveStatus::Cycle => "cycle",
738    }
739}
740
741/// Parse a cached status slug back into a [`ResolveStatus`], defaulting to
742/// [`Resolved`](ResolveStatus::Resolved) for the self-root/unknown case.
743fn status_from_slug(slug: &str) -> ResolveStatus {
744    match slug {
745        "external" => ResolveStatus::External,
746        "unresolved" => ResolveStatus::Unresolved,
747        "cycle" => ResolveStatus::Cycle,
748        _ => ResolveStatus::Resolved,
749    }
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755    use std::collections::HashMap;
756
757    /// A clean three-clip chain: cover -> remaster -> gen root, all present.
758    fn chain_clips() -> Vec<Clip> {
759        vec![
760            Clip {
761                id: "c".into(),
762                title: "Cover".into(),
763                clip_type: "gen".into(),
764                task: "cover".into(),
765                created_at: "t2".into(),
766                cover_clip_id: "b".into(),
767                edited_clip_id: "b".into(),
768                ..Default::default()
769            },
770            Clip {
771                id: "b".into(),
772                title: "Remaster".into(),
773                clip_type: "upsample".into(),
774                task: "upsample".into(),
775                created_at: "t1".into(),
776                upsample_clip_id: "a".into(),
777                edited_clip_id: "a".into(),
778                ..Default::default()
779            },
780            Clip {
781                id: "a".into(),
782                title: "Root".into(),
783                clip_type: "gen".into(),
784                created_at: "t0".into(),
785                ..Default::default()
786            },
787        ]
788    }
789
790    /// The matching resolution: every clip roots at `a`, all resolved.
791    fn chain_resolution() -> Resolution {
792        let mut roots = HashMap::new();
793        for id in ["a", "b", "c"] {
794            roots.insert(
795                id.to_owned(),
796                RootInfo {
797                    root_id: "a".into(),
798                    root_title: "Root".into(),
799                    status: ResolveStatus::Resolved,
800                },
801            );
802        }
803        Resolution {
804            roots,
805            gap_filled: Vec::new(),
806        }
807    }
808
809    fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
810        store
811            .edges
812            .iter()
813            .find(|e| e.child_id == child && e.parent_id == parent)
814            .expect("edge should exist")
815    }
816
817    #[test]
818    fn new_store_is_empty_and_versioned() {
819        let store = LineageStore::new();
820        assert!(store.is_empty());
821        assert_eq!(store.len(), 0);
822        assert_eq!(store.schema_version, 1);
823    }
824
825    #[test]
826    fn update_populates_nodes_edges_and_cache() {
827        let mut store = LineageStore::new();
828        store.update(&chain_clips(), &chain_resolution(), "now");
829
830        // A node per clip, dated and typed from the clip.
831        assert_eq!(store.len(), 3);
832        let cover = store.node("c").unwrap();
833        assert_eq!(cover.title, "Cover");
834        assert_eq!(cover.clip_type, "gen");
835        assert_eq!(cover.task, "cover");
836        assert_eq!(cover.created_at, "t2");
837        assert_eq!(cover.status, "observed");
838        assert!(!cover.is_trashed);
839        assert_eq!(cover.first_seen_at, "now");
840        assert_eq!(cover.last_seen_at, "now");
841
842        // One primary edge per non-root clip; the root emits none.
843        assert_eq!(store.edges.len(), 2);
844        let cb = edge(&store, "c", "b");
845        assert_eq!(cb.edge_type, "cover");
846        assert_eq!(cb.role, "primary");
847        assert_eq!(cb.ordinal, 0);
848        assert_eq!(cb.source_field, "cover_clip_id");
849        assert_eq!(cb.status, "active");
850        let ba = edge(&store, "b", "a");
851        assert_eq!(ba.edge_type, "remaster");
852        assert!(!store.edges.iter().any(|e| e.child_id == "a"));
853
854        // The cache roots every clip at `a`, resolved.
855        for id in ["a", "b", "c"] {
856            let cached = store.get_root(id).unwrap();
857            assert_eq!(cached.root_id, "a");
858            assert_eq!(cached.status, "resolved");
859            assert_eq!(cached.algorithm_version, 1);
860        }
861    }
862
863    #[test]
864    fn album_for_id_matches_context_for_and_handles_unknown() {
865        let mut store = LineageStore::new();
866        store.update(&chain_clips(), &chain_resolution(), "now");
867
868        // A child folds under its differently-titled root, agreeing with the
869        // live-clip rule via context_for.
870        assert_eq!(store.album_for_id("c"), "Root");
871        let cover = &chain_clips()[0];
872        assert_eq!(
873            store.album_for_id("c"),
874            store.context_for(cover).album(&cover.title)
875        );
876        // The root folders under its own title.
877        assert_eq!(store.album_for_id("a"), "Root");
878        // An id absent from the store folds to an empty own title.
879        assert_eq!(store.album_for_id("missing"), "");
880    }
881
882    #[test]
883    fn serde_roundtrip_preserves_a_relational_shape() {
884        let mut store = LineageStore::new();
885        store.update(&chain_clips(), &chain_resolution(), "now");
886
887        let json = serde_json::to_string(&store).unwrap();
888        let back: LineageStore = serde_json::from_str(&json).unwrap();
889        assert_eq!(store, back);
890
891        let value: serde_json::Value = serde_json::to_value(&store).unwrap();
892        assert_eq!(value.get("schema_version").unwrap(), 1);
893        assert!(value.get("nodes").unwrap().is_object());
894        assert!(value.get("edges").unwrap().is_array());
895        assert!(value.get("resolution_cache").unwrap().is_object());
896
897        // Relational, not adjacency: a node carries no edges/parent of its own,
898        // and an edge is a flat row keyed by child and parent.
899        let node = value.get("nodes").unwrap().get("c").unwrap();
900        assert!(node.get("edges").is_none());
901        assert!(node.get("parent_id").is_none());
902        let first_edge = value.get("edges").unwrap().get(0).unwrap();
903        assert!(first_edge.get("child_id").is_some());
904        assert!(first_edge.get("parent_id").is_some());
905    }
906
907    #[test]
908    fn update_is_idempotent_bar_last_seen() {
909        let clips = chain_clips();
910        let resolution = chain_resolution();
911        let mut store = LineageStore::new();
912        store.update(&clips, &resolution, "first");
913        let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
914        let edge_count = store.edges.len();
915
916        store.update(&clips, &resolution, "second");
917
918        // No new nodes, edges, or cache rows: the second run only refreshes.
919        assert_eq!(
920            store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
921            node_ids
922        );
923        assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
924        assert_eq!(store.resolution_cache.len(), 3);
925
926        // first_seen_at sticks; last_seen_at advances.
927        let cover = store.node("c").unwrap();
928        assert_eq!(cover.first_seen_at, "first");
929        assert_eq!(cover.last_seen_at, "second");
930        let cb = edge(&store, "c", "b");
931        assert_eq!(cb.first_seen_at, "first");
932        assert_eq!(cb.last_seen_at, "second");
933        // Root ids are stable across the re-run.
934        assert_eq!(store.get_root("c").unwrap().root_id, "a");
935    }
936
937    #[test]
938    fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
939        let mut store = LineageStore::new();
940        store.update(&chain_clips(), &chain_resolution(), "first");
941        assert_eq!(store.get_root("c").unwrap().status, "resolved");
942
943        // A later run where `c` fails to resolve (a transient gap-fill miss)
944        // and a brand-new clip `d` that only reaches an external boundary.
945        let child = Clip {
946            id: "c".into(),
947            title: "Cover".into(),
948            clip_type: "gen".into(),
949            task: "cover".into(),
950            cover_clip_id: "b".into(),
951            edited_clip_id: "b".into(),
952            ..Default::default()
953        };
954        let mut roots = HashMap::new();
955        roots.insert(
956            "c".to_owned(),
957            RootInfo {
958                root_id: "elsewhere".into(),
959                root_title: String::new(),
960                status: ResolveStatus::External,
961            },
962        );
963        roots.insert(
964            "d".to_owned(),
965            RootInfo {
966                root_id: "boundary".into(),
967                root_title: String::new(),
968                status: ResolveStatus::External,
969            },
970        );
971        let resolution = Resolution {
972            roots,
973            gap_filled: Vec::new(),
974        };
975        store.update(&[child], &resolution, "second");
976
977        // The resolved root of `c` is kept, not downgraded.
978        let cached = store.get_root("c").unwrap();
979        assert_eq!(cached.root_id, "a");
980        assert_eq!(cached.status, "resolved");
981        assert_eq!(cached.computed_at, "first");
982        // A never-resolved clip records its last-known non-resolved status.
983        let d = store.get_root("d").unwrap();
984        assert_eq!(d.root_id, "boundary");
985        assert_eq!(d.status, "external");
986    }
987
988    #[test]
989    fn gap_filled_trashed_ancestor_is_a_durable_node() {
990        // The trashed ancestor is not among `clips`; it arrives only via the
991        // resolution's gap_filled set, yet must be archived as a node so its
992        // lineage survives Suno's purge (HARDENING H4 / L2).
993        let child = Clip {
994            id: "c".into(),
995            title: "Cover".into(),
996            clip_type: "gen".into(),
997            task: "cover".into(),
998            cover_clip_id: "t".into(),
999            edited_clip_id: "t".into(),
1000            ..Default::default()
1001        };
1002        let trashed = Clip {
1003            id: "t".into(),
1004            title: "Trashed Original".into(),
1005            clip_type: "gen".into(),
1006            is_trashed: true,
1007            ..Default::default()
1008        };
1009        let mut roots = HashMap::new();
1010        roots.insert(
1011            "c".to_owned(),
1012            RootInfo {
1013                root_id: "t".into(),
1014                root_title: "Trashed Original".into(),
1015                status: ResolveStatus::Resolved,
1016            },
1017        );
1018        let resolution = Resolution {
1019            roots,
1020            gap_filled: vec![trashed],
1021        };
1022        store_update_and_assert_trashed(child, resolution);
1023    }
1024
1025    fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
1026        let mut store = LineageStore::new();
1027        store.update(&[child], &resolution, "now");
1028
1029        let node = store
1030            .node("t")
1031            .expect("trashed ancestor should be archived");
1032        assert!(node.is_trashed);
1033        assert_eq!(node.title, "Trashed Original");
1034        // The child roots at the trashed ancestor.
1035        assert_eq!(store.get_root("c").unwrap().root_id, "t");
1036    }
1037
1038    #[test]
1039    fn partial_json_loads_with_defaults() {
1040        // An older/partial file missing whole collections and per-row fields
1041        // still loads: container and row defaults fill the gaps.
1042        let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
1043        let store: LineageStore = serde_json::from_str(json).unwrap();
1044        assert_eq!(store.schema_version, 1);
1045        let node = store.node("x").unwrap();
1046        assert_eq!(node.title, "Kept");
1047        assert_eq!(node.status, "observed");
1048        assert_eq!(store.edges[0].status, "active");
1049        assert!(store.resolution_cache.is_empty());
1050        // The album-art collection is additive: a store written before folder
1051        // art existed loads with no albums and no folder art.
1052        assert!(store.albums.is_empty());
1053        assert!(store.album_art("x").is_none());
1054        // The playlist collection is likewise additive: absent in an older
1055        // store, it defaults empty (HARDENING B2: no stored playlist means no
1056        // reconcile ever treats one as stale).
1057        assert!(store.playlists.is_empty());
1058        assert!(store.playlist("x").is_none());
1059    }
1060
1061    #[test]
1062    fn album_art_roundtrips_and_reads_by_kind() {
1063        let mut store = LineageStore::new();
1064        store.albums.insert(
1065            "root-1".to_owned(),
1066            AlbumArt {
1067                folder_jpg: Some(ArtifactState {
1068                    path: "alice/Album/folder.jpg".to_owned(),
1069                    hash: "jpg-h".to_owned(),
1070                }),
1071                folder_webp: Some(ArtifactState {
1072                    path: "alice/Album/cover.webp".to_owned(),
1073                    hash: "webp-h".to_owned(),
1074                }),
1075            },
1076        );
1077
1078        let json = serde_json::to_string(&store).unwrap();
1079        let back: LineageStore = serde_json::from_str(&json).unwrap();
1080        assert_eq!(store, back);
1081
1082        // The serialised shape is a relational `albums` map keyed by root id.
1083        let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1084        let album = value.get("albums").unwrap().get("root-1").unwrap();
1085        assert_eq!(
1086            album.get("folder_jpg").unwrap().get("hash").unwrap(),
1087            "jpg-h"
1088        );
1089
1090        let art = back.album_art("root-1").unwrap();
1091        assert_eq!(
1092            art.artifact(ArtifactKind::FolderJpg).unwrap().path,
1093            "alice/Album/folder.jpg"
1094        );
1095        assert_eq!(
1096            art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
1097            "webp-h"
1098        );
1099        // A per-clip kind has no album slot.
1100        assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
1101    }
1102
1103    #[test]
1104    fn empty_album_art_omits_slots_when_serialised() {
1105        // An all-`None` AlbumArt round-trips and writes an empty object, so the
1106        // absent-slot default holds both ways.
1107        let empty = AlbumArt::default();
1108        assert!(empty.is_empty());
1109        let value = serde_json::to_value(&empty).unwrap();
1110        assert!(value.get("folder_jpg").is_none());
1111        assert!(value.get("folder_webp").is_none());
1112        let back: AlbumArt = serde_json::from_str("{}").unwrap();
1113        assert_eq!(back, empty);
1114    }
1115
1116    #[test]
1117    fn set_album_artifact_upserts_then_prunes_when_emptied() {
1118        let mut store = LineageStore::new();
1119        let jpg = ArtifactState {
1120            path: "a/folder.jpg".to_owned(),
1121            hash: "h1".to_owned(),
1122        };
1123        store.set_album_artifact("root-1", ArtifactKind::FolderJpg, Some(jpg.clone()));
1124        assert_eq!(store.album_art("root-1").unwrap().folder_jpg, Some(jpg));
1125
1126        // Clearing the only slot prunes the whole album row (no dead entries).
1127        store.set_album_artifact("root-1", ArtifactKind::FolderJpg, None);
1128        assert!(store.album_art("root-1").is_none());
1129        assert!(store.albums.is_empty());
1130    }
1131
1132    #[test]
1133    fn playlist_state_roundtrips_by_id() {
1134        let mut store = LineageStore::new();
1135        store.playlists.insert(
1136            "pl1".to_owned(),
1137            PlaylistState {
1138                name: "Road Trip".to_owned(),
1139                path: "Road Trip.m3u8".to_owned(),
1140                hash: "abc123".to_owned(),
1141            },
1142        );
1143
1144        let json = serde_json::to_string(&store).unwrap();
1145        let back: LineageStore = serde_json::from_str(&json).unwrap();
1146        assert_eq!(store, back);
1147
1148        // The serialised shape is a relational `playlists` map keyed by id.
1149        let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1150        let pl = value.get("playlists").unwrap().get("pl1").unwrap();
1151        assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
1152        assert_eq!(pl.get("hash").unwrap(), "abc123");
1153
1154        let stored = back.playlist("pl1").unwrap();
1155        assert_eq!(stored.name, "Road Trip");
1156        assert_eq!(stored.hash, "abc123");
1157    }
1158
1159    #[test]
1160    fn set_playlist_upserts_then_clears() {
1161        let mut store = LineageStore::new();
1162        let state = PlaylistState {
1163            name: "Mix".to_owned(),
1164            path: "Mix.m3u8".to_owned(),
1165            hash: "h1".to_owned(),
1166        };
1167        store.set_playlist("pl1", Some(state.clone()));
1168        assert_eq!(store.playlist("pl1"), Some(&state));
1169
1170        // A rewrite replaces the row in place.
1171        let renamed = PlaylistState {
1172            name: "Mix v2".to_owned(),
1173            path: "Mix v2.m3u8".to_owned(),
1174            hash: "h2".to_owned(),
1175        };
1176        store.set_playlist("pl1", Some(renamed.clone()));
1177        assert_eq!(store.playlist("pl1"), Some(&renamed));
1178
1179        // Clearing removes the row so no dangling entry survives a delete.
1180        store.set_playlist("pl1", None);
1181        assert!(store.playlist("pl1").is_none());
1182        assert!(store.playlists.is_empty());
1183    }
1184
1185    #[test]
1186    fn context_for_roots_a_remix_at_its_stored_ancestor() {
1187        let mut store = LineageStore::new();
1188        store.update(&chain_clips(), &chain_resolution(), "now");
1189
1190        let child = &chain_clips()[0]; // "c", a cover of "b"
1191        let ctx = store.context_for(child);
1192        assert_eq!(ctx.root_id, "a");
1193        assert_eq!(ctx.root_title, "Root");
1194        assert_eq!(ctx.parent_id, "b");
1195        assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1196        assert_eq!(ctx.status, ResolveStatus::Resolved);
1197        // The remix folders under its resolved root's album.
1198        assert_eq!(ctx.album("Cover"), "Root");
1199    }
1200
1201    #[test]
1202    fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
1203        let mut store = LineageStore::new();
1204        store.update(&chain_clips(), &chain_resolution(), "now");
1205
1206        let root = &chain_clips()[2]; // "a"
1207        let ctx = store.context_for(root);
1208        assert_eq!(ctx.root_id, "a");
1209        assert_eq!(ctx.root_title, "Root");
1210        assert_eq!(ctx.parent_id, "");
1211        assert_eq!(ctx.edge_type, None);
1212        assert_eq!(ctx.album("Root"), "Root");
1213    }
1214
1215    #[test]
1216    fn context_for_an_unknown_clip_is_self_rooted() {
1217        let store = LineageStore::new();
1218        let orphan = Clip {
1219            id: "z".into(),
1220            title: "Lonely".into(),
1221            ..Default::default()
1222        };
1223        let ctx = store.context_for(&orphan);
1224        assert_eq!(ctx.root_id, "z");
1225        assert_eq!(ctx.root_title, "Lonely");
1226        assert_eq!(ctx.parent_id, "");
1227        assert_eq!(ctx.status, ResolveStatus::Resolved);
1228    }
1229
1230    #[test]
1231    fn context_for_retains_a_purged_ancestor_album() {
1232        // The trashed ancestor arrives only via gap_filled, yet a later run
1233        // whose resolver failed (modelled here by simply not re-updating) must
1234        // still root the child at the archived ancestor with its stored title
1235        // (HARDENING H3).
1236        let child = Clip {
1237            id: "c".into(),
1238            title: "Cover".into(),
1239            clip_type: "gen".into(),
1240            task: "cover".into(),
1241            cover_clip_id: "t".into(),
1242            edited_clip_id: "t".into(),
1243            ..Default::default()
1244        };
1245        let trashed = Clip {
1246            id: "t".into(),
1247            title: "Trashed Original".into(),
1248            clip_type: "gen".into(),
1249            is_trashed: true,
1250            ..Default::default()
1251        };
1252        let mut roots = HashMap::new();
1253        roots.insert(
1254            "c".to_owned(),
1255            RootInfo {
1256                root_id: "t".into(),
1257                root_title: "Trashed Original".into(),
1258                status: ResolveStatus::Resolved,
1259            },
1260        );
1261        let resolution = Resolution {
1262            roots,
1263            gap_filled: vec![trashed],
1264        };
1265        let mut store = LineageStore::new();
1266        store.update(std::slice::from_ref(&child), &resolution, "now");
1267
1268        let ctx = store.context_for(&child);
1269        assert_eq!(ctx.root_id, "t");
1270        assert_eq!(ctx.root_title, "Trashed Original");
1271        assert_eq!(ctx.album("Cover"), "Trashed Original");
1272    }
1273
1274    #[test]
1275    fn colliding_root_titles_flags_only_shared_distinct_roots() {
1276        // Two distinct roots share the title "Break Through"; a third root is
1277        // unique; a child of a shared root does not add a spurious distinct root.
1278        let clips = vec![
1279            Clip {
1280                id: "r1".into(),
1281                title: "Break Through".into(),
1282                clip_type: "gen".into(),
1283                ..Default::default()
1284            },
1285            Clip {
1286                id: "r2".into(),
1287                title: "Break Through".into(),
1288                clip_type: "gen".into(),
1289                ..Default::default()
1290            },
1291            Clip {
1292                id: "r3".into(),
1293                title: "Solo".into(),
1294                clip_type: "gen".into(),
1295                ..Default::default()
1296            },
1297            Clip {
1298                id: "c1".into(),
1299                title: "Break Through".into(),
1300                clip_type: "gen".into(),
1301                task: "cover".into(),
1302                cover_clip_id: "r1".into(),
1303                edited_clip_id: "r1".into(),
1304                ..Default::default()
1305            },
1306        ];
1307        let mut roots = HashMap::new();
1308        for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1309            let title = if root == "r3" {
1310                "Solo"
1311            } else {
1312                "Break Through"
1313            };
1314            roots.insert(
1315                id.to_owned(),
1316                RootInfo {
1317                    root_id: root.into(),
1318                    root_title: title.into(),
1319                    status: ResolveStatus::Resolved,
1320                },
1321            );
1322        }
1323        let resolution = Resolution {
1324            roots,
1325            gap_filled: Vec::new(),
1326        };
1327        let mut store = LineageStore::new();
1328        store.update(&clips, &resolution, "now");
1329
1330        let colliding = store.colliding_root_titles();
1331        assert!(colliding.contains("Break Through"));
1332        assert!(!colliding.contains("Solo"));
1333        assert_eq!(colliding.len(), 1);
1334    }
1335
1336    fn owner(id: &str, name: &str) -> Owner {
1337        Owner {
1338            user_id: id.to_owned(),
1339            display_name: name.to_owned(),
1340        }
1341    }
1342
1343    #[test]
1344    fn owner_check_covers_first_use_match_and_mismatch() {
1345        let mut store = LineageStore::new();
1346        assert_eq!(store.owner_check("user_a"), OwnerCheck::FirstUse);
1347
1348        store.pin_owner(owner("user_a", "Alice"));
1349        assert_eq!(store.owner_check("user_a"), OwnerCheck::Match);
1350        assert_eq!(store.owner_check("user_b"), OwnerCheck::Mismatch);
1351        assert_eq!(store.owner().unwrap().display_name, "Alice");
1352    }
1353
1354    #[test]
1355    fn refresh_display_name_only_when_changed_and_never_when_unpinned() {
1356        let mut store = LineageStore::new();
1357        // Unpinned: nothing to refresh.
1358        assert!(!store.refresh_display_name("Alice"));
1359        assert!(store.owner().is_none());
1360
1361        store.pin_owner(owner("user_a", "Alice"));
1362        // Same name is a no-op.
1363        assert!(!store.refresh_display_name("Alice"));
1364        // A changed name updates and reports the change.
1365        assert!(store.refresh_display_name("Alice Cooper"));
1366        assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
1367        // The user id is left untouched.
1368        assert_eq!(store.owner().unwrap().user_id, "user_a");
1369    }
1370
1371    #[test]
1372    fn owner_gate_covers_the_full_matrix() {
1373        let alice = owner("user_a", "Alice");
1374
1375        // Unpinned defers to first-use, regardless of the flag.
1376        assert_eq!(owner_gate(None, None, "user_a", false), OwnerGate::FirstUse);
1377        assert_eq!(owner_gate(None, None, "user_a", true), OwnerGate::FirstUse);
1378
1379        // A matching owner proceeds.
1380        assert_eq!(
1381            owner_gate(Some(&alice), None, "user_a", false),
1382            OwnerGate::Proceed
1383        );
1384
1385        // A differing owner aborts without the flag, re-pins with it.
1386        assert_eq!(
1387            owner_gate(Some(&alice), None, "user_b", false),
1388            OwnerGate::AbortMismatch
1389        );
1390        assert_eq!(
1391            owner_gate(Some(&alice), None, "user_b", true),
1392            OwnerGate::Repin
1393        );
1394
1395        // A configured id that differs ALWAYS aborts, even with the flag and
1396        // even on a first-use (unpinned) library.
1397        assert_eq!(
1398            owner_gate(Some(&alice), Some("user_c"), "user_a", true),
1399            OwnerGate::AbortConfigMismatch
1400        );
1401        assert_eq!(
1402            owner_gate(None, Some("user_c"), "user_a", true),
1403            OwnerGate::AbortConfigMismatch
1404        );
1405        // A configured id that matches does not interfere.
1406        assert_eq!(
1407            owner_gate(Some(&alice), Some("user_a"), "user_a", false),
1408            OwnerGate::Proceed
1409        );
1410
1411        // Only Repin is additive.
1412        assert!(OwnerGate::Repin.is_additive());
1413        for gate in [
1414            OwnerGate::AbortConfigMismatch,
1415            OwnerGate::AbortMismatch,
1416            OwnerGate::Proceed,
1417            OwnerGate::FirstUse,
1418        ] {
1419            assert!(!gate.is_additive());
1420        }
1421    }
1422
1423    #[test]
1424    fn adopt_decision_covers_every_branch() {
1425        let owned: BTreeSet<&str> = ["c1", "c2"].into_iter().collect();
1426        let empty: BTreeSet<&str> = BTreeSet::new();
1427
1428        // Empty library adopts outright regardless of the listing or the flag.
1429        assert_eq!(
1430            adopt_decision(&["x", "y"], &empty, true, false),
1431            AdoptDecision::PinFresh
1432        );
1433        // Non-empty but not enumerated: cannot confirm, so leave it unpinned.
1434        assert_eq!(
1435            adopt_decision(&["c1"], &owned, false, false),
1436            AdoptDecision::SkipPin
1437        );
1438        assert_eq!(
1439            adopt_decision(&["c1"], &owned, false, true),
1440            AdoptDecision::SkipPin
1441        );
1442        // Enumerated with overlap: same account, adopt in normal mode.
1443        assert_eq!(
1444            adopt_decision(&["c1", "z"], &owned, true, false),
1445            AdoptDecision::PinAdopt
1446        );
1447        // Enumerated with no overlap: refuse without the flag, force-adopt with.
1448        assert_eq!(
1449            adopt_decision(&["z1", "z2"], &owned, true, false),
1450            AdoptDecision::Abort
1451        );
1452        assert_eq!(
1453            adopt_decision(&["z1", "z2"], &owned, true, true),
1454            AdoptDecision::AdoptForced
1455        );
1456
1457        // Only the forced adoption is additive.
1458        assert!(AdoptDecision::AdoptForced.is_additive());
1459        for decision in [
1460            AdoptDecision::PinFresh,
1461            AdoptDecision::PinAdopt,
1462            AdoptDecision::Abort,
1463            AdoptDecision::SkipPin,
1464        ] {
1465            assert!(!decision.is_additive());
1466        }
1467    }
1468
1469    #[test]
1470    fn older_store_without_owner_loads_as_none_and_pinned_roundtrips() {
1471        // A store written before the owner field existed loads with owner None.
1472        let json = r#"{"nodes":{},"edges":[]}"#;
1473        let store: LineageStore = serde_json::from_str(json).unwrap();
1474        assert!(store.owner().is_none());
1475        // An unpinned store omits the field entirely (skip_serializing_if).
1476        let value = serde_json::to_value(&store).unwrap();
1477        assert!(value.get("owner").is_none());
1478
1479        // A pinned store round-trips and serialises the owner.
1480        let mut pinned = LineageStore::new();
1481        pinned.pin_owner(owner("user_a", "Alice"));
1482        let back: LineageStore =
1483            serde_json::from_str(&serde_json::to_string(&pinned).unwrap()).unwrap();
1484        assert_eq!(back, pinned);
1485        assert_eq!(back.owner().unwrap().user_id, "user_a");
1486    }
1487}