Skip to main content

suno_core/
naming.rs

1//! Pure naming and relative path rendering for [`Clip`] values.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5use std::path::PathBuf;
6use std::str::FromStr;
7
8use serde::{Deserialize, Serialize};
9
10use crate::Clip;
11use crate::error::{Error, Result};
12use crate::lineage::LineageContext;
13
14/// The default relative path template.
15///
16/// Supported placeholders are `{creator}`, `{handle}`, `{album}`, `{title}`,
17/// `{id}`, `{id8}` (first 8 characters of the clip id), and `{root_id8}`
18/// (first 8 of the resolved lineage root id). Empty path segments are dropped
19/// after rendering.
20///
21/// The default embeds `[{id8}]` in the file name so same-title clips never
22/// collide, and folders under `{album}`, which resolves to the lineage root's
23/// title (else the clip's own title).
24pub const DEFAULT_TEMPLATE: &str = "{creator}/{album}/{creator}-{title} [{id8}]";
25const DEFAULT_MAX_COMPONENT_LEN: usize = 80;
26
27const MIN_BASE_CHARS_WITH_SUFFIX: usize = 1;
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum CharacterSet {
32    #[default]
33    Unicode,
34    Ascii,
35}
36
37impl FromStr for CharacterSet {
38    type Err = Error;
39
40    fn from_str(s: &str) -> Result<Self> {
41        match s.to_ascii_lowercase().as_str() {
42            "unicode" => Ok(Self::Unicode),
43            "ascii" => Ok(Self::Ascii),
44            other => Err(Error::Config(format!(
45                "unknown character_set '{other}'; expected 'unicode' or 'ascii'"
46            ))),
47        }
48    }
49}
50
51impl fmt::Display for CharacterSet {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            Self::Unicode => f.write_str("unicode"),
55            Self::Ascii => f.write_str("ascii"),
56        }
57    }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct NamingConfig {
62    pub template: String,
63    pub character_set: CharacterSet,
64    pub max_component_len: usize,
65}
66
67impl Default for NamingConfig {
68    fn default() -> Self {
69        Self {
70            template: DEFAULT_TEMPLATE.to_string(),
71            character_set: CharacterSet::Unicode,
72            max_component_len: DEFAULT_MAX_COMPONENT_LEN,
73        }
74    }
75}
76
77#[derive(Debug, Clone, Copy)]
78pub struct NamingRequest<'a> {
79    pub clip: &'a Clip,
80    pub lineage: &'a LineageContext,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct RenderedName {
85    pub relative_path: PathBuf,
86    pub base_name: String,
87}
88
89pub fn render_clip_name(request: NamingRequest<'_>, config: &NamingConfig) -> RenderedName {
90    let album = album_component(request, config);
91    render_with_album(request, config, &album)
92}
93
94pub fn render_clip_names(
95    requests: &[NamingRequest<'_>],
96    config: &NamingConfig,
97    colliding_albums: &BTreeSet<String>,
98) -> Vec<RenderedName> {
99    let albums = disambiguated_albums(requests, config, colliding_albums);
100    let mut rendered = requests
101        .iter()
102        .zip(&albums)
103        .map(|(request, album)| render_with_album(*request, config, album))
104        .collect::<Vec<_>>();
105
106    // Filename fallback: any distinct clips that still render to one path (a
107    // custom template lacking `{id8}`) are separated by their full clip id.
108    let mut collisions = BTreeMap::<String, Vec<usize>>::new();
109    for (index, name) in rendered.iter().enumerate() {
110        collisions
111            .entry(name.relative_path.to_string_lossy().into_owned())
112            .or_default()
113            .push(index);
114    }
115
116    for indexes in collisions.into_values().filter(|indexes| indexes.len() > 1) {
117        for index in indexes {
118            let suffix = &requests[index].clip.id;
119            rendered[index] =
120                with_suffix(rendered[index].clone(), suffix, config.max_component_len);
121        }
122    }
123
124    rendered
125}
126
127/// The album path component for every request, with a clip whose root title
128/// collides across distinct roots disambiguated by `[{root_id8}]`.
129///
130/// Distinct roots must never share an album folder (two different upload roots
131/// titled "Break Through" exist). `colliding_albums` is the authoritative set
132/// of such shared root titles, computed once from the whole lineage store, so
133/// the decision is stable across runs and independent of which clips appear in
134/// this batch. A clip whose resolved album is in that set always gets its
135/// root's short id appended; every other clip keeps the bare album and groups
136/// with its same-root siblings.
137fn disambiguated_albums(
138    requests: &[NamingRequest<'_>],
139    config: &NamingConfig,
140    colliding_albums: &BTreeSet<String>,
141) -> Vec<String> {
142    requests
143        .iter()
144        .map(|request| album_for(*request, config, colliding_albums))
145        .collect()
146}
147
148/// The (possibly disambiguated) album component for one request.
149fn album_for(
150    request: NamingRequest<'_>,
151    config: &NamingConfig,
152    colliding_albums: &BTreeSet<String>,
153) -> String {
154    let raw_album = request.lineage.album(&title_name(request.clip));
155    let album = sanitise_component(&raw_album, config.character_set, config.max_component_len);
156    if colliding_albums.contains(raw_album.trim()) {
157        let suffix = truncate_chars(&request.lineage.root_id, 8);
158        sanitise_component(
159            &format!("{album} [{suffix}]"),
160            config.character_set,
161            config.max_component_len,
162        )
163    } else {
164        album
165    }
166}
167
168/// The sanitised album component: the resolved lineage album (root title, else
169/// the clip's own title).
170fn album_component(request: NamingRequest<'_>, config: &NamingConfig) -> String {
171    let album = request.lineage.album(&title_name(request.clip));
172    sanitise_component(&album, config.character_set, config.max_component_len)
173}
174
175/// Render one clip's path with an already-resolved album component.
176fn render_with_album(
177    request: NamingRequest<'_>,
178    config: &NamingConfig,
179    album: &str,
180) -> RenderedName {
181    let clip = request.clip;
182    let creator = sanitise_component(
183        &creator_name(clip),
184        config.character_set,
185        config.max_component_len,
186    );
187    let handle = sanitise_component(&clip.handle, config.character_set, config.max_component_len);
188    let title = sanitise_component(
189        &title_name(clip),
190        config.character_set,
191        config.max_component_len,
192    );
193    let id = sanitise_component(&clip.id, CharacterSet::Ascii, config.max_component_len);
194    let id8 = sanitise_component(
195        &truncate_chars(&clip.id, 8),
196        CharacterSet::Ascii,
197        config.max_component_len,
198    );
199    let root_id8 = sanitise_component(
200        &truncate_chars(&request.lineage.root_id, 8),
201        CharacterSet::Ascii,
202        config.max_component_len,
203    );
204    let mut components = config
205        .template
206        .split('/')
207        .filter_map(|segment| {
208            let rendered = segment
209                .replace("{creator}", &creator)
210                .replace("{handle}", &handle)
211                .replace("{album}", album)
212                .replace("{title}", &title)
213                .replace("{root_id8}", &root_id8)
214                .replace("{id8}", &id8)
215                .replace("{id}", &id);
216            let sanitised =
217                sanitise_component(&rendered, config.character_set, config.max_component_len);
218            (!sanitised.is_empty()).then_some(sanitised)
219        })
220        .collect::<Vec<_>>();
221
222    if components.is_empty() {
223        components.push(title.clone());
224    }
225
226    let mut base_name = components
227        .pop()
228        .filter(|value| !value.is_empty())
229        .unwrap_or_else(|| title.clone());
230    // Guarantee a non-empty file name even when every token sanitises away.
231    if base_name.is_empty() {
232        base_name = append_suffix(&base_name, &clip.id, config.max_component_len);
233    }
234
235    let mut relative_path = PathBuf::new();
236    for component in components {
237        relative_path.push(component);
238    }
239
240    relative_path.push(&base_name);
241    RenderedName {
242        relative_path,
243        base_name,
244    }
245}
246
247fn with_suffix(mut rendered: RenderedName, suffix: &str, max_component_len: usize) -> RenderedName {
248    rendered.base_name = append_suffix(&rendered.base_name, suffix, max_component_len);
249    rendered.relative_path.set_file_name(&rendered.base_name);
250    rendered
251}
252
253fn creator_name(clip: &Clip) -> String {
254    non_blank(&clip.display_name)
255        .or_else(|| non_blank(&clip.handle))
256        .unwrap_or("Unknown Creator")
257        .to_string()
258}
259
260fn title_name(clip: &Clip) -> String {
261    let title = clip.title.trim();
262    if title.is_empty() || title.eq_ignore_ascii_case("untitled") {
263        "Untitled".to_string()
264    } else {
265        title.to_string()
266    }
267}
268
269fn append_suffix(base: &str, suffix: &str, max_component_len: usize) -> String {
270    let suffix_pattern = format!(" [{suffix}]");
271    if base.ends_with(&suffix_pattern) {
272        return sanitise_component(base, CharacterSet::Unicode, max_component_len);
273    }
274
275    let max_len =
276        max_component_len.max(suffix_pattern.chars().count() + MIN_BASE_CHARS_WITH_SUFFIX);
277    let allowed = max_len.saturating_sub(suffix_pattern.chars().count());
278    let truncated = truncate_chars(base.trim_end(), allowed);
279    let combined = format!("{truncated}{suffix_pattern}");
280    sanitise_component(&combined, CharacterSet::Unicode, max_len)
281}
282
283/// Sanitise a free-form playlist name into a single safe path component.
284///
285/// Applies the same Unicode filtering and length cap as clip path components
286/// (default [`CharacterSet::Unicode`], [`DEFAULT_MAX_COMPONENT_LEN`]), so a
287/// playlist file name obeys the same filesystem rules as the rest of the
288/// library. An empty or fully-stripped name falls back to `playlist` so the
289/// caller always has a non-empty stem to append `.m3u8` to.
290pub fn sanitise_name(name: &str) -> String {
291    let cleaned = sanitise_component(name, CharacterSet::Unicode, DEFAULT_MAX_COMPONENT_LEN);
292    if cleaned.is_empty() {
293        "playlist".to_string()
294    } else {
295        cleaned
296    }
297}
298
299/// The `.stems` sub-folder that sits beside a song's audio file.
300///
301/// `base` is the song's extensionless relative path (the same value the audio
302/// and its sidecars are built from), so the folder is `{base}.stems`. It cannot
303/// collide with the audio file (`{base}.<ext>`) or any `{base}.<sidecar>`
304/// because the `.stems` suffix is distinct, mirroring the sidecar convention.
305pub fn stems_folder(base: &str) -> String {
306    format!("{base}.stems")
307}
308
309/// The relative path of one stem file inside a song's [`stems_folder`].
310///
311/// Named base+label+disambiguation rather than label-only, because Auto Split
312/// can mislabel stems and Advanced Split yields ~100 instruments, so blank or
313/// duplicate labels are expected. The file is
314/// `{song file name} - {label} [{stem id8}].{ext}`; the ` - {label}` piece is
315/// dropped when the label sanitises to empty, and the `[{stem id8}]`
316/// disambiguator (the first 8 characters of the stable stem id) keeps blank or
317/// duplicate labels collision-free. Every component is run through the same
318/// [`sanitise_component`] filter as the rest of the library, honouring
319/// `character_set`.
320pub fn stem_file_path(
321    base: &str,
322    label: &str,
323    stem_id: &str,
324    ext: &str,
325    character_set: CharacterSet,
326) -> String {
327    let folder = stems_folder(base);
328    // The song's own file-name stem (the last path component of `base`), reused
329    // so a stem stays identifiable even when viewed outside its `.stems` folder.
330    let song_stem = base.rsplit('/').next().unwrap_or(base);
331    let label = sanitise_component(label, character_set, DEFAULT_MAX_COMPONENT_LEN);
332    let id8 = sanitise_component(
333        &truncate_chars(stem_id, 8),
334        CharacterSet::Ascii,
335        DEFAULT_MAX_COMPONENT_LEN,
336    );
337
338    let mut name = song_stem.to_string();
339    if !label.is_empty() {
340        name.push_str(" - ");
341        name.push_str(&label);
342    }
343    if !id8.is_empty() {
344        name.push_str(" [");
345        name.push_str(&id8);
346        name.push(']');
347    }
348    // A degenerate base (empty song stem, blank label, empty id) must still
349    // yield a usable name rather than a hidden dotfile.
350    if name.trim().is_empty() {
351        name = "stem".to_string();
352    }
353    format!("{folder}/{name}.{}", sanitise_ext(ext))
354}
355
356/// Reduce a candidate extension to a safe lowercase alphanumeric token,
357/// defaulting to `mp3` when it is empty or fully stripped. The caller passes the
358/// resolved stem format's extension (`wav` or `mp3`); stems are stored RAW.
359fn sanitise_ext(ext: &str) -> String {
360    let cleaned: String = ext
361        .trim_start_matches('.')
362        .chars()
363        .filter(|c| c.is_ascii_alphanumeric())
364        .flat_map(char::to_lowercase)
365        .take(8)
366        .collect();
367    if cleaned.is_empty() {
368        "mp3".to_string()
369    } else {
370        cleaned
371    }
372}
373
374fn sanitise_component(
375    value: &str,
376    character_set: CharacterSet,
377    max_component_len: usize,
378) -> String {
379    let filtered = match character_set {
380        CharacterSet::Unicode => value.chars().map(unicode_char).collect::<String>(),
381        CharacterSet::Ascii => value.chars().flat_map(ascii_chars).collect::<String>(),
382    };
383    let collapsed = filtered.split_whitespace().collect::<Vec<_>>().join(" ");
384    let trimmed = collapsed.trim_matches([' ', '.']);
385    if trimmed.is_empty() {
386        return String::new();
387    }
388
389    let mut result = truncate_chars(trimmed, max_component_len.max(1));
390    result = result.trim_matches([' ', '.']).to_string();
391    if result.is_empty() {
392        return String::new();
393    }
394    if result == "." || result == ".." {
395        return "item".to_string();
396    }
397    if !result.ends_with('_') && is_reserved_name(&result) {
398        result.push('_');
399    }
400    result
401}
402
403fn unicode_char(ch: char) -> char {
404    if matches!(
405        ch,
406        '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0'
407    ) || ch.is_control()
408    {
409        ' '
410    } else {
411        ch
412    }
413}
414
415fn ascii_chars(ch: char) -> Vec<char> {
416    if ch.is_ascii() {
417        return vec![unicode_char(ch)];
418    }
419
420    match ch {
421        'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => vec!['A'],
422        'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => vec!['a'],
423        'Ç' => vec!['C'],
424        'ç' => vec!['c'],
425        'È' | 'É' | 'Ê' | 'Ë' => vec!['E'],
426        'è' | 'é' | 'ê' | 'ë' => vec!['e'],
427        'Ì' | 'Í' | 'Î' | 'Ï' => vec!['I'],
428        'ì' | 'í' | 'î' | 'ï' => vec!['i'],
429        'Ñ' => vec!['N'],
430        'ñ' => vec!['n'],
431        'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => vec!['O'],
432        'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' => vec!['o'],
433        'Ù' | 'Ú' | 'Û' | 'Ü' => vec!['U'],
434        'ù' | 'ú' | 'û' | 'ü' => vec!['u'],
435        'Ý' | 'Ÿ' => vec!['Y'],
436        'ý' | 'ÿ' => vec!['y'],
437        'Æ' => vec!['A', 'E'],
438        'æ' => vec!['a', 'e'],
439        'Œ' => vec!['O', 'E'],
440        'œ' => vec!['o', 'e'],
441        'ß' => vec!['s', 's'],
442        _ => vec![' '],
443    }
444}
445
446fn truncate_chars(value: &str, max_len: usize) -> String {
447    value.chars().take(max_len).collect()
448}
449
450fn non_blank(value: &str) -> Option<&str> {
451    let trimmed = value.trim();
452    (!trimmed.is_empty()).then_some(trimmed)
453}
454
455fn is_reserved_name(value: &str) -> bool {
456    let stem = value.split('.').next().unwrap_or(value);
457    matches!(
458        stem.to_ascii_uppercase().as_str(),
459        "CON"
460            | "PRN"
461            | "AUX"
462            | "NUL"
463            | "COM1"
464            | "COM2"
465            | "COM3"
466            | "COM4"
467            | "COM5"
468            | "COM6"
469            | "COM7"
470            | "COM8"
471            | "COM9"
472            | "LPT1"
473            | "LPT2"
474            | "LPT3"
475            | "LPT4"
476            | "LPT5"
477            | "LPT6"
478            | "LPT7"
479            | "LPT8"
480            | "LPT9"
481    )
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use crate::lineage::{EdgeType, ResolveStatus};
488    use std::collections::{BTreeMap, BTreeSet};
489
490    fn test_clip(id: &str, title: &str) -> Clip {
491        Clip {
492            id: id.to_string(),
493            title: title.to_string(),
494            display_name: "München".to_string(),
495            handle: "munchen".to_string(),
496            album_title: String::new(),
497            root_ancestor_id: String::new(),
498            ..Clip::default()
499        }
500    }
501
502    fn render_own(clip: &Clip, config: &NamingConfig) -> RenderedName {
503        let lineage = LineageContext::own_root(clip);
504        render_clip_name(
505            NamingRequest {
506                clip,
507                lineage: &lineage,
508            },
509            config,
510        )
511    }
512
513    fn render_all_own(
514        clips: &[Clip],
515        config: &NamingConfig,
516        colliding: &BTreeSet<String>,
517    ) -> Vec<RenderedName> {
518        let lineages: Vec<LineageContext> = clips.iter().map(LineageContext::own_root).collect();
519        let requests: Vec<NamingRequest> = clips
520            .iter()
521            .zip(&lineages)
522            .map(|(clip, lineage)| NamingRequest { clip, lineage })
523            .collect();
524        render_clip_names(&requests, config, colliding)
525    }
526
527    #[test]
528    fn unicode_names_are_preserved_and_ascii_falls_back() {
529        let clip = test_clip("abc12345", "Beyoncé/東京");
530
531        let unicode = render_own(&clip, &NamingConfig::default());
532        assert_eq!(
533            unicode.relative_path.to_string_lossy(),
534            "München/Beyoncé 東京/München-Beyoncé 東京 [abc12345]"
535        );
536
537        let ascii = render_own(
538            &clip,
539            &NamingConfig {
540                character_set: CharacterSet::Ascii,
541                ..NamingConfig::default()
542            },
543        );
544        assert_eq!(
545            ascii.relative_path.to_string_lossy(),
546            "Munchen/Beyonce/Munchen-Beyonce [abc12345]"
547        );
548    }
549
550    #[test]
551    fn reserved_and_hostile_names_are_sanitised() {
552        let clip = Clip {
553            id: "deadbeef".to_string(),
554            title: "CON<>:\"/\\|?*.".to_string(),
555            display_name: "AUX".to_string(),
556            ..Clip::default()
557        };
558
559        let rendered = render_own(&clip, &NamingConfig::default());
560        let path = rendered.relative_path.to_string_lossy();
561        assert!(path.starts_with("AUX_/CON_/"), "path was {path}");
562        assert!(rendered.base_name.contains("[deadbeef]"));
563    }
564
565    #[test]
566    fn default_template_always_embeds_id8() {
567        let clip = test_clip("abcdef1234567890", "Any Title");
568        let rendered = render_own(&clip, &NamingConfig::default());
569        assert!(
570            rendered.base_name.contains("[abcdef12]"),
571            "base_name was {}",
572            rendered.base_name
573        );
574    }
575
576    #[test]
577    fn blank_titles_use_a_stable_suffix() {
578        let clip = test_clip("12345678-clip", "   ");
579
580        let rendered = render_own(&clip, &NamingConfig::default());
581        assert_eq!(rendered.base_name, "München-Untitled [12345678]");
582        assert_eq!(
583            rendered.relative_path.to_string_lossy(),
584            "München/Untitled/München-Untitled [12345678]"
585        );
586    }
587
588    #[test]
589    fn very_long_titles_are_trimmed() {
590        let clip = test_clip("abcdef12", &"a".repeat(120));
591        let rendered = render_own(
592            &clip,
593            &NamingConfig {
594                max_component_len: 24,
595                ..NamingConfig::default()
596            },
597        );
598
599        for component in rendered.relative_path.components() {
600            let text = component.as_os_str().to_string_lossy();
601            assert!(
602                text.chars().count() <= 24,
603                "component {text:?} exceeds 24 chars"
604            );
605        }
606    }
607
608    #[test]
609    fn same_title_siblings_stay_distinct_via_id8() {
610        // Two clips sharing a root (same album folder) and the same title must
611        // still land on distinct files; the default template's {id8} does that.
612        let lineage = LineageContext {
613            root_id: "root-9".to_string(),
614            root_title: "Origin".to_string(),
615            root_date: String::new(),
616            parent_id: "root-9".to_string(),
617            edge_type: Some(EdgeType::Cover),
618            status: ResolveStatus::Resolved,
619        };
620        let first = test_clip("11111111-alpha", "Shared");
621        let second = test_clip("22222222-beta", "Shared");
622        let requests = [
623            NamingRequest {
624                clip: &first,
625                lineage: &lineage,
626            },
627            NamingRequest {
628                clip: &second,
629                lineage: &lineage,
630            },
631        ];
632
633        let names = render_clip_names(&requests, &NamingConfig::default(), &BTreeSet::new());
634
635        assert_eq!(
636            names[0].relative_path.to_string_lossy(),
637            "München/Origin/München-Shared [11111111]"
638        );
639        assert_eq!(
640            names[1].relative_path.to_string_lossy(),
641            "München/Origin/München-Shared [22222222]"
642        );
643    }
644
645    #[test]
646    fn id8_prefix_collision_falls_back_to_full_id() {
647        // Custom template without {id8} so identical titles collide and the
648        // filename fallback (full id) has to keep them distinct.
649        let config = NamingConfig {
650            template: "{creator}/{title}".to_string(),
651            ..NamingConfig::default()
652        };
653        let first = test_clip("abcd1234-first", "Untitled");
654        let second = test_clip("abcd1234-second", "Untitled");
655
656        let names = render_all_own(&[first.clone(), second.clone()], &config, &BTreeSet::new());
657        let swapped = render_all_own(&[second.clone(), first.clone()], &config, &BTreeSet::new());
658
659        assert_ne!(
660            names[0].relative_path.to_string_lossy(),
661            names[1].relative_path.to_string_lossy()
662        );
663
664        let ordered = |rendered: &[RenderedName], clips: &[Clip]| {
665            clips
666                .iter()
667                .zip(rendered)
668                .map(|(clip, name)| {
669                    (
670                        clip.id.clone(),
671                        name.relative_path.to_string_lossy().into_owned(),
672                    )
673                })
674                .collect::<BTreeMap<_, _>>()
675        };
676        assert_eq!(
677            ordered(&names, &[first.clone(), second.clone()]),
678            ordered(&swapped, &[second, first])
679        );
680    }
681
682    #[test]
683    fn album_is_root_title_for_a_remix() {
684        let clip = Clip {
685            id: "child".to_string(),
686            title: "Remix".to_string(),
687            display_name: "München".to_string(),
688            ..Clip::default()
689        };
690        let lineage = LineageContext {
691            root_id: "root-1".to_string(),
692            root_title: "Original".to_string(),
693            root_date: String::new(),
694            parent_id: "root-1".to_string(),
695            edge_type: Some(EdgeType::Cover),
696            status: ResolveStatus::Resolved,
697        };
698
699        let rendered = render_clip_name(
700            NamingRequest {
701                clip: &clip,
702                lineage: &lineage,
703            },
704            &NamingConfig::default(),
705        );
706        assert_eq!(
707            rendered.relative_path.to_string_lossy(),
708            "München/Original/München-Remix [child]"
709        );
710    }
711
712    #[test]
713    fn overridden_album_drives_the_folder_path() {
714        // A LineageContext whose root_title carries a manual override (as the
715        // store produces it) folders the clip under the preferred album name.
716        let clip = Clip {
717            id: "child".to_string(),
718            title: "Remix".to_string(),
719            display_name: "München".to_string(),
720            ..Clip::default()
721        };
722        let lineage = LineageContext {
723            root_id: "root-1".to_string(),
724            root_title: "Preferred Album".to_string(),
725            root_date: String::new(),
726            parent_id: "root-1".to_string(),
727            edge_type: Some(EdgeType::Cover),
728            status: ResolveStatus::Resolved,
729        };
730
731        let rendered = render_clip_name(
732            NamingRequest {
733                clip: &clip,
734                lineage: &lineage,
735            },
736            &NamingConfig::default(),
737        );
738        assert_eq!(
739            rendered.relative_path.to_string_lossy(),
740            "München/Preferred Album/München-Remix [child]"
741        );
742    }
743
744    #[test]
745    fn album_is_own_title_for_a_root() {
746        let clip = Clip {
747            id: "root-1".to_string(),
748            title: "Original".to_string(),
749            display_name: "München".to_string(),
750            ..Clip::default()
751        };
752
753        let rendered = render_own(&clip, &NamingConfig::default());
754        assert_eq!(
755            rendered.relative_path.to_string_lossy(),
756            "München/Original/München-Original [root-1]"
757        );
758    }
759
760    #[test]
761    fn shared_album_title_from_distinct_roots_is_disambiguated() {
762        let first = Clip {
763            id: "aaaa1111-x".to_string(),
764            title: "Break Through".to_string(),
765            display_name: "München".to_string(),
766            ..Clip::default()
767        };
768        let second = Clip {
769            id: "bbbb2222-y".to_string(),
770            title: "Break Through".to_string(),
771            display_name: "München".to_string(),
772            ..Clip::default()
773        };
774
775        // The colliding set is authoritative (store-driven), so disambiguation
776        // does not depend on both roots appearing in the same batch.
777        let colliding: BTreeSet<String> = ["Break Through".to_string()].into_iter().collect();
778        let names = render_all_own(
779            &[first.clone(), second.clone()],
780            &NamingConfig::default(),
781            &colliding,
782        );
783        let swapped = render_all_own(
784            &[second.clone(), first.clone()],
785            &NamingConfig::default(),
786            &colliding,
787        );
788
789        let album_of = |rendered: &RenderedName| {
790            rendered
791                .relative_path
792                .components()
793                .nth(1)
794                .map(|component| component.as_os_str().to_string_lossy().into_owned())
795                .unwrap_or_default()
796        };
797
798        assert_eq!(album_of(&names[0]), "Break Through [aaaa1111]");
799        assert_eq!(album_of(&names[1]), "Break Through [bbbb2222]");
800        // Deterministic regardless of input order.
801        assert_eq!(album_of(&swapped[0]), "Break Through [bbbb2222]");
802        assert_eq!(album_of(&swapped[1]), "Break Through [aaaa1111]");
803
804        // The MEDIUM fix: a narrowed run showing only one of the two roots
805        // still gets the suffixed folder, so folders never oscillate.
806        let alone = render_all_own(
807            std::slice::from_ref(&first),
808            &NamingConfig::default(),
809            &colliding,
810        );
811        assert_eq!(album_of(&alone[0]), "Break Through [aaaa1111]");
812    }
813
814    #[test]
815    fn unique_root_title_stays_a_bare_album() {
816        // A title absent from the colliding set keeps its bare folder even when
817        // the batch happens to hold a same-titled sibling of the same root.
818        let clip = Clip {
819            id: "solo-1".to_string(),
820            title: "Solo".to_string(),
821            display_name: "München".to_string(),
822            ..Clip::default()
823        };
824        let names = render_all_own(&[clip], &NamingConfig::default(), &BTreeSet::new());
825        assert_eq!(
826            names[0].relative_path.to_string_lossy(),
827            "München/Solo/München-Solo [solo-1]"
828        );
829    }
830
831    #[test]
832    fn sanitise_name_strips_separators_and_falls_back_when_empty() {
833        assert_eq!(sanitise_name("Road/Trip: 2024"), "Road Trip 2024");
834        assert_eq!(sanitise_name(""), "playlist");
835        // A name made only of illegal characters strips to nothing, so the
836        // caller still gets a usable, non-empty stem.
837        assert_eq!(sanitise_name("///"), "playlist");
838    }
839
840    #[test]
841    fn stems_folder_is_a_sibling_suffix_of_the_song_base() {
842        assert_eq!(
843            stems_folder("Creator/Album/Creator-Song [abcd1234]"),
844            "Creator/Album/Creator-Song [abcd1234].stems"
845        );
846    }
847
848    #[test]
849    fn stem_file_path_combines_song_stem_label_and_disambiguator() {
850        let path = stem_file_path(
851            "Creator/Album/Creator-Song [abcd1234]",
852            "Vocals",
853            "stem-vocals-9f8e7d6c",
854            "mp3",
855            CharacterSet::Unicode,
856        );
857        assert_eq!(
858            path,
859            "Creator/Album/Creator-Song [abcd1234].stems/Creator-Song [abcd1234] - Vocals [stem-voc].mp3"
860        );
861    }
862
863    #[test]
864    fn stem_file_path_disambiguates_blank_and_duplicate_labels_by_id() {
865        // Two stems with the SAME (blank) label must not collide: the stem-id
866        // disambiguator keeps them distinct even with no usable label.
867        let a = stem_file_path("song", "", "id-aaaaaaaa", "wav", CharacterSet::Unicode);
868        let b = stem_file_path("song", "", "id-bbbbbbbb", "wav", CharacterSet::Unicode);
869        assert_eq!(a, "song.stems/song [id-aaaaa].wav");
870        assert_eq!(b, "song.stems/song [id-bbbbb].wav");
871        assert_ne!(a, b);
872    }
873
874    #[test]
875    fn stem_file_path_sanitises_label_and_extension_and_honours_ascii() {
876        // Illegal path characters in the label are stripped, the extension is
877        // reduced to a safe lowercase token, and ASCII folding applies.
878        let path = stem_file_path(
879            "song",
880            "Lead/Vocal: Æ",
881            "STEMID12",
882            ".FLAC",
883            CharacterSet::Ascii,
884        );
885        assert_eq!(path, "song.stems/song - Lead Vocal AE [STEMID12].flac");
886        // A junk extension falls back to mp3 (defensive; callers pass wav/mp3).
887        let fallback = stem_file_path("s", "Bass", "x", "??", CharacterSet::Unicode);
888        assert_eq!(fallback, "s.stems/s - Bass [x].mp3");
889    }
890}