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] = with_suffix(
120                rendered[index].clone(),
121                suffix,
122                config.character_set,
123                config.max_component_len,
124            );
125        }
126    }
127
128    rendered
129}
130
131/// The album path component for every request, with a clip whose root title
132/// collides across distinct roots disambiguated by `[{root_id8}]`.
133///
134/// Distinct roots must never share an album folder (two different upload roots
135/// titled "Break Through" exist). `colliding_albums` is the authoritative set
136/// of such shared root titles, computed once from the whole lineage store, so
137/// the decision is stable across runs and independent of which clips appear in
138/// this batch. A clip whose resolved album is in that set always gets its
139/// root's short id appended; every other clip keeps the bare album and groups
140/// with its same-root siblings.
141fn disambiguated_albums(
142    requests: &[NamingRequest<'_>],
143    config: &NamingConfig,
144    colliding_albums: &BTreeSet<String>,
145) -> Vec<String> {
146    requests
147        .iter()
148        .map(|request| album_for(*request, config, colliding_albums))
149        .collect()
150}
151
152/// The (possibly disambiguated) album component for one request.
153fn album_for(
154    request: NamingRequest<'_>,
155    config: &NamingConfig,
156    colliding_albums: &BTreeSet<String>,
157) -> String {
158    let raw_album = request.lineage.album(&title_name(request.clip));
159    let album = sanitise_component(&raw_album, config.character_set, config.max_component_len);
160    if colliding_albums.contains(raw_album.trim()) {
161        let suffix = truncate_chars(&request.lineage.root_id, 8);
162        append_suffix(
163            &album,
164            &suffix,
165            config.character_set,
166            config.max_component_len,
167        )
168    } else {
169        album
170    }
171}
172
173/// The sanitised album component: the resolved lineage album (root title, else
174/// the clip's own title).
175fn album_component(request: NamingRequest<'_>, config: &NamingConfig) -> String {
176    let album = request.lineage.album(&title_name(request.clip));
177    sanitise_component(&album, config.character_set, config.max_component_len)
178}
179
180/// Render one clip's path with an already-resolved album component.
181fn render_with_album(
182    request: NamingRequest<'_>,
183    config: &NamingConfig,
184    album: &str,
185) -> RenderedName {
186    let clip = request.clip;
187    let creator = sanitise_component(
188        &creator_name(clip),
189        config.character_set,
190        config.max_component_len,
191    );
192    let handle = sanitise_component(&clip.handle, config.character_set, config.max_component_len);
193    let title = sanitise_component(
194        &title_name(clip),
195        config.character_set,
196        config.max_component_len,
197    );
198    let id = sanitise_component(&clip.id, CharacterSet::Ascii, config.max_component_len);
199    let id8 = sanitise_component(
200        &truncate_chars(&clip.id, 8),
201        CharacterSet::Ascii,
202        config.max_component_len,
203    );
204    let root_id8 = sanitise_component(
205        &truncate_chars(&request.lineage.root_id, 8),
206        CharacterSet::Ascii,
207        config.max_component_len,
208    );
209    let mut components = config
210        .template
211        .split('/')
212        .filter_map(|segment| {
213            let rendered = segment
214                .replace("{creator}", &creator)
215                .replace("{handle}", &handle)
216                .replace("{album}", album)
217                .replace("{title}", &title)
218                .replace("{root_id8}", &root_id8)
219                .replace("{id8}", &id8)
220                .replace("{id}", &id);
221            let sanitised = sanitise_segment(
222                &rendered,
223                config.character_set,
224                config.max_component_len,
225                [id8.as_str(), root_id8.as_str()],
226            );
227            (!sanitised.is_empty()).then_some(sanitised)
228        })
229        .collect::<Vec<_>>();
230
231    if components.is_empty() {
232        components.push(title.clone());
233    }
234
235    let mut base_name = components
236        .pop()
237        .filter(|value| !value.is_empty())
238        .unwrap_or_else(|| title.clone());
239    // Guarantee a non-empty file name even when every token sanitises away.
240    if base_name.is_empty() {
241        base_name = append_suffix(
242            &base_name,
243            &clip.id,
244            config.character_set,
245            config.max_component_len,
246        );
247    }
248
249    let mut relative_path = PathBuf::new();
250    for component in components {
251        relative_path.push(component);
252    }
253
254    relative_path.push(&base_name);
255    RenderedName {
256        relative_path,
257        base_name,
258    }
259}
260
261fn with_suffix(
262    mut rendered: RenderedName,
263    suffix: &str,
264    character_set: CharacterSet,
265    max_component_len: usize,
266) -> RenderedName {
267    rendered.base_name = append_suffix(
268        &rendered.base_name,
269        suffix,
270        character_set,
271        max_component_len,
272    );
273    rendered.relative_path.set_file_name(&rendered.base_name);
274    rendered
275}
276
277fn creator_name(clip: &Clip) -> String {
278    non_blank(&clip.display_name)
279        .or_else(|| non_blank(&clip.handle))
280        .unwrap_or("Unknown Creator")
281        .to_string()
282}
283
284fn title_name(clip: &Clip) -> String {
285    let title = clip.title.trim();
286    if title.is_empty() || title.eq_ignore_ascii_case("untitled") {
287        "Untitled".to_string()
288    } else {
289        title.to_string()
290    }
291}
292
293fn append_suffix(
294    base: &str,
295    suffix: &str,
296    character_set: CharacterSet,
297    max_component_len: usize,
298) -> String {
299    let suffix_pattern = format!(" [{suffix}]");
300    if base.ends_with(&suffix_pattern) {
301        return sanitise_component(base, character_set, max_component_len);
302    }
303
304    let max_len =
305        max_component_len.max(suffix_pattern.chars().count() + MIN_BASE_CHARS_WITH_SUFFIX);
306    let allowed = max_len.saturating_sub(suffix_pattern.chars().count());
307    // Sanitise the base before measuring it. The character set can expand a
308    // character (ascii turns `ß` into `ss`), so budgeting the cut on the raw
309    // length could let the sanitised prefix grow back over the room reserved for
310    // the suffix and slice through it again (#120).
311    let base = sanitise_component(base, character_set, max_len);
312    let truncated = truncate_chars(base.trim_end(), allowed);
313    let combined = format!("{truncated}{suffix_pattern}");
314    sanitise_component(&combined, character_set, max_len)
315}
316
317/// Sanitise a rendered template segment, preserving a trailing ` [id]`
318/// disambiguator (the `[{id8}]` or `[{root_id8}]` the template embeds) when the
319/// segment would otherwise be truncated through it. Only the title portion is
320/// shortened, so two long-titled siblings keep their distinguishing id and the
321/// closing bracket is never left unbalanced (#120). A segment that does not end
322/// in a disambiguator is sanitised exactly as before.
323fn sanitise_segment(
324    rendered: &str,
325    character_set: CharacterSet,
326    max_component_len: usize,
327    disambiguators: [&str; 2],
328) -> String {
329    for suffix in disambiguators {
330        if suffix.is_empty() {
331            continue;
332        }
333        let pattern = format!(" [{suffix}]");
334        if let Some(prefix) = rendered.strip_suffix(&pattern) {
335            return append_suffix(prefix, suffix, character_set, max_component_len);
336        }
337    }
338    sanitise_component(rendered, character_set, max_component_len)
339}
340
341/// Sanitise a free-form playlist name into a single safe path component.
342///
343/// Applies the same Unicode filtering and length cap as clip path components
344/// (default [`CharacterSet::Unicode`], [`DEFAULT_MAX_COMPONENT_LEN`]), so a
345/// playlist file name obeys the same filesystem rules as the rest of the
346/// library. An empty or fully-stripped name falls back to `playlist` so the
347/// caller always has a non-empty stem to append `.m3u8` to.
348pub fn sanitise_name(name: &str) -> String {
349    let cleaned = sanitise_component(name, CharacterSet::Unicode, DEFAULT_MAX_COMPONENT_LEN);
350    if cleaned.is_empty() {
351        "playlist".to_string()
352    } else {
353        cleaned
354    }
355}
356
357/// The `.stems` sub-folder that sits beside a song's audio file.
358///
359/// `base` is the song's extensionless relative path (the same value the audio
360/// and its sidecars are built from), so the folder is `{base}.stems`. It cannot
361/// collide with the audio file (`{base}.<ext>`) or any `{base}.<sidecar>`
362/// because the `.stems` suffix is distinct, mirroring the sidecar convention.
363pub fn stems_folder(base: &str) -> String {
364    format!("{base}.stems")
365}
366
367/// The relative path of one stem file inside a song's [`stems_folder`].
368///
369/// Named base+label+disambiguation rather than label-only, because Auto Split
370/// can mislabel stems and Advanced Split yields ~100 instruments, so blank or
371/// duplicate labels are expected. The file is
372/// `{song file name} - {label} [{stem id8}].{ext}`; the ` - {label}` piece is
373/// dropped when the label sanitises to empty, and the `[{stem id8}]`
374/// disambiguator (the first 8 characters of the stable stem id) keeps blank or
375/// duplicate labels collision-free. Every component is run through the same
376/// [`sanitise_component`] filter as the rest of the library, honouring
377/// `character_set`.
378pub fn stem_file_path(
379    base: &str,
380    label: &str,
381    stem_id: &str,
382    ext: &str,
383    character_set: CharacterSet,
384) -> String {
385    let folder = stems_folder(base);
386    // The song's own file-name stem (the last path component of `base`), reused
387    // so a stem stays identifiable even when viewed outside its `.stems` folder.
388    let song_stem = base.rsplit('/').next().unwrap_or(base);
389    let label = sanitise_component(label, character_set, DEFAULT_MAX_COMPONENT_LEN);
390    let id8 = sanitise_component(
391        &truncate_chars(stem_id, 8),
392        CharacterSet::Ascii,
393        DEFAULT_MAX_COMPONENT_LEN,
394    );
395
396    let mut name = song_stem.to_string();
397    if !label.is_empty() {
398        name.push_str(" - ");
399        name.push_str(&label);
400    }
401    if !id8.is_empty() {
402        name.push_str(" [");
403        name.push_str(&id8);
404        name.push(']');
405    }
406    // A degenerate base (empty song stem, blank label, empty id) must still
407    // yield a usable name rather than a hidden dotfile.
408    if name.trim().is_empty() {
409        name = "stem".to_string();
410    }
411    format!("{folder}/{name}.{}", sanitise_ext(ext))
412}
413
414/// Reduce a candidate extension to a safe lowercase alphanumeric token,
415/// defaulting to `mp3` when it is empty or fully stripped. The caller passes the
416/// resolved stem format's extension (`wav` or `mp3`); stems are stored RAW.
417fn sanitise_ext(ext: &str) -> String {
418    let cleaned: String = ext
419        .trim_start_matches('.')
420        .chars()
421        .filter(|c| c.is_ascii_alphanumeric())
422        .flat_map(char::to_lowercase)
423        .take(8)
424        .collect();
425    if cleaned.is_empty() {
426        "mp3".to_string()
427    } else {
428        cleaned
429    }
430}
431
432fn sanitise_component(
433    value: &str,
434    character_set: CharacterSet,
435    max_component_len: usize,
436) -> String {
437    let filtered = match character_set {
438        CharacterSet::Unicode => value.chars().map(unicode_char).collect::<String>(),
439        CharacterSet::Ascii => value.chars().flat_map(ascii_chars).collect::<String>(),
440    };
441    let collapsed = filtered.split_whitespace().collect::<Vec<_>>().join(" ");
442    let trimmed = collapsed.trim_matches([' ', '.']);
443    if trimmed.is_empty() {
444        return String::new();
445    }
446
447    let mut result = truncate_chars(trimmed, max_component_len.max(1));
448    result = result.trim_matches([' ', '.']).to_string();
449    if result.is_empty() {
450        return String::new();
451    }
452    if result == "." || result == ".." {
453        return "item".to_string();
454    }
455    if !result.ends_with('_') && is_reserved_name(&result) {
456        result.push('_');
457    }
458    result
459}
460
461fn unicode_char(ch: char) -> char {
462    if matches!(
463        ch,
464        '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0'
465    ) || ch.is_control()
466    {
467        ' '
468    } else {
469        ch
470    }
471}
472
473fn ascii_chars(ch: char) -> Vec<char> {
474    if ch.is_ascii() {
475        return vec![unicode_char(ch)];
476    }
477
478    match ch {
479        'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => vec!['A'],
480        'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => vec!['a'],
481        'Ç' => vec!['C'],
482        'ç' => vec!['c'],
483        'È' | 'É' | 'Ê' | 'Ë' => vec!['E'],
484        'è' | 'é' | 'ê' | 'ë' => vec!['e'],
485        'Ì' | 'Í' | 'Î' | 'Ï' => vec!['I'],
486        'ì' | 'í' | 'î' | 'ï' => vec!['i'],
487        'Ñ' => vec!['N'],
488        'ñ' => vec!['n'],
489        'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => vec!['O'],
490        'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' => vec!['o'],
491        'Ù' | 'Ú' | 'Û' | 'Ü' => vec!['U'],
492        'ù' | 'ú' | 'û' | 'ü' => vec!['u'],
493        'Ý' | 'Ÿ' => vec!['Y'],
494        'ý' | 'ÿ' => vec!['y'],
495        'Æ' => vec!['A', 'E'],
496        'æ' => vec!['a', 'e'],
497        'Œ' => vec!['O', 'E'],
498        'œ' => vec!['o', 'e'],
499        'ß' => vec!['s', 's'],
500        _ => vec![' '],
501    }
502}
503
504fn truncate_chars(value: &str, max_len: usize) -> String {
505    value.chars().take(max_len).collect()
506}
507
508fn non_blank(value: &str) -> Option<&str> {
509    let trimmed = value.trim();
510    (!trimmed.is_empty()).then_some(trimmed)
511}
512
513fn is_reserved_name(value: &str) -> bool {
514    let stem = value.split('.').next().unwrap_or(value);
515    matches!(
516        stem.to_ascii_uppercase().as_str(),
517        "CON"
518            | "PRN"
519            | "AUX"
520            | "NUL"
521            | "COM1"
522            | "COM2"
523            | "COM3"
524            | "COM4"
525            | "COM5"
526            | "COM6"
527            | "COM7"
528            | "COM8"
529            | "COM9"
530            | "LPT1"
531            | "LPT2"
532            | "LPT3"
533            | "LPT4"
534            | "LPT5"
535            | "LPT6"
536            | "LPT7"
537            | "LPT8"
538            | "LPT9"
539    )
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use crate::lineage::{EdgeType, ResolveStatus};
546    use std::collections::{BTreeMap, BTreeSet};
547
548    fn test_clip(id: &str, title: &str) -> Clip {
549        Clip {
550            id: id.to_string(),
551            title: title.to_string(),
552            display_name: "München".to_string(),
553            handle: "munchen".to_string(),
554            album_title: String::new(),
555            root_ancestor_id: String::new(),
556            ..Clip::default()
557        }
558    }
559
560    fn render_own(clip: &Clip, config: &NamingConfig) -> RenderedName {
561        let lineage = LineageContext::own_root(clip);
562        render_clip_name(
563            NamingRequest {
564                clip,
565                lineage: &lineage,
566            },
567            config,
568        )
569    }
570
571    fn render_all_own(
572        clips: &[Clip],
573        config: &NamingConfig,
574        colliding: &BTreeSet<String>,
575    ) -> Vec<RenderedName> {
576        let lineages: Vec<LineageContext> = clips.iter().map(LineageContext::own_root).collect();
577        let requests: Vec<NamingRequest> = clips
578            .iter()
579            .zip(&lineages)
580            .map(|(clip, lineage)| NamingRequest { clip, lineage })
581            .collect();
582        render_clip_names(&requests, config, colliding)
583    }
584
585    #[test]
586    fn unicode_names_are_preserved_and_ascii_falls_back() {
587        let clip = test_clip("abc12345", "Beyoncé/東京");
588
589        let unicode = render_own(&clip, &NamingConfig::default());
590        assert_eq!(
591            unicode.relative_path.to_string_lossy(),
592            "München/Beyoncé 東京/München-Beyoncé 東京 [abc12345]"
593        );
594
595        let ascii = render_own(
596            &clip,
597            &NamingConfig {
598                character_set: CharacterSet::Ascii,
599                ..NamingConfig::default()
600            },
601        );
602        assert_eq!(
603            ascii.relative_path.to_string_lossy(),
604            "Munchen/Beyonce/Munchen-Beyonce [abc12345]"
605        );
606    }
607
608    #[test]
609    fn reserved_and_hostile_names_are_sanitised() {
610        let clip = Clip {
611            id: "deadbeef".to_string(),
612            title: "CON<>:\"/\\|?*.".to_string(),
613            display_name: "AUX".to_string(),
614            ..Clip::default()
615        };
616
617        let rendered = render_own(&clip, &NamingConfig::default());
618        let path = rendered.relative_path.to_string_lossy();
619        assert!(path.starts_with("AUX_/CON_/"), "path was {path}");
620        assert!(rendered.base_name.contains("[deadbeef]"));
621    }
622
623    #[test]
624    fn default_template_always_embeds_id8() {
625        let clip = test_clip("abcdef1234567890", "Any Title");
626        let rendered = render_own(&clip, &NamingConfig::default());
627        assert!(
628            rendered.base_name.contains("[abcdef12]"),
629            "base_name was {}",
630            rendered.base_name
631        );
632    }
633
634    #[test]
635    fn blank_titles_use_a_stable_suffix() {
636        let clip = test_clip("12345678-clip", "   ");
637
638        let rendered = render_own(&clip, &NamingConfig::default());
639        assert_eq!(rendered.base_name, "München-Untitled [12345678]");
640        assert_eq!(
641            rendered.relative_path.to_string_lossy(),
642            "München/Untitled/München-Untitled [12345678]"
643        );
644    }
645
646    #[test]
647    fn very_long_titles_are_trimmed() {
648        let clip = test_clip("abcdef12", &"a".repeat(120));
649        let rendered = render_own(
650            &clip,
651            &NamingConfig {
652                max_component_len: 24,
653                ..NamingConfig::default()
654            },
655        );
656
657        for component in rendered.relative_path.components() {
658            let text = component.as_os_str().to_string_lossy();
659            assert!(
660                text.chars().count() <= 24,
661                "component {text:?} exceeds 24 chars"
662            );
663        }
664        // The trailing [id8] must survive the truncation intact (#120).
665        assert!(
666            rendered.base_name.ends_with(" [abcdef12]"),
667            "id8 disambiguator was sliced; base_name was {:?}",
668            rendered.base_name
669        );
670    }
671
672    #[test]
673    fn long_names_keep_the_full_id8_disambiguator() {
674        // A creator+title long enough to overflow the cap keeps the whole
675        // trailing [id8]: the title is shortened, not the id, so the name stays
676        // complete and the bracket stays balanced (#120).
677        let clip = test_clip("1234abcd-tail", &"a".repeat(120));
678        let config = NamingConfig {
679            max_component_len: 40,
680            ..NamingConfig::default()
681        };
682        let rendered = render_own(&clip, &config);
683
684        assert!(
685            rendered.base_name.ends_with(" [1234abcd]"),
686            "base_name must end with the full disambiguator, was {:?}",
687            rendered.base_name
688        );
689        assert_eq!(rendered.base_name.chars().count(), 40);
690    }
691
692    #[test]
693    fn long_titled_siblings_stay_distinct_with_balanced_brackets() {
694        // Two same-(long-)titled clips sharing a root must remain distinct: only
695        // the title is shortened, so their [id8] suffixes differ and neither name
696        // ends up with an unbalanced bracket (#120).
697        let lineage = LineageContext {
698            root_id: "root-42".to_string(),
699            root_title: "Origin".to_string(),
700            root_date: String::new(),
701            parent_id: "root-42".to_string(),
702            edge_type: Some(EdgeType::Cover),
703            status: ResolveStatus::Resolved,
704        };
705        let title = "z".repeat(200);
706        let first = test_clip("aaaa1111-x", &title);
707        let second = test_clip("bbbb2222-y", &title);
708        let requests = [
709            NamingRequest {
710                clip: &first,
711                lineage: &lineage,
712            },
713            NamingRequest {
714                clip: &second,
715                lineage: &lineage,
716            },
717        ];
718
719        let names = render_clip_names(&requests, &NamingConfig::default(), &BTreeSet::new());
720
721        assert!(names[0].base_name.ends_with(" [aaaa1111]"));
722        assert!(names[1].base_name.ends_with(" [bbbb2222]"));
723        assert_ne!(names[0].relative_path, names[1].relative_path);
724        for name in &names {
725            assert!(name.base_name.chars().count() <= 80);
726            assert_eq!(name.base_name.matches('[').count(), 1, "unbalanced '['");
727            assert_eq!(name.base_name.matches(']').count(), 1, "unbalanced ']'");
728        }
729    }
730
731    #[test]
732    fn long_colliding_album_keeps_its_root_id8() {
733        // The album [root_id8] disambiguator is preserved when a long album title
734        // must be truncated, mirroring the file-name fix (#120).
735        let long = "Break Through ".repeat(20);
736        let title = long.trim().to_string();
737        let clip = Clip {
738            id: "aaaa1111-x".to_string(),
739            title: title.clone(),
740            display_name: "München".to_string(),
741            ..Clip::default()
742        };
743        let colliding: BTreeSet<String> = [title].into_iter().collect();
744        let names = render_all_own(&[clip], &NamingConfig::default(), &colliding);
745
746        let album = names[0]
747            .relative_path
748            .components()
749            .nth(1)
750            .map(|component| component.as_os_str().to_string_lossy().into_owned())
751            .unwrap_or_default();
752        assert!(album.ends_with(" [aaaa1111]"), "album was {album:?}");
753        assert!(album.chars().count() <= 80);
754    }
755
756    #[test]
757    fn ascii_expanding_chars_do_not_slice_the_disambiguator() {
758        // A literal expanding character (`ß` -> `ss` under ascii) in a custom
759        // template, right before the trailing ` [{id8}]`, must not grow back over
760        // the suffix and slice it: the base is sized after expansion (#120).
761        let clip = test_clip("1234abcd", "Title");
762        let config = NamingConfig {
763            template: format!("{}{{title}} [{{id8}}]", "ß".repeat(80)),
764            character_set: CharacterSet::Ascii,
765            max_component_len: 40,
766        };
767        let rendered = render_own(&clip, &config);
768
769        assert!(
770            rendered.base_name.ends_with(" [1234abcd]"),
771            "expansion sliced the id8; base_name was {:?}",
772            rendered.base_name
773        );
774        assert!(rendered.base_name.chars().count() <= 40);
775    }
776
777    #[test]
778    fn same_title_siblings_stay_distinct_via_id8() {
779        // Two clips sharing a root (same album folder) and the same title must
780        // still land on distinct files; the default template's {id8} does that.
781        let lineage = LineageContext {
782            root_id: "root-9".to_string(),
783            root_title: "Origin".to_string(),
784            root_date: String::new(),
785            parent_id: "root-9".to_string(),
786            edge_type: Some(EdgeType::Cover),
787            status: ResolveStatus::Resolved,
788        };
789        let first = test_clip("11111111-alpha", "Shared");
790        let second = test_clip("22222222-beta", "Shared");
791        let requests = [
792            NamingRequest {
793                clip: &first,
794                lineage: &lineage,
795            },
796            NamingRequest {
797                clip: &second,
798                lineage: &lineage,
799            },
800        ];
801
802        let names = render_clip_names(&requests, &NamingConfig::default(), &BTreeSet::new());
803
804        assert_eq!(
805            names[0].relative_path.to_string_lossy(),
806            "München/Origin/München-Shared [11111111]"
807        );
808        assert_eq!(
809            names[1].relative_path.to_string_lossy(),
810            "München/Origin/München-Shared [22222222]"
811        );
812    }
813
814    #[test]
815    fn id8_prefix_collision_falls_back_to_full_id() {
816        // Custom template without {id8} so identical titles collide and the
817        // filename fallback (full id) has to keep them distinct.
818        let config = NamingConfig {
819            template: "{creator}/{title}".to_string(),
820            ..NamingConfig::default()
821        };
822        let first = test_clip("abcd1234-first", "Untitled");
823        let second = test_clip("abcd1234-second", "Untitled");
824
825        let names = render_all_own(&[first.clone(), second.clone()], &config, &BTreeSet::new());
826        let swapped = render_all_own(&[second.clone(), first.clone()], &config, &BTreeSet::new());
827
828        assert_ne!(
829            names[0].relative_path.to_string_lossy(),
830            names[1].relative_path.to_string_lossy()
831        );
832
833        let ordered = |rendered: &[RenderedName], clips: &[Clip]| {
834            clips
835                .iter()
836                .zip(rendered)
837                .map(|(clip, name)| {
838                    (
839                        clip.id.clone(),
840                        name.relative_path.to_string_lossy().into_owned(),
841                    )
842                })
843                .collect::<BTreeMap<_, _>>()
844        };
845        assert_eq!(
846            ordered(&names, &[first.clone(), second.clone()]),
847            ordered(&swapped, &[second, first])
848        );
849    }
850
851    #[test]
852    fn album_is_root_title_for_a_remix() {
853        let clip = Clip {
854            id: "child".to_string(),
855            title: "Remix".to_string(),
856            display_name: "München".to_string(),
857            ..Clip::default()
858        };
859        let lineage = LineageContext {
860            root_id: "root-1".to_string(),
861            root_title: "Original".to_string(),
862            root_date: String::new(),
863            parent_id: "root-1".to_string(),
864            edge_type: Some(EdgeType::Cover),
865            status: ResolveStatus::Resolved,
866        };
867
868        let rendered = render_clip_name(
869            NamingRequest {
870                clip: &clip,
871                lineage: &lineage,
872            },
873            &NamingConfig::default(),
874        );
875        assert_eq!(
876            rendered.relative_path.to_string_lossy(),
877            "München/Original/München-Remix [child]"
878        );
879    }
880
881    #[test]
882    fn overridden_album_drives_the_folder_path() {
883        // A LineageContext whose root_title carries a manual override (as the
884        // store produces it) folders the clip under the preferred album name.
885        let clip = Clip {
886            id: "child".to_string(),
887            title: "Remix".to_string(),
888            display_name: "München".to_string(),
889            ..Clip::default()
890        };
891        let lineage = LineageContext {
892            root_id: "root-1".to_string(),
893            root_title: "Preferred Album".to_string(),
894            root_date: String::new(),
895            parent_id: "root-1".to_string(),
896            edge_type: Some(EdgeType::Cover),
897            status: ResolveStatus::Resolved,
898        };
899
900        let rendered = render_clip_name(
901            NamingRequest {
902                clip: &clip,
903                lineage: &lineage,
904            },
905            &NamingConfig::default(),
906        );
907        assert_eq!(
908            rendered.relative_path.to_string_lossy(),
909            "München/Preferred Album/München-Remix [child]"
910        );
911    }
912
913    #[test]
914    fn album_is_own_title_for_a_root() {
915        let clip = Clip {
916            id: "root-1".to_string(),
917            title: "Original".to_string(),
918            display_name: "München".to_string(),
919            ..Clip::default()
920        };
921
922        let rendered = render_own(&clip, &NamingConfig::default());
923        assert_eq!(
924            rendered.relative_path.to_string_lossy(),
925            "München/Original/München-Original [root-1]"
926        );
927    }
928
929    #[test]
930    fn shared_album_title_from_distinct_roots_is_disambiguated() {
931        let first = Clip {
932            id: "aaaa1111-x".to_string(),
933            title: "Break Through".to_string(),
934            display_name: "München".to_string(),
935            ..Clip::default()
936        };
937        let second = Clip {
938            id: "bbbb2222-y".to_string(),
939            title: "Break Through".to_string(),
940            display_name: "München".to_string(),
941            ..Clip::default()
942        };
943
944        // The colliding set is authoritative (store-driven), so disambiguation
945        // does not depend on both roots appearing in the same batch.
946        let colliding: BTreeSet<String> = ["Break Through".to_string()].into_iter().collect();
947        let names = render_all_own(
948            &[first.clone(), second.clone()],
949            &NamingConfig::default(),
950            &colliding,
951        );
952        let swapped = render_all_own(
953            &[second.clone(), first.clone()],
954            &NamingConfig::default(),
955            &colliding,
956        );
957
958        let album_of = |rendered: &RenderedName| {
959            rendered
960                .relative_path
961                .components()
962                .nth(1)
963                .map(|component| component.as_os_str().to_string_lossy().into_owned())
964                .unwrap_or_default()
965        };
966
967        assert_eq!(album_of(&names[0]), "Break Through [aaaa1111]");
968        assert_eq!(album_of(&names[1]), "Break Through [bbbb2222]");
969        // Deterministic regardless of input order.
970        assert_eq!(album_of(&swapped[0]), "Break Through [bbbb2222]");
971        assert_eq!(album_of(&swapped[1]), "Break Through [aaaa1111]");
972
973        // The MEDIUM fix: a narrowed run showing only one of the two roots
974        // still gets the suffixed folder, so folders never oscillate.
975        let alone = render_all_own(
976            std::slice::from_ref(&first),
977            &NamingConfig::default(),
978            &colliding,
979        );
980        assert_eq!(album_of(&alone[0]), "Break Through [aaaa1111]");
981    }
982
983    #[test]
984    fn unique_root_title_stays_a_bare_album() {
985        // A title absent from the colliding set keeps its bare folder even when
986        // the batch happens to hold a same-titled sibling of the same root.
987        let clip = Clip {
988            id: "solo-1".to_string(),
989            title: "Solo".to_string(),
990            display_name: "München".to_string(),
991            ..Clip::default()
992        };
993        let names = render_all_own(&[clip], &NamingConfig::default(), &BTreeSet::new());
994        assert_eq!(
995            names[0].relative_path.to_string_lossy(),
996            "München/Solo/München-Solo [solo-1]"
997        );
998    }
999
1000    #[test]
1001    fn sanitise_name_strips_separators_and_falls_back_when_empty() {
1002        assert_eq!(sanitise_name("Road/Trip: 2024"), "Road Trip 2024");
1003        assert_eq!(sanitise_name(""), "playlist");
1004        // A name made only of illegal characters strips to nothing, so the
1005        // caller still gets a usable, non-empty stem.
1006        assert_eq!(sanitise_name("///"), "playlist");
1007    }
1008
1009    #[test]
1010    fn stems_folder_is_a_sibling_suffix_of_the_song_base() {
1011        assert_eq!(
1012            stems_folder("Creator/Album/Creator-Song [abcd1234]"),
1013            "Creator/Album/Creator-Song [abcd1234].stems"
1014        );
1015    }
1016
1017    #[test]
1018    fn stem_file_path_combines_song_stem_label_and_disambiguator() {
1019        let path = stem_file_path(
1020            "Creator/Album/Creator-Song [abcd1234]",
1021            "Vocals",
1022            "stem-vocals-9f8e7d6c",
1023            "mp3",
1024            CharacterSet::Unicode,
1025        );
1026        assert_eq!(
1027            path,
1028            "Creator/Album/Creator-Song [abcd1234].stems/Creator-Song [abcd1234] - Vocals [stem-voc].mp3"
1029        );
1030    }
1031
1032    #[test]
1033    fn stem_file_path_disambiguates_blank_and_duplicate_labels_by_id() {
1034        // Two stems with the SAME (blank) label must not collide: the stem-id
1035        // disambiguator keeps them distinct even with no usable label.
1036        let a = stem_file_path("song", "", "id-aaaaaaaa", "wav", CharacterSet::Unicode);
1037        let b = stem_file_path("song", "", "id-bbbbbbbb", "wav", CharacterSet::Unicode);
1038        assert_eq!(a, "song.stems/song [id-aaaaa].wav");
1039        assert_eq!(b, "song.stems/song [id-bbbbb].wav");
1040        assert_ne!(a, b);
1041    }
1042
1043    #[test]
1044    fn stem_file_path_sanitises_label_and_extension_and_honours_ascii() {
1045        // Illegal path characters in the label are stripped, the extension is
1046        // reduced to a safe lowercase token, and ASCII folding applies.
1047        let path = stem_file_path(
1048            "song",
1049            "Lead/Vocal: Æ",
1050            "STEMID12",
1051            ".FLAC",
1052            CharacterSet::Ascii,
1053        );
1054        assert_eq!(path, "song.stems/song - Lead Vocal AE [STEMID12].flac");
1055        // A junk extension falls back to mp3 (defensive; callers pass wav/mp3).
1056        let fallback = stem_file_path("s", "Bass", "x", "??", CharacterSet::Unicode);
1057        assert_eq!(fallback, "s.stems/s - Bass [x].mp3");
1058    }
1059}