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_feed = serde_json::json!({
1232            "clips": [{
1233                "id": "root", "title": "Original", "status": "complete",
1234                "metadata": {"type": "gen"}
1235            }]
1236        })
1237        .to_string();
1238        let http = ScriptedHttp::new()
1239            .with_auth()
1240            .route("/api/feed/v2/?ids=", Reply::json(&root_feed));
1241        let mut client = authed_client(&http);
1242
1243        let roots = pollster::block_on(resolve_roots(
1244            &[cover],
1245            &mut client,
1246            &http,
1247            ResolveOpts::default(),
1248        ))
1249        .unwrap()
1250        .roots;
1251
1252        let info = &roots["child"];
1253        assert_eq!(info.status, ResolveStatus::Resolved);
1254        assert_eq!(info.root_id, "root");
1255        assert_eq!(info.root_title, "Original");
1256        assert_eq!(http.count("/api/feed/v2/?ids=root"), 1);
1257        assert_eq!(
1258            http.count("/api/clips/parent"),
1259            0,
1260            "the parent endpoint must not be used when ?ids= succeeds"
1261        );
1262    }
1263
1264    #[test]
1265    fn resolve_roots_returns_gap_filled_ancestors_for_archival() {
1266        // The fetched (often trashed) ancestor is handed back so a later phase
1267        // can archive it before Suno's purge (HARDENING H4). It resolves the
1268        // child's root yet stays out of the roots (download) set.
1269        let cover = Clip {
1270            id: "child".into(),
1271            title: "Cover".into(),
1272            clip_type: "gen".into(),
1273            task: "cover".into(),
1274            cover_clip_id: "root".into(),
1275            edited_clip_id: "root".into(),
1276            ..Default::default()
1277        };
1278        let root_feed = serde_json::json!({
1279            "clips": [{
1280                "id": "root", "title": "Trashed Original", "status": "complete",
1281                "metadata": {"type": "gen"}
1282            }]
1283        })
1284        .to_string();
1285        let http = ScriptedHttp::new()
1286            .with_auth()
1287            .route("/api/feed/v2/?ids=", Reply::json(&root_feed));
1288        let mut client = authed_client(&http);
1289
1290        let resolution = pollster::block_on(resolve_roots(
1291            &[cover],
1292            &mut client,
1293            &http,
1294            ResolveOpts::default(),
1295        ))
1296        .unwrap();
1297
1298        assert_eq!(resolution.gap_filled.len(), 1);
1299        assert_eq!(resolution.gap_filled[0].id, "root");
1300        assert_eq!(resolution.gap_filled[0].title, "Trashed Original");
1301        assert_eq!(resolution.roots["child"].root_id, "root");
1302        assert!(
1303            !resolution.roots.contains_key("root"),
1304            "gap-filled ancestors must never enter the roots set"
1305        );
1306    }
1307
1308    #[test]
1309    fn resolve_roots_falls_back_to_the_parent_endpoint() {
1310        let cover = Clip {
1311            id: "child".into(),
1312            title: "Cover".into(),
1313            clip_type: "gen".into(),
1314            task: "cover".into(),
1315            cover_clip_id: "missing".into(),
1316            edited_clip_id: "missing".into(),
1317            ..Default::default()
1318        };
1319        // The `?ids=` route cannot return `missing`; the parent endpoint yields
1320        // its parent (the root), which the walk then bridges over `missing` to.
1321        let empty_feed = serde_json::json!({"clips": []}).to_string();
1322        let parent_body = serde_json::json!({
1323            "id": "root", "title": "Original", "status": "complete",
1324            "metadata": {"type": "gen"}
1325        })
1326        .to_string();
1327        let http = ScriptedHttp::new()
1328            .with_auth()
1329            .route("/api/feed/v2/?ids=", Reply::json(&empty_feed))
1330            .route("/api/clips/parent", Reply::json(&parent_body));
1331        let mut client = authed_client(&http);
1332
1333        let roots = pollster::block_on(resolve_roots(
1334            &[cover],
1335            &mut client,
1336            &http,
1337            ResolveOpts::default(),
1338        ))
1339        .unwrap()
1340        .roots;
1341
1342        let info = &roots["child"];
1343        assert_eq!(info.status, ResolveStatus::Resolved);
1344        assert_eq!(info.root_id, "root");
1345        assert_eq!(info.root_title, "Original");
1346        assert!(
1347            http.count("/api/clips/parent?clip_id=missing") >= 1,
1348            "the missing ancestor must be resolved via the parent endpoint"
1349        );
1350    }
1351
1352    #[test]
1353    fn resolve_roots_detects_a_cycle_without_looping() {
1354        let a = Clip {
1355            id: "a".into(),
1356            title: "A".into(),
1357            clip_type: "gen".into(),
1358            task: "cover".into(),
1359            cover_clip_id: "b".into(),
1360            edited_clip_id: "b".into(),
1361            ..Default::default()
1362        };
1363        let b = Clip {
1364            id: "b".into(),
1365            title: "B".into(),
1366            clip_type: "gen".into(),
1367            task: "cover".into(),
1368            cover_clip_id: "a".into(),
1369            edited_clip_id: "a".into(),
1370            ..Default::default()
1371        };
1372        let http = ScriptedHttp::new();
1373        let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1374
1375        let roots = pollster::block_on(resolve_roots(
1376            &[a, b],
1377            &mut client,
1378            &http,
1379            ResolveOpts::default(),
1380        ))
1381        .unwrap()
1382        .roots;
1383
1384        assert_eq!(roots["a"].status, ResolveStatus::Cycle);
1385        assert_eq!(roots["b"].status, ResolveStatus::Cycle);
1386        assert!(http.calls().is_empty());
1387    }
1388
1389    #[test]
1390    fn resolve_roots_marks_external_when_the_budget_is_exhausted() {
1391        // child -> m1 (missing) -> m2 (missing) -> ...; only one gap-fill allowed.
1392        let child = Clip {
1393            id: "child".into(),
1394            title: "Child".into(),
1395            clip_type: "gen".into(),
1396            task: "cover".into(),
1397            cover_clip_id: "m1".into(),
1398            edited_clip_id: "m1".into(),
1399            ..Default::default()
1400        };
1401        let m1_feed = serde_json::json!({
1402            "clips": [{
1403                "id": "m1", "title": "Middle", "status": "complete",
1404                "metadata": {"type": "gen", "task": "cover", "cover_clip_id": "m2", "edited_clip_id": "m2"}
1405            }]
1406        })
1407        .to_string();
1408        let http = ScriptedHttp::new()
1409            .with_auth()
1410            .route("/api/feed/v2/?ids=", Reply::json(&m1_feed));
1411        let mut client = authed_client(&http);
1412        let opts = ResolveOpts {
1413            max_gap_fills: 1,
1414            hop_cap: 64,
1415        };
1416
1417        let roots = pollster::block_on(resolve_roots(&[child], &mut client, &http, opts))
1418            .unwrap()
1419            .roots;
1420
1421        let info = &roots["child"];
1422        assert_eq!(info.status, ResolveStatus::External);
1423        assert_eq!(
1424            info.root_id, "m2",
1425            "resolution stops at the first ancestor it could not fetch"
1426        );
1427        assert_eq!(http.count("/api/feed/v2/?ids=m1"), 1);
1428        assert_eq!(
1429            http.count("ids=m2"),
1430            0,
1431            "the gap-fill budget must not be exceeded"
1432        );
1433    }
1434
1435    #[test]
1436    fn resolve_roots_external_root_endpoint_stops_the_walk() {
1437        // The parent endpoint reporting no parent means an external root: the
1438        // ancestor exists on Suno but is outside the caller's library.
1439        let cover = Clip {
1440            id: "child".into(),
1441            title: "Cover".into(),
1442            clip_type: "gen".into(),
1443            task: "cover".into(),
1444            cover_clip_id: "outside".into(),
1445            edited_clip_id: "outside".into(),
1446            ..Default::default()
1447        };
1448        let empty_feed = serde_json::json!({"clips": []}).to_string();
1449        let http = ScriptedHttp::new()
1450            .with_auth()
1451            .route("/api/feed/v2/?ids=", Reply::json(&empty_feed))
1452            .route("/api/clips/parent", Reply::status(404));
1453        let mut client = authed_client(&http);
1454
1455        let roots = pollster::block_on(resolve_roots(
1456            &[cover],
1457            &mut client,
1458            &http,
1459            ResolveOpts::default(),
1460        ))
1461        .unwrap()
1462        .roots;
1463
1464        let info = &roots["child"];
1465        assert_eq!(info.status, ResolveStatus::External);
1466        assert_eq!(info.root_id, "outside");
1467    }
1468
1469    fn resolution_with(roots: Vec<(&str, RootInfo)>) -> Resolution {
1470        Resolution {
1471            roots: roots
1472                .into_iter()
1473                .map(|(id, info)| (id.to_owned(), info))
1474                .collect(),
1475            gap_filled: Vec::new(),
1476        }
1477    }
1478
1479    #[test]
1480    fn context_for_a_root_uses_its_own_id_and_title() {
1481        let root = Clip {
1482            id: "root-1".into(),
1483            title: "Original".into(),
1484            ..Default::default()
1485        };
1486        let resolution = resolution_with(vec![(
1487            "root-1",
1488            RootInfo {
1489                root_id: "root-1".into(),
1490                root_title: "Original".into(),
1491                status: ResolveStatus::Resolved,
1492            },
1493        )]);
1494
1495        let ctx = LineageContext::for_clip(&root, &resolution);
1496        assert_eq!(ctx.root_id, "root-1");
1497        assert_eq!(ctx.root_title, "Original");
1498        assert_eq!(ctx.parent_id, "");
1499        assert_eq!(ctx.edge_type, None);
1500        // A root folders under its own title.
1501        assert_eq!(ctx.album("Original"), "Original");
1502    }
1503
1504    #[test]
1505    fn context_for_a_remix_carries_root_and_parent() {
1506        let child = Clip {
1507            id: "child-1".into(),
1508            title: "Remix".into(),
1509            clip_type: "gen".into(),
1510            task: "cover".into(),
1511            cover_clip_id: "root-1".into(),
1512            edited_clip_id: "root-1".into(),
1513            ..Default::default()
1514        };
1515        let resolution = resolution_with(vec![(
1516            "child-1",
1517            RootInfo {
1518                root_id: "root-1".into(),
1519                root_title: "Original".into(),
1520                status: ResolveStatus::Resolved,
1521            },
1522        )]);
1523
1524        let ctx = LineageContext::for_clip(&child, &resolution);
1525        assert_eq!(ctx.root_id, "root-1");
1526        assert_eq!(ctx.root_title, "Original");
1527        assert_eq!(ctx.parent_id, "root-1");
1528        assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1529        // A remix folders under the root's album title, not its own.
1530        assert_eq!(ctx.album("Remix"), "Original");
1531    }
1532
1533    #[test]
1534    fn context_absent_from_resolution_is_its_own_root() {
1535        let clip = Clip {
1536            id: "lonely".into(),
1537            title: "Solo".into(),
1538            ..Default::default()
1539        };
1540        let ctx = LineageContext::for_clip(&clip, &resolution_with(vec![]));
1541        assert_eq!(ctx.root_id, "lonely");
1542        assert_eq!(ctx.root_title, "Solo");
1543        assert_eq!(ctx.status, ResolveStatus::Resolved);
1544        assert_eq!(ctx.album("Solo"), "Solo");
1545    }
1546
1547    #[test]
1548    fn album_falls_back_to_own_title_when_root_title_is_empty() {
1549        let ctx = LineageContext {
1550            root_id: "outside".into(),
1551            root_title: String::new(),
1552            parent_id: "outside".into(),
1553            edge_type: Some(EdgeType::Cover),
1554            status: ResolveStatus::External,
1555        };
1556        assert_eq!(ctx.album("My Title"), "My Title");
1557    }
1558
1559    #[test]
1560    fn own_root_has_no_parent() {
1561        let clip = Clip {
1562            id: "solo".into(),
1563            title: "Solo".into(),
1564            ..Default::default()
1565        };
1566        let ctx = LineageContext::own_root(&clip);
1567        assert_eq!(ctx.root_id, "solo");
1568        assert_eq!(ctx.parent_id, "");
1569        assert_eq!(ctx.edge_type, None);
1570    }
1571}