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