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