Skip to main content

suno_core/
lineage.rs

1//! Pure lineage resolution: classify a clip's parent edge and walk a library
2//! back to its root ancestor.
3//!
4//! Suno records how a clip was derived across a scatter of metadata fields
5//! (`task`, `type`, and a family of `*_clip_id` pointers plus `history` and
6//! `concat_history`). This module turns those into a single primary parent per
7//! clip ([`immediate_parent`]) classified by [`EdgeType`], the full set of
8//! parent [`Edge`]s for the later graph store ([`lineage_edges`]), and a
9//! root-ancestor map for a whole library ([`resolve_roots`]).
10//!
11//! Classification is deliberately blind to `is_remix`: that flag is a UI hint,
12//! not a structural fact, so it never changes an edge. All resolution stays
13//! pure; the only IO is the [`Http`] port reached through [`SunoClient`], used
14//! solely to gap-fill ancestors that are missing from the caller's listing.
15
16use std::collections::{HashMap, HashSet};
17
18use crate::client::SunoClient;
19use crate::clock::Clock;
20use crate::error::Result;
21use crate::http::Http;
22use crate::model::Clip;
23
24/// The all-zero UUID Suno uses as a "no clip" sentinel in pointer fields.
25const ZERO_UUID: &str = "00000000-0000-0000-0000-000000000000";
26
27/// How one clip relates to its immediate parent.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum EdgeType {
30    /// A cover: re-performed audio from a source clip.
31    Cover,
32    /// A remaster or upsample to higher fidelity.
33    Remaster,
34    /// A playback-speed edit.
35    SpeedEdit,
36    /// A studio edit export.
37    Edit,
38    /// An extension appended after a source clip.
39    Extend,
40    /// A section (infill) replaced within a source clip.
41    SectionReplace,
42    /// A stitch (concatenation) of two or more segments.
43    Stitch,
44    /// A derived clip with a parent pointer but no more specific marker.
45    Derived,
46    /// An external upload with no Suno parent.
47    Uploaded,
48}
49
50impl EdgeType {
51    /// A human label describing the relationship to the parent.
52    pub fn label(self) -> &'static str {
53        match self {
54            EdgeType::Cover => "Cover of",
55            EdgeType::Remaster => "Remaster of",
56            EdgeType::SpeedEdit => "Speed-edited from",
57            EdgeType::Edit => "Edited from",
58            EdgeType::Extend => "Extended from",
59            EdgeType::SectionReplace => "Section replaced from",
60            EdgeType::Stitch => "Stitched from",
61            EdgeType::Derived => "Derived from",
62            EdgeType::Uploaded => "Uploaded",
63        }
64    }
65}
66
67/// Whether an [`Edge`] is the clip's primary parent or a supporting one.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum EdgeRole {
70    /// The single lineage parent used for root resolution and album grouping.
71    Primary,
72    /// An additional source (an extra stitch segment, an infill's future half).
73    Secondary,
74}
75
76/// One parent link of a clip, for the later lineage graph store.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Edge {
79    /// The parent clip id, normalised (`m_` stripped, sentinel dropped).
80    pub parent_id: String,
81    /// How the clip relates to this parent.
82    pub edge_type: EdgeType,
83    /// Whether this is the primary parent or a secondary source.
84    pub role: EdgeRole,
85    /// Position within its role (0 for the primary, then secondaries in order).
86    pub ordinal: u32,
87    /// The metadata field this parent id was read from.
88    pub source_field: &'static str,
89}
90
91/// Tunables bounding how hard [`resolve_roots`] works per call.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct ResolveOpts {
94    /// Maximum number of missing ancestor ids to fetch from the network.
95    pub max_gap_fills: u32,
96    /// Maximum hops to walk up a single chain before giving up.
97    pub hop_cap: u32,
98}
99
100impl Default for ResolveOpts {
101    fn default() -> Self {
102        Self {
103            max_gap_fills: 200,
104            hop_cap: 64,
105        }
106    }
107}
108
109/// The outcome of resolving a clip's root ancestor.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum ResolveStatus {
112    /// The root was reached: a clip present in the index with no parent.
113    Resolved,
114    /// Resolution stopped at an ancestor outside the index (gap-fill budget
115    /// exhausted, or the API reported it has no parent of its own).
116    External,
117    /// The root could not be determined within the hop cap.
118    Unresolved,
119    /// A cycle was detected while walking (pathological data).
120    Cycle,
121}
122
123/// The resolved root ancestor of a clip.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct RootInfo {
126    /// The root (or boundary) ancestor id.
127    pub root_id: String,
128    /// The root clip's title, if it is present in the index (else empty).
129    pub root_title: String,
130    /// How resolution terminated.
131    pub status: ResolveStatus,
132}
133
134/// The outcome of [`resolve_roots`]: a root for every input clip, plus the
135/// ancestor clips fetched to bridge gaps.
136///
137/// `gap_filled` is kept structurally separate from `roots` on purpose. Those
138/// ancestors (often trashed) exist only so lineage could be walked; a later
139/// phase persists them to the graph store so a trashed ancestor is archived
140/// before Suno's purge, but they must never be treated as download candidates.
141#[derive(Debug, Clone, PartialEq)]
142pub struct Resolution {
143    /// The resolved root for every clip passed to [`resolve_roots`], keyed by
144    /// clip id.
145    pub roots: HashMap<String, RootInfo>,
146    /// Ancestor clips fetched during gap-fill, sorted by id. Not download
147    /// candidates: they were pulled solely to complete the lineage walk.
148    pub gap_filled: Vec<Clip>,
149}
150
151/// The resolved lineage of a single clip, threaded into naming, tagging, and
152/// change detection.
153///
154/// This is the bridge between the pure resolver ([`Resolution`]) and the parts
155/// of the engine that turn a clip into files: it carries exactly the resolved
156/// values that get embedded in a path or a tag (the root the clip folders
157/// under, the immediate parent and how it derives from it), so those consumers
158/// never re-read the now-defunct `root_ancestor_id`/`album_title` feed fields.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct LineageContext {
161    /// The resolved root ancestor id (the clip's own id when it is a root).
162    pub root_id: String,
163    /// The root ancestor's title (empty when the root is outside the index).
164    pub root_title: String,
165    /// The immediate parent id ([`immediate_parent`]); empty for a root.
166    pub parent_id: String,
167    /// How the clip derives from its parent; `None` for a root.
168    pub edge_type: Option<EdgeType>,
169    /// How root resolution terminated.
170    pub status: ResolveStatus,
171}
172
173impl LineageContext {
174    /// Build the context for `clip` from a whole-library [`Resolution`].
175    ///
176    /// Root id/title/status come from `resolution.roots[clip.id]`; when the clip
177    /// is absent (it was not part of the resolved set) it is treated as its own
178    /// resolved root. The parent id and edge come from [`immediate_parent`],
179    /// which is empty/`None` for a root.
180    pub fn for_clip(clip: &Clip, resolution: &Resolution) -> LineageContext {
181        let (root_id, root_title, status) = match resolution.roots.get(&clip.id) {
182            Some(info) => (info.root_id.clone(), info.root_title.clone(), info.status),
183            None => (clip.id.clone(), clip.title.clone(), ResolveStatus::Resolved),
184        };
185        let (parent_id, edge_type) = match immediate_parent(clip) {
186            Some((id, edge)) => (id, Some(edge)),
187            None => (String::new(), None),
188        };
189        LineageContext {
190            root_id,
191            root_title,
192            parent_id,
193            edge_type,
194            status,
195        }
196    }
197
198    /// A self-rooted context for `clip`: it is treated as its own resolved root
199    /// with no parent. Used as a defensive fallback where a resolved context is
200    /// unavailable (a clip absent from the current desired set).
201    pub fn own_root(clip: &Clip) -> LineageContext {
202        LineageContext {
203            root_id: clip.id.clone(),
204            root_title: clip.title.clone(),
205            parent_id: String::new(),
206            edge_type: None,
207            status: ResolveStatus::Resolved,
208        }
209    }
210
211    /// The album the clip folders under: the root ancestor's title when it is a
212    /// real, different root, otherwise `own_title`.
213    ///
214    /// A root (or an unresolved clip whose root title is empty, or a clip whose
215    /// root shares its title) folders under its own title; only a resolved,
216    /// differently-titled ancestor pulls the clip into the ancestor's album.
217    pub fn album(&self, own_title: &str) -> String {
218        let root_title = self.root_title.trim();
219        if !root_title.is_empty() && self.root_title != own_title {
220            self.root_title.clone()
221        } else {
222            own_title.to_owned()
223        }
224    }
225}
226
227/// Classify a clip's relationship to its parent, purely from its structure.
228///
229/// Inspects only `task`, `type`, and the pointer fields; never `is_remix`.
230/// Returns `None` for a clip with no parent (a root, original, or upload). The
231/// first matching rule wins, so more specific operations take precedence over
232/// the generic `Derived` fallback.
233///
234/// A stitch is keyed on `type == "concat"` alone, never on a non-empty
235/// `concat_history`: Suno copies a parent's `concat_history` verbatim onto
236/// clips derived from a stitched track, so a cover or remaster *of* a stitch
237/// still carries it. Keying on the type keeps those classified by their own
238/// operation (and parented through their own pointer) instead of the stitch.
239pub fn edge_type(clip: &Clip) -> Option<EdgeType> {
240    let task = clip.task.as_str();
241    let clip_type = clip.clip_type.as_str();
242
243    if task == "infill" || task == "fixed_infill" {
244        Some(EdgeType::SectionReplace)
245    } else if task == "extend" {
246        Some(EdgeType::Extend)
247    } else if clip_type == "concat" {
248        Some(EdgeType::Stitch)
249    } else if clip_type == "edit_speed" {
250        Some(EdgeType::SpeedEdit)
251    } else if task == "cover" {
252        Some(EdgeType::Cover)
253    } else if clip_type == "upsample" || task == "upsample" {
254        Some(EdgeType::Remaster)
255    } else if clip_type == "edit_v3_export" {
256        Some(EdgeType::Edit)
257    } else if normalise_id(&clip.edited_clip_id).is_some() {
258        Some(EdgeType::Derived)
259    } else {
260        None
261    }
262}
263
264/// The clip's primary parent id and the edge that links them.
265///
266/// Applies the same precedence as [`edge_type`], then reads the parent pointer
267/// appropriate to that operation, falling through per-op candidates in order.
268/// Every id is normalised (a leading `m_` stripped, an empty or all-zero
269/// sentinel treated as absent). Returns `None` for a root or when no usable
270/// parent id is present.
271pub fn immediate_parent(clip: &Clip) -> Option<(String, EdgeType)> {
272    primary_parent(clip).map(|(id, edge, _field)| (id, edge))
273}
274
275/// Every parent link of a clip: the primary parent plus any secondaries.
276///
277/// The primary edge (from [`immediate_parent`]) is `Primary` with ordinal 0,
278/// when a primary parent id is present. A stitch also records
279/// `concat_history[1..]` as `Secondary` sources, and a section replace records
280/// its `override_future_clip_id` (when distinct) as a `Secondary`. When the
281/// primary pointer is absent but secondaries remain (for example a stitch whose
282/// base segment id is empty), the secondaries are still emitted with their own
283/// ordinals. All ids are normalised. A clip with no parent operation yields an
284/// empty vector.
285pub fn lineage_edges(clip: &Clip) -> Vec<Edge> {
286    let Some(edge_type) = edge_type(clip) else {
287        return Vec::new();
288    };
289
290    let mut edges = Vec::new();
291    if let Some((parent_id, _edge, source_field)) = primary_parent(clip) {
292        edges.push(Edge {
293            parent_id,
294            edge_type,
295            role: EdgeRole::Primary,
296            ordinal: 0,
297            source_field,
298        });
299    }
300
301    match edge_type {
302        EdgeType::Stitch => {
303            for (ordinal, entry) in clip.concat_history.iter().enumerate().skip(1) {
304                if let Some(id) = normalise_id(&entry.id) {
305                    edges.push(Edge {
306                        parent_id: id,
307                        edge_type,
308                        role: EdgeRole::Secondary,
309                        ordinal: ordinal as u32,
310                        source_field: "concat_history",
311                    });
312                }
313            }
314        }
315        EdgeType::SectionReplace => {
316            if let Some(future) = normalise_id(&clip.override_future_clip_id)
317                && edges
318                    .first()
319                    .is_none_or(|primary| primary.parent_id != future)
320            {
321                edges.push(Edge {
322                    parent_id: future,
323                    edge_type,
324                    role: EdgeRole::Secondary,
325                    ordinal: 1,
326                    source_field: "override_future_clip_id",
327                });
328            }
329        }
330        _ => {}
331    }
332
333    edges
334}
335
336/// Resolve the root ancestor of every clip in `clips`.
337///
338/// Walks each clip up its [`immediate_parent`] chain to a root. Chains that
339/// stay within `clips` resolve with no network access. When a parent is absent
340/// from the index it is gap-filled: missing ids are fetched in a batch through
341/// [`SunoClient::get_clips_by_ids`], and any id that cannot be retrieved that
342/// way falls back to [`SunoClient::get_clip_parent`], which yields one ancestor
343/// hop to keep walking (never assumed to be the absolute root).
344///
345/// Gap-filled clips (which may be trashed) are held in an index that is kept
346/// structurally separate from the caller's `clips`; they exist only to resolve
347/// ancestry and must never be treated as download candidates by later phases.
348///
349/// Bounded by [`ResolveOpts`]: at most `max_gap_fills` ancestor ids are fetched
350/// (exhaustion yields [`ResolveStatus::External`] at the last reachable
351/// ancestor), and each chain walks at most `hop_cap` hops. A cycle yields
352/// [`ResolveStatus::Cycle`]. The returned [`Resolution`] has a root entry for
353/// every input clip, plus the gap-filled ancestor clips it fetched.
354pub async fn resolve_roots(
355    clips: &[Clip],
356    client: &mut SunoClient<impl Clock>,
357    http: &impl Http,
358    opts: ResolveOpts,
359) -> Result<Resolution> {
360    let mut resolver = Resolver::new(clips, opts);
361    resolver.run(client, http).await?;
362    Ok(resolver.into_resolution(clips))
363}
364
365/// The clip's primary parent id, edge type, and the source field it came from.
366///
367/// Shared by [`immediate_parent`] and [`lineage_edges`] so the two never drift.
368fn primary_parent(clip: &Clip) -> Option<(String, EdgeType, &'static str)> {
369    let edge = edge_type(clip)?;
370    let history_head = clip.history.first().map_or("", |entry| entry.id.as_str());
371    let concat_head = clip
372        .concat_history
373        .first()
374        .map_or("", |entry| entry.id.as_str());
375
376    let candidates: Vec<(&str, &'static str)> = match edge {
377        EdgeType::SectionReplace => vec![
378            (
379                clip.override_history_clip_id.as_str(),
380                "override_history_clip_id",
381            ),
382            (
383                clip.override_future_clip_id.as_str(),
384                "override_future_clip_id",
385            ),
386            (history_head, "history"),
387            (clip.edited_clip_id.as_str(), "edited_clip_id"),
388        ],
389        EdgeType::Extend => vec![
390            (history_head, "history"),
391            (clip.edited_clip_id.as_str(), "edited_clip_id"),
392        ],
393        EdgeType::Stitch => vec![
394            (concat_head, "concat_history"),
395            (clip.edited_clip_id.as_str(), "edited_clip_id"),
396        ],
397        EdgeType::SpeedEdit => vec![
398            (clip.speed_clip_id.as_str(), "speed_clip_id"),
399            (clip.edited_clip_id.as_str(), "edited_clip_id"),
400        ],
401        EdgeType::Cover => vec![
402            (clip.cover_clip_id.as_str(), "cover_clip_id"),
403            (clip.edited_clip_id.as_str(), "edited_clip_id"),
404        ],
405        EdgeType::Remaster => vec![
406            (clip.upsample_clip_id.as_str(), "upsample_clip_id"),
407            (clip.remaster_clip_id.as_str(), "remaster_clip_id"),
408            (clip.edited_clip_id.as_str(), "edited_clip_id"),
409        ],
410        EdgeType::Edit | EdgeType::Derived => {
411            vec![(clip.edited_clip_id.as_str(), "edited_clip_id")]
412        }
413        EdgeType::Uploaded => vec![],
414    };
415
416    candidates
417        .into_iter()
418        .find_map(|(value, field)| normalise_id(value).map(|id| (id, edge, field)))
419}
420
421/// Normalise a raw pointer id: strip a leading `m_`, and treat an empty or
422/// all-zero sentinel value as absent.
423fn normalise_id(id: &str) -> Option<String> {
424    let id = id.strip_prefix("m_").unwrap_or(id);
425    if id.is_empty() || id == ZERO_UUID {
426        None
427    } else {
428        Some(id.to_string())
429    }
430}
431
432/// The result of walking one chain as far as the current index allows.
433enum Walk {
434    /// The start clip's root is now recorded in the memo.
435    Resolved,
436    /// The walk stalled needing this ancestor id gap-filled.
437    Blocked(String),
438}
439
440/// Working state for one [`resolve_roots`] call.
441///
442/// `index` holds the input clips plus any gap-filled ancestors so the walk can
443/// read their pointers; `gap_filled` records which ids were fetched here so
444/// later phases can tell ancestors apart from download candidates. `bridges`
445/// maps a missing id to the known parent that the parent endpoint returned in
446/// its place, and `external` records ids the API reported as parentless roots.
447struct Resolver {
448    index: HashMap<String, Clip>,
449    gap_filled: HashSet<String>,
450    bridges: HashMap<String, String>,
451    external: HashSet<String>,
452    memo: HashMap<String, RootInfo>,
453    targets: Vec<String>,
454    budget: u32,
455    hop_cap: u32,
456}
457
458impl Resolver {
459    fn new(clips: &[Clip], opts: ResolveOpts) -> Self {
460        let index = clips
461            .iter()
462            .map(|clip| (clip.id.clone(), clip.clone()))
463            .collect();
464        let targets = clips.iter().map(|clip| clip.id.clone()).collect();
465        Self {
466            index,
467            gap_filled: HashSet::new(),
468            bridges: HashMap::new(),
469            external: HashSet::new(),
470            memo: HashMap::new(),
471            targets,
472            budget: opts.max_gap_fills,
473            hop_cap: opts.hop_cap,
474        }
475    }
476
477    /// Resolve every target, gap-filling missing ancestors until the whole set
478    /// is settled or the budget runs out.
479    async fn run(&mut self, client: &mut SunoClient<impl Clock>, http: &impl Http) -> Result<()> {
480        let targets = self.targets.clone();
481        loop {
482            let mut frontier: Vec<String> = Vec::new();
483            let mut seen: HashSet<String> = HashSet::new();
484            let mut blocked: Vec<(String, String)> = Vec::new();
485
486            for target in &targets {
487                if self.memo.contains_key(target) {
488                    continue;
489                }
490                if let Walk::Blocked(missing) = self.walk(target) {
491                    if seen.insert(missing.clone()) {
492                        frontier.push(missing.clone());
493                    }
494                    blocked.push((target.clone(), missing));
495                }
496            }
497
498            if blocked.is_empty() {
499                break;
500            }
501            if self.budget == 0 || !self.gap_fill(client, http, &frontier).await? {
502                self.finalise_external(&blocked);
503                break;
504            }
505        }
506        Ok(())
507    }
508
509    /// Walk `start` up its parent chain within the current index, memoising the
510    /// root for every node reached. Returns [`Walk::Blocked`] with the first
511    /// ancestor id that is missing and needs gap-filling.
512    fn walk(&mut self, start: &str) -> Walk {
513        if self.memo.contains_key(start) {
514            return Walk::Resolved;
515        }
516        let mut chain: Vec<String> = Vec::new();
517        let mut visited: HashSet<String> = HashSet::new();
518        let mut current = start.to_string();
519        let mut hops = 0u32;
520
521        loop {
522            if let Some(info) = self.memo.get(&current).cloned() {
523                self.assign(&chain, &info);
524                return Walk::Resolved;
525            }
526            if visited.contains(&current) {
527                let info = self.terminal(&current, ResolveStatus::Cycle);
528                self.assign(&chain, &info);
529                self.memo.insert(current, info);
530                return Walk::Resolved;
531            }
532            if hops >= self.hop_cap {
533                let info = self.terminal(&current, ResolveStatus::Unresolved);
534                self.assign(&chain, &info);
535                self.memo.insert(current, info);
536                return Walk::Resolved;
537            }
538
539            let (parent, title) = match self.index.get(&current) {
540                Some(clip) => (immediate_parent(clip), clip.title.clone()),
541                None => return Walk::Blocked(current),
542            };
543
544            let Some((parent_id, _edge)) = parent else {
545                let info = RootInfo {
546                    root_id: current.clone(),
547                    root_title: title,
548                    status: ResolveStatus::Resolved,
549                };
550                self.assign(&chain, &info);
551                self.memo.insert(current, info);
552                return Walk::Resolved;
553            };
554
555            visited.insert(current.clone());
556            chain.push(current);
557
558            if self.index.contains_key(&parent_id) {
559                current = parent_id;
560            } else if let Some(bridged) = self.bridges.get(&parent_id).cloned() {
561                visited.insert(parent_id);
562                current = bridged;
563            } else if self.external.contains(&parent_id) {
564                let info = self.terminal(&parent_id, ResolveStatus::External);
565                self.assign(&chain, &info);
566                self.memo.insert(parent_id, info);
567                return Walk::Resolved;
568            } else {
569                return Walk::Blocked(parent_id);
570            }
571            hops += 1;
572        }
573    }
574
575    /// Fetch missing `frontier` ancestors, batching by id and falling back to
576    /// the parent endpoint. Returns whether the index (or bridges/externals)
577    /// grew, so the caller can detect a stalled resolution.
578    async fn gap_fill(
579        &mut self,
580        client: &mut SunoClient<impl Clock>,
581        http: &impl Http,
582        frontier: &[String],
583    ) -> Result<bool> {
584        let mut want: Vec<String> = frontier
585            .iter()
586            .filter(|id| !self.known(id))
587            .cloned()
588            .collect();
589        if want.is_empty() {
590            return Ok(false);
591        }
592        want.sort();
593        let take = (self.budget as usize).min(want.len());
594        let batch: Vec<String> = want.into_iter().take(take).collect();
595        self.budget -= batch.len() as u32;
596
597        let refs: Vec<&str> = batch.iter().map(String::as_str).collect();
598        let fetched = client.get_clips_by_ids(http, &refs).await?;
599
600        let mut returned: HashSet<String> = HashSet::new();
601        let mut progressed = false;
602        for clip in fetched {
603            returned.insert(clip.id.clone());
604            if self.insert_ancestor(clip) {
605                progressed = true;
606            }
607        }
608
609        for id in &batch {
610            if returned.contains(id) {
611                continue;
612            }
613            match client.get_clip_parent(http, id).await? {
614                Some(parent) => {
615                    let parent_id = parent.id.clone();
616                    self.insert_ancestor(parent);
617                    self.bridges.insert(id.clone(), parent_id);
618                    progressed = true;
619                }
620                None => {
621                    self.external.insert(id.clone());
622                    progressed = true;
623                }
624            }
625        }
626
627        Ok(progressed)
628    }
629
630    /// Add a gap-filled ancestor to the index, tracking it as an ancestor-only
631    /// clip. Returns whether it was newly added.
632    fn insert_ancestor(&mut self, clip: Clip) -> bool {
633        if clip.id.is_empty() || self.index.contains_key(&clip.id) {
634            return false;
635        }
636        self.gap_filled.insert(clip.id.clone());
637        self.index.insert(clip.id.clone(), clip);
638        true
639    }
640
641    /// Whether an id is already resolvable without another fetch.
642    fn known(&self, id: &str) -> bool {
643        self.index.contains_key(id) || self.bridges.contains_key(id) || self.external.contains(id)
644    }
645
646    /// Mark every still-unresolved blocked target as external at the ancestor it
647    /// stalled on.
648    fn finalise_external(&mut self, blocked: &[(String, String)]) {
649        for (target, missing) in blocked {
650            if self.memo.contains_key(target) {
651                continue;
652            }
653            let info = self.terminal(missing, ResolveStatus::External);
654            self.memo.insert(target.clone(), info);
655        }
656    }
657
658    /// Build a [`RootInfo`] rooted at `id`, titled from the index when present.
659    fn terminal(&self, id: &str, status: ResolveStatus) -> RootInfo {
660        RootInfo {
661            root_id: id.to_string(),
662            root_title: self.title_of(id),
663            status,
664        }
665    }
666
667    /// The title of an indexed clip, or empty when it is not in the index.
668    fn title_of(&self, id: &str) -> String {
669        self.index
670            .get(id)
671            .map_or_else(String::new, |clip| clip.title.clone())
672    }
673
674    /// Record `info` as the root for every node on `chain`.
675    fn assign(&mut self, chain: &[String], info: &RootInfo) {
676        for id in chain {
677            self.memo.insert(id.clone(), info.clone());
678        }
679    }
680
681    /// Project the memo onto the input clips (so every one has a root entry) and
682    /// collect the gap-filled ancestors, sorted by id for a deterministic order.
683    fn into_resolution(self, clips: &[Clip]) -> Resolution {
684        let mut roots = HashMap::with_capacity(clips.len());
685        for clip in clips {
686            let info = self
687                .memo
688                .get(&clip.id)
689                .cloned()
690                .unwrap_or_else(|| RootInfo {
691                    root_id: clip.id.clone(),
692                    root_title: clip.title.clone(),
693                    status: ResolveStatus::Unresolved,
694                });
695            roots.insert(clip.id.clone(), info);
696        }
697
698        let mut gap_filled: Vec<Clip> = self
699            .gap_filled
700            .iter()
701            .filter_map(|id| self.index.get(id).cloned())
702            .collect();
703        gap_filled.sort_by(|a, b| a.id.cmp(&b.id));
704
705        Resolution { roots, gap_filled }
706    }
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712    use crate::auth::ClerkAuth;
713    use crate::model::HistoryEntry;
714    use crate::testutil::{RecordingClock, Reply, ScriptedHttp};
715
716    fn history(id: &str) -> HistoryEntry {
717        HistoryEntry {
718            id: id.to_owned(),
719            ..Default::default()
720        }
721    }
722
723    // A clean six-clip chain modelled on the real `chain1` grounding data:
724    // upsample -> cover -> upsample -> cover -> edit -> root. For every hop the
725    // op pointer and `edited_clip_id` agree, as they do in the live shape.
726    fn chain1_clips() -> Vec<Clip> {
727        vec![
728            Clip {
729                id: "40068b49".into(),
730                title: "Zac and the Sea Eagles (Lullaby Version)".into(),
731                clip_type: "upsample".into(),
732                task: "upsample".into(),
733                is_remix: true,
734                upsample_clip_id: "52962dae".into(),
735                edited_clip_id: "52962dae".into(),
736                ..Default::default()
737            },
738            Clip {
739                id: "52962dae".into(),
740                title: "Zac and the Sea Eagles (Edit) (Remastered)".into(),
741                clip_type: "gen".into(),
742                task: "cover".into(),
743                is_remix: true,
744                cover_clip_id: "536e1b92".into(),
745                edited_clip_id: "536e1b92".into(),
746                ..Default::default()
747            },
748            Clip {
749                id: "536e1b92".into(),
750                title: "Zac and the Sea Eagles (Edit) (Remastered)".into(),
751                clip_type: "upsample".into(),
752                task: "upsample".into(),
753                is_remix: true,
754                upsample_clip_id: "b9f27ee1".into(),
755                edited_clip_id: "b9f27ee1".into(),
756                ..Default::default()
757            },
758            Clip {
759                id: "b9f27ee1".into(),
760                title: "Zac and the Sea Eagles (Edit)".into(),
761                clip_type: "gen".into(),
762                task: "cover".into(),
763                is_remix: true,
764                cover_clip_id: "c1997d52".into(),
765                edited_clip_id: "c1997d52".into(),
766                ..Default::default()
767            },
768            Clip {
769                id: "c1997d52".into(),
770                title: "Zac and the Sea Eagles (Rework)".into(),
771                clip_type: "edit_v3_export".into(),
772                edited_clip_id: "dfb59a04".into(),
773                ..Default::default()
774            },
775            Clip {
776                id: "dfb59a04".into(),
777                title: "Zac and the Sea Eagles".into(),
778                clip_type: "gen".into(),
779                ..Default::default()
780            },
781        ]
782    }
783
784    fn authed_client(http: &ScriptedHttp) -> SunoClient<RecordingClock> {
785        let mut auth = ClerkAuth::new("eyJtoken");
786        pollster::block_on(auth.authenticate(http)).unwrap();
787        SunoClient::new(auth, RecordingClock::new())
788    }
789
790    #[test]
791    fn edge_type_labels_read_naturally() {
792        assert_eq!(EdgeType::Cover.label(), "Cover of");
793        assert_eq!(EdgeType::Remaster.label(), "Remaster of");
794        assert_eq!(EdgeType::SpeedEdit.label(), "Speed-edited from");
795        assert_eq!(EdgeType::Edit.label(), "Edited from");
796        assert_eq!(EdgeType::Extend.label(), "Extended from");
797        assert_eq!(EdgeType::SectionReplace.label(), "Section replaced from");
798        assert_eq!(EdgeType::Stitch.label(), "Stitched from");
799        assert_eq!(EdgeType::Derived.label(), "Derived from");
800        assert_eq!(EdgeType::Uploaded.label(), "Uploaded");
801    }
802
803    #[test]
804    fn classifies_remaster_cover_edit_and_root_across_chain1() {
805        let clips = chain1_clips();
806
807        assert_eq!(edge_type(&clips[0]), Some(EdgeType::Remaster));
808        assert_eq!(
809            immediate_parent(&clips[0]),
810            Some(("52962dae".into(), EdgeType::Remaster))
811        );
812
813        assert_eq!(edge_type(&clips[1]), Some(EdgeType::Cover));
814        assert_eq!(
815            immediate_parent(&clips[1]),
816            Some(("536e1b92".into(), EdgeType::Cover))
817        );
818
819        assert_eq!(edge_type(&clips[4]), Some(EdgeType::Edit));
820        assert_eq!(
821            immediate_parent(&clips[4]),
822            Some(("dfb59a04".into(), EdgeType::Edit))
823        );
824
825        assert_eq!(edge_type(&clips[5]), None);
826        assert_eq!(immediate_parent(&clips[5]), None);
827    }
828
829    #[test]
830    fn classifies_speed_edit_from_speed_pointer_without_edited() {
831        // Real `chain2` shape: edit_speed carries speed_clip_id and no edited_clip_id.
832        let clip = Clip {
833            id: "6e5193b1".into(),
834            title: "Go Xavi Go, Fast. (Drum n' Bass Version)".into(),
835            clip_type: "edit_speed".into(),
836            is_remix: true,
837            speed_clip_id: "2b69882c".into(),
838            ..Default::default()
839        };
840        assert_eq!(edge_type(&clip), Some(EdgeType::SpeedEdit));
841        assert_eq!(
842            immediate_parent(&clip),
843            Some(("2b69882c".into(), EdgeType::SpeedEdit))
844        );
845    }
846
847    #[test]
848    fn empty_task_gen_is_a_root() {
849        // Real `chain2` root: gen with an empty task string.
850        let clip = Clip {
851            id: "b4f16694".into(),
852            title: "Go Xavi Go, Fast.".into(),
853            clip_type: "gen".into(),
854            task: String::new(),
855            ..Default::default()
856        };
857        assert_eq!(edge_type(&clip), None);
858        assert_eq!(immediate_parent(&clip), None);
859    }
860
861    #[test]
862    fn classifies_extend_from_history_head() {
863        let clip = Clip {
864            id: "9a3dcb67".into(),
865            title: "Extended".into(),
866            clip_type: "gen".into(),
867            task: "extend".into(),
868            edited_clip_id: "0a3c311a".into(),
869            history: vec![HistoryEntry {
870                id: "0a3c311a".into(),
871                continue_at: Some(115.35),
872                ..Default::default()
873            }],
874            ..Default::default()
875        };
876        assert_eq!(edge_type(&clip), Some(EdgeType::Extend));
877        assert_eq!(
878            immediate_parent(&clip),
879            Some(("0a3c311a".into(), EdgeType::Extend))
880        );
881    }
882
883    #[test]
884    fn classifies_infill_with_override_history_precedence() {
885        // Real infill shape: override_history wins over future, history, and edited.
886        let clip = Clip {
887            id: "c0ce5c48".into(),
888            title: "Section replaced".into(),
889            clip_type: "gen".into(),
890            task: "infill".into(),
891            edited_clip_id: "cf37e05f".into(),
892            override_history_clip_id: "d3d28e59".into(),
893            override_future_clip_id: "ea88571e".into(),
894            history: vec![HistoryEntry {
895                id: "cf37e05f".into(),
896                infill: true,
897                infill_start_s: Some(20.4),
898                infill_end_s: Some(24.92),
899                ..Default::default()
900            }],
901            ..Default::default()
902        };
903        assert_eq!(edge_type(&clip), Some(EdgeType::SectionReplace));
904        assert_eq!(
905            immediate_parent(&clip),
906            Some(("d3d28e59".into(), EdgeType::SectionReplace))
907        );
908    }
909
910    #[test]
911    fn fixed_infill_is_also_section_replace() {
912        let clip = Clip {
913            task: "fixed_infill".into(),
914            override_history_clip_id: "past".into(),
915            edited_clip_id: "edited".into(),
916            ..Default::default()
917        };
918        assert_eq!(edge_type(&clip), Some(EdgeType::SectionReplace));
919        assert_eq!(
920            immediate_parent(&clip),
921            Some(("past".into(), EdgeType::SectionReplace))
922        );
923    }
924
925    #[test]
926    fn classifies_stitch_from_concat_base() {
927        // Real concat shape: type=concat, base segment first in concat_history.
928        let clip = Clip {
929            id: "43ba1ce3".into(),
930            title: "Stitched".into(),
931            clip_type: "concat".into(),
932            concat_history: vec![
933                HistoryEntry {
934                    id: "ead64fbe".into(),
935                    continue_at: Some(149.19),
936                    ..Default::default()
937                },
938                history("da47b824"),
939            ],
940            ..Default::default()
941        };
942        assert_eq!(edge_type(&clip), Some(EdgeType::Stitch));
943        assert_eq!(
944            immediate_parent(&clip),
945            Some(("ead64fbe".into(), EdgeType::Stitch))
946        );
947    }
948
949    #[test]
950    fn inherited_concat_history_without_concat_type_is_not_a_stitch() {
951        // Suno copies a parent stitch's concat_history onto derived clips. A
952        // plain `gen` that merely carries it (no type=concat, no other marker)
953        // must NOT be read as a stitch; here it has no parent pointer, so it is
954        // a root.
955        let clip = Clip {
956            clip_type: "gen".into(),
957            concat_history: vec![history("base"), history("second")],
958            ..Default::default()
959        };
960        assert_eq!(edge_type(&clip), None);
961        assert_eq!(immediate_parent(&clip), None);
962    }
963
964    #[test]
965    fn cover_of_a_stitch_classifies_as_cover_not_stitch() {
966        // A cover OF a stitched track inherits the parent's concat_history but is
967        // itself a cover: it must classify as Cover and parent via cover_clip_id,
968        // never as a Stitch pointing at an inherited concat segment.
969        let clip = Clip {
970            id: "cov".into(),
971            title: "Cover of a stitch".into(),
972            clip_type: "gen".into(),
973            task: "cover".into(),
974            cover_clip_id: "stitch-parent".into(),
975            edited_clip_id: "stitch-parent".into(),
976            concat_history: vec![history("inherited-base"), history("inherited-seg")],
977            ..Default::default()
978        };
979        assert_eq!(edge_type(&clip), Some(EdgeType::Cover));
980        assert_eq!(
981            immediate_parent(&clip),
982            Some(("stitch-parent".into(), EdgeType::Cover))
983        );
984    }
985
986    #[test]
987    fn upload_is_a_root() {
988        let clip = Clip {
989            id: "4770ef56".into(),
990            title: "Uploaded audio".into(),
991            clip_type: "upload".into(),
992            ..Default::default()
993        };
994        assert_eq!(edge_type(&clip), None);
995        assert_eq!(immediate_parent(&clip), None);
996    }
997
998    #[test]
999    fn edited_only_clip_is_derived() {
1000        // A task the resolver has no specific rule for, but a parent pointer.
1001        let clip = Clip {
1002            clip_type: "gen".into(),
1003            task: "chop_sample_condition".into(),
1004            edited_clip_id: "parent-x".into(),
1005            ..Default::default()
1006        };
1007        assert_eq!(edge_type(&clip), Some(EdgeType::Derived));
1008        assert_eq!(
1009            immediate_parent(&clip),
1010            Some(("parent-x".into(), EdgeType::Derived))
1011        );
1012    }
1013
1014    #[test]
1015    fn unmarked_clip_without_pointer_is_a_root() {
1016        let clip = Clip {
1017            clip_type: "gen".into(),
1018            task: "chop_sample_condition".into(),
1019            ..Default::default()
1020        };
1021        assert_eq!(edge_type(&clip), None);
1022        assert_eq!(immediate_parent(&clip), None);
1023    }
1024
1025    #[test]
1026    fn is_remix_does_not_change_classification() {
1027        let base = Clip {
1028            clip_type: "gen".into(),
1029            task: "cover".into(),
1030            cover_clip_id: "root-1".into(),
1031            edited_clip_id: "root-1".into(),
1032            ..Default::default()
1033        };
1034        let mut with_flag = base.clone();
1035        with_flag.is_remix = true;
1036        let mut without_flag = base;
1037        without_flag.is_remix = false;
1038
1039        assert_eq!(edge_type(&with_flag), edge_type(&without_flag));
1040        assert_eq!(
1041            immediate_parent(&with_flag),
1042            immediate_parent(&without_flag)
1043        );
1044        assert_eq!(edge_type(&with_flag), Some(EdgeType::Cover));
1045        assert_eq!(
1046            immediate_parent(&with_flag),
1047            Some(("root-1".into(), EdgeType::Cover))
1048        );
1049    }
1050
1051    #[test]
1052    fn zero_uuid_cover_falls_back_to_edited() {
1053        let clip = Clip {
1054            clip_type: "gen".into(),
1055            task: "cover".into(),
1056            cover_clip_id: ZERO_UUID.into(),
1057            edited_clip_id: "real-parent".into(),
1058            ..Default::default()
1059        };
1060        assert_eq!(
1061            immediate_parent(&clip),
1062            Some(("real-parent".into(), EdgeType::Cover))
1063        );
1064    }
1065
1066    #[test]
1067    fn m_prefix_is_stripped_from_history_and_concat_ids() {
1068        let extend = Clip {
1069            clip_type: "gen".into(),
1070            task: "extend".into(),
1071            history: vec![history("m_abc123")],
1072            ..Default::default()
1073        };
1074        assert_eq!(
1075            immediate_parent(&extend),
1076            Some(("abc123".into(), EdgeType::Extend))
1077        );
1078
1079        let stitch = Clip {
1080            clip_type: "concat".into(),
1081            concat_history: vec![history("m_base"), history("m_second")],
1082            ..Default::default()
1083        };
1084        let edges = lineage_edges(&stitch);
1085        assert_eq!(edges[0].parent_id, "base");
1086        assert_eq!(edges[1].parent_id, "second");
1087        assert_eq!(edges[1].role, EdgeRole::Secondary);
1088    }
1089
1090    #[test]
1091    fn lineage_edges_of_a_root_is_empty() {
1092        let clip = Clip {
1093            clip_type: "gen".into(),
1094            ..Default::default()
1095        };
1096        assert!(lineage_edges(&clip).is_empty());
1097    }
1098
1099    #[test]
1100    fn lineage_edges_records_stitch_secondaries_in_order() {
1101        let clip = Clip {
1102            clip_type: "concat".into(),
1103            concat_history: vec![history("base"), history("seg1"), history("seg2")],
1104            ..Default::default()
1105        };
1106        let edges = lineage_edges(&clip);
1107        assert_eq!(
1108            edges,
1109            vec![
1110                Edge {
1111                    parent_id: "base".into(),
1112                    edge_type: EdgeType::Stitch,
1113                    role: EdgeRole::Primary,
1114                    ordinal: 0,
1115                    source_field: "concat_history",
1116                },
1117                Edge {
1118                    parent_id: "seg1".into(),
1119                    edge_type: EdgeType::Stitch,
1120                    role: EdgeRole::Secondary,
1121                    ordinal: 1,
1122                    source_field: "concat_history",
1123                },
1124                Edge {
1125                    parent_id: "seg2".into(),
1126                    edge_type: EdgeType::Stitch,
1127                    role: EdgeRole::Secondary,
1128                    ordinal: 2,
1129                    source_field: "concat_history",
1130                },
1131            ]
1132        );
1133    }
1134
1135    #[test]
1136    fn lineage_edges_emits_secondaries_when_the_primary_is_absent() {
1137        // A stitch whose base segment id is empty still has real secondary
1138        // segments: they must be emitted (with their own ordinals) rather than
1139        // dropped for want of a primary.
1140        let clip = Clip {
1141            clip_type: "concat".into(),
1142            concat_history: vec![history(""), history("seg1"), history("seg2")],
1143            ..Default::default()
1144        };
1145        let edges = lineage_edges(&clip);
1146        assert_eq!(
1147            edges,
1148            vec![
1149                Edge {
1150                    parent_id: "seg1".into(),
1151                    edge_type: EdgeType::Stitch,
1152                    role: EdgeRole::Secondary,
1153                    ordinal: 1,
1154                    source_field: "concat_history",
1155                },
1156                Edge {
1157                    parent_id: "seg2".into(),
1158                    edge_type: EdgeType::Stitch,
1159                    role: EdgeRole::Secondary,
1160                    ordinal: 2,
1161                    source_field: "concat_history",
1162                },
1163            ],
1164            "secondaries survive an empty primary base segment"
1165        );
1166    }
1167
1168    #[test]
1169    fn lineage_edges_records_infill_future_as_secondary() {
1170        let clip = Clip {
1171            task: "infill".into(),
1172            override_history_clip_id: "past".into(),
1173            override_future_clip_id: "future".into(),
1174            ..Default::default()
1175        };
1176        let edges = lineage_edges(&clip);
1177        assert_eq!(edges[0].parent_id, "past");
1178        assert_eq!(edges[0].role, EdgeRole::Primary);
1179        assert_eq!(edges[0].source_field, "override_history_clip_id");
1180        assert_eq!(
1181            edges[1],
1182            Edge {
1183                parent_id: "future".into(),
1184                edge_type: EdgeType::SectionReplace,
1185                role: EdgeRole::Secondary,
1186                ordinal: 1,
1187                source_field: "override_future_clip_id",
1188            }
1189        );
1190    }
1191
1192    #[test]
1193    fn resolve_roots_walks_a_connected_chain_with_no_http() {
1194        let http = ScriptedHttp::new();
1195        let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1196        let clips = chain1_clips();
1197
1198        let roots = pollster::block_on(resolve_roots(
1199            &clips,
1200            &mut client,
1201            &http,
1202            ResolveOpts::default(),
1203        ))
1204        .unwrap()
1205        .roots;
1206
1207        assert!(
1208            http.calls().is_empty(),
1209            "a fully-connected chain must never touch the network"
1210        );
1211        assert_eq!(roots.len(), clips.len());
1212        for clip in &clips {
1213            let info = &roots[&clip.id];
1214            assert_eq!(info.status, ResolveStatus::Resolved);
1215            assert_eq!(info.root_id, "dfb59a04");
1216            assert_eq!(info.root_title, "Zac and the Sea Eagles");
1217        }
1218    }
1219
1220    #[test]
1221    fn resolve_roots_gap_fills_a_missing_ancestor_by_id() {
1222        let cover = Clip {
1223            id: "child".into(),
1224            title: "Cover".into(),
1225            clip_type: "gen".into(),
1226            task: "cover".into(),
1227            cover_clip_id: "root".into(),
1228            edited_clip_id: "root".into(),
1229            ..Default::default()
1230        };
1231        let root_clip = serde_json::json!({
1232            "id": "root", "title": "Original", "status": "complete",
1233            "metadata": {"type": "gen"}
1234        })
1235        .to_string();
1236        let http = ScriptedHttp::new()
1237            .with_auth()
1238            .route("/api/clip/root", Reply::json(&root_clip));
1239        let mut client = authed_client(&http);
1240
1241        let roots = pollster::block_on(resolve_roots(
1242            &[cover],
1243            &mut client,
1244            &http,
1245            ResolveOpts::default(),
1246        ))
1247        .unwrap()
1248        .roots;
1249
1250        let info = &roots["child"];
1251        assert_eq!(info.status, ResolveStatus::Resolved);
1252        assert_eq!(info.root_id, "root");
1253        assert_eq!(info.root_title, "Original");
1254        assert_eq!(http.count("/api/clip/root"), 1);
1255        assert_eq!(
1256            http.count("/api/clips/parent"),
1257            0,
1258            "the parent endpoint must not be used when the per-id fetch succeeds"
1259        );
1260    }
1261
1262    #[test]
1263    fn resolve_roots_returns_gap_filled_ancestors_for_archival() {
1264        // The fetched (often trashed) ancestor is handed back so a later phase
1265        // can archive it before Suno's purge (HARDENING H4). It resolves the
1266        // child's root yet stays out of the roots (download) set.
1267        let cover = Clip {
1268            id: "child".into(),
1269            title: "Cover".into(),
1270            clip_type: "gen".into(),
1271            task: "cover".into(),
1272            cover_clip_id: "root".into(),
1273            edited_clip_id: "root".into(),
1274            ..Default::default()
1275        };
1276        let root_clip = serde_json::json!({
1277            "id": "root", "title": "Trashed Original", "status": "complete",
1278            "metadata": {"type": "gen"}
1279        })
1280        .to_string();
1281        let http = ScriptedHttp::new()
1282            .with_auth()
1283            .route("/api/clip/root", Reply::json(&root_clip));
1284        let mut client = authed_client(&http);
1285
1286        let resolution = pollster::block_on(resolve_roots(
1287            &[cover],
1288            &mut client,
1289            &http,
1290            ResolveOpts::default(),
1291        ))
1292        .unwrap();
1293
1294        assert_eq!(resolution.gap_filled.len(), 1);
1295        assert_eq!(resolution.gap_filled[0].id, "root");
1296        assert_eq!(resolution.gap_filled[0].title, "Trashed Original");
1297        assert_eq!(resolution.roots["child"].root_id, "root");
1298        assert!(
1299            !resolution.roots.contains_key("root"),
1300            "gap-filled ancestors must never enter the roots set"
1301        );
1302    }
1303
1304    #[test]
1305    fn resolve_roots_falls_back_to_the_parent_endpoint() {
1306        let cover = Clip {
1307            id: "child".into(),
1308            title: "Cover".into(),
1309            clip_type: "gen".into(),
1310            task: "cover".into(),
1311            cover_clip_id: "missing".into(),
1312            edited_clip_id: "missing".into(),
1313            ..Default::default()
1314        };
1315        // The per-id fetch of `missing` 404s; the parent endpoint yields its
1316        // parent (the root), which the walk then bridges over `missing` to.
1317        let parent_body = serde_json::json!({
1318            "id": "root", "title": "Original", "status": "complete",
1319            "metadata": {"type": "gen"}
1320        })
1321        .to_string();
1322        let http = ScriptedHttp::new()
1323            .with_auth()
1324            .route("/api/clip/missing", Reply::status(404))
1325            .route("/api/clips/parent", Reply::json(&parent_body));
1326        let mut client = authed_client(&http);
1327
1328        let roots = pollster::block_on(resolve_roots(
1329            &[cover],
1330            &mut client,
1331            &http,
1332            ResolveOpts::default(),
1333        ))
1334        .unwrap()
1335        .roots;
1336
1337        let info = &roots["child"];
1338        assert_eq!(info.status, ResolveStatus::Resolved);
1339        assert_eq!(info.root_id, "root");
1340        assert_eq!(info.root_title, "Original");
1341        assert!(
1342            http.count("/api/clips/parent?clip_id=missing") >= 1,
1343            "the missing ancestor must be resolved via the parent endpoint"
1344        );
1345    }
1346
1347    #[test]
1348    fn resolve_roots_detects_a_cycle_without_looping() {
1349        let a = Clip {
1350            id: "a".into(),
1351            title: "A".into(),
1352            clip_type: "gen".into(),
1353            task: "cover".into(),
1354            cover_clip_id: "b".into(),
1355            edited_clip_id: "b".into(),
1356            ..Default::default()
1357        };
1358        let b = Clip {
1359            id: "b".into(),
1360            title: "B".into(),
1361            clip_type: "gen".into(),
1362            task: "cover".into(),
1363            cover_clip_id: "a".into(),
1364            edited_clip_id: "a".into(),
1365            ..Default::default()
1366        };
1367        let http = ScriptedHttp::new();
1368        let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1369
1370        let roots = pollster::block_on(resolve_roots(
1371            &[a, b],
1372            &mut client,
1373            &http,
1374            ResolveOpts::default(),
1375        ))
1376        .unwrap()
1377        .roots;
1378
1379        assert_eq!(roots["a"].status, ResolveStatus::Cycle);
1380        assert_eq!(roots["b"].status, ResolveStatus::Cycle);
1381        assert!(http.calls().is_empty());
1382    }
1383
1384    #[test]
1385    fn resolve_roots_marks_external_when_the_budget_is_exhausted() {
1386        // child -> m1 (missing) -> m2 (missing) -> ...; only one gap-fill allowed.
1387        let child = Clip {
1388            id: "child".into(),
1389            title: "Child".into(),
1390            clip_type: "gen".into(),
1391            task: "cover".into(),
1392            cover_clip_id: "m1".into(),
1393            edited_clip_id: "m1".into(),
1394            ..Default::default()
1395        };
1396        let m1_clip = serde_json::json!({
1397            "id": "m1", "title": "Middle", "status": "complete",
1398            "metadata": {"type": "gen", "task": "cover", "cover_clip_id": "m2", "edited_clip_id": "m2"}
1399        })
1400        .to_string();
1401        let http = ScriptedHttp::new()
1402            .with_auth()
1403            .route("/api/clip/m1", Reply::json(&m1_clip));
1404        let mut client = authed_client(&http);
1405        let opts = ResolveOpts {
1406            max_gap_fills: 1,
1407            hop_cap: 64,
1408        };
1409
1410        let roots = pollster::block_on(resolve_roots(&[child], &mut client, &http, opts))
1411            .unwrap()
1412            .roots;
1413
1414        let info = &roots["child"];
1415        assert_eq!(info.status, ResolveStatus::External);
1416        assert_eq!(
1417            info.root_id, "m2",
1418            "resolution stops at the first ancestor it could not fetch"
1419        );
1420        assert_eq!(http.count("/api/clip/m1"), 1);
1421        assert_eq!(
1422            http.count("/api/clip/m2"),
1423            0,
1424            "the gap-fill budget must not be exceeded"
1425        );
1426    }
1427
1428    #[test]
1429    fn resolve_roots_external_root_endpoint_stops_the_walk() {
1430        // The parent endpoint reporting no parent means an external root: the
1431        // ancestor exists on Suno but is outside the caller's library.
1432        let cover = Clip {
1433            id: "child".into(),
1434            title: "Cover".into(),
1435            clip_type: "gen".into(),
1436            task: "cover".into(),
1437            cover_clip_id: "outside".into(),
1438            edited_clip_id: "outside".into(),
1439            ..Default::default()
1440        };
1441        let http = ScriptedHttp::new()
1442            .with_auth()
1443            .route("/api/clip/outside", Reply::status(404))
1444            .route("/api/clips/parent", Reply::status(404));
1445        let mut client = authed_client(&http);
1446
1447        let roots = pollster::block_on(resolve_roots(
1448            &[cover],
1449            &mut client,
1450            &http,
1451            ResolveOpts::default(),
1452        ))
1453        .unwrap()
1454        .roots;
1455
1456        let info = &roots["child"];
1457        assert_eq!(info.status, ResolveStatus::External);
1458        assert_eq!(info.root_id, "outside");
1459    }
1460
1461    fn resolution_with(roots: Vec<(&str, RootInfo)>) -> Resolution {
1462        Resolution {
1463            roots: roots
1464                .into_iter()
1465                .map(|(id, info)| (id.to_owned(), info))
1466                .collect(),
1467            gap_filled: Vec::new(),
1468        }
1469    }
1470
1471    #[test]
1472    fn context_for_a_root_uses_its_own_id_and_title() {
1473        let root = Clip {
1474            id: "root-1".into(),
1475            title: "Original".into(),
1476            ..Default::default()
1477        };
1478        let resolution = resolution_with(vec![(
1479            "root-1",
1480            RootInfo {
1481                root_id: "root-1".into(),
1482                root_title: "Original".into(),
1483                status: ResolveStatus::Resolved,
1484            },
1485        )]);
1486
1487        let ctx = LineageContext::for_clip(&root, &resolution);
1488        assert_eq!(ctx.root_id, "root-1");
1489        assert_eq!(ctx.root_title, "Original");
1490        assert_eq!(ctx.parent_id, "");
1491        assert_eq!(ctx.edge_type, None);
1492        // A root folders under its own title.
1493        assert_eq!(ctx.album("Original"), "Original");
1494    }
1495
1496    #[test]
1497    fn context_for_a_remix_carries_root_and_parent() {
1498        let child = Clip {
1499            id: "child-1".into(),
1500            title: "Remix".into(),
1501            clip_type: "gen".into(),
1502            task: "cover".into(),
1503            cover_clip_id: "root-1".into(),
1504            edited_clip_id: "root-1".into(),
1505            ..Default::default()
1506        };
1507        let resolution = resolution_with(vec![(
1508            "child-1",
1509            RootInfo {
1510                root_id: "root-1".into(),
1511                root_title: "Original".into(),
1512                status: ResolveStatus::Resolved,
1513            },
1514        )]);
1515
1516        let ctx = LineageContext::for_clip(&child, &resolution);
1517        assert_eq!(ctx.root_id, "root-1");
1518        assert_eq!(ctx.root_title, "Original");
1519        assert_eq!(ctx.parent_id, "root-1");
1520        assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1521        // A remix folders under the root's album title, not its own.
1522        assert_eq!(ctx.album("Remix"), "Original");
1523    }
1524
1525    #[test]
1526    fn context_absent_from_resolution_is_its_own_root() {
1527        let clip = Clip {
1528            id: "lonely".into(),
1529            title: "Solo".into(),
1530            ..Default::default()
1531        };
1532        let ctx = LineageContext::for_clip(&clip, &resolution_with(vec![]));
1533        assert_eq!(ctx.root_id, "lonely");
1534        assert_eq!(ctx.root_title, "Solo");
1535        assert_eq!(ctx.status, ResolveStatus::Resolved);
1536        assert_eq!(ctx.album("Solo"), "Solo");
1537    }
1538
1539    #[test]
1540    fn album_falls_back_to_own_title_when_root_title_is_empty() {
1541        let ctx = LineageContext {
1542            root_id: "outside".into(),
1543            root_title: String::new(),
1544            parent_id: "outside".into(),
1545            edge_type: Some(EdgeType::Cover),
1546            status: ResolveStatus::External,
1547        };
1548        assert_eq!(ctx.album("My Title"), "My Title");
1549    }
1550
1551    #[test]
1552    fn own_root_has_no_parent() {
1553        let clip = Clip {
1554            id: "solo".into(),
1555            title: "Solo".into(),
1556            ..Default::default()
1557        };
1558        let ctx = LineageContext::own_root(&clip);
1559        assert_eq!(ctx.root_id, "solo");
1560        assert_eq!(ctx.parent_id, "");
1561        assert_eq!(ctx.edge_type, None);
1562    }
1563}