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;
13use crate::pathkey::canonical_path_key;
14
15/// The default relative path template.
16///
17/// Supported placeholders are `{creator}`, `{handle}`, `{album}`, `{title}`,
18/// `{id}`, `{id8}` (first 8 characters of the clip id), and `{root_id8}`
19/// (first 8 of the resolved lineage root id). Empty path segments are dropped
20/// after rendering.
21///
22/// The default embeds `[{id8}]` in the file name so same-title clips never
23/// collide, and folders under `{album}`, which resolves to the lineage root's
24/// title (else the clip's own title).
25pub const DEFAULT_TEMPLATE: &str = "{creator}/{album}/{creator}-{title} [{id8}]";
26const DEFAULT_MAX_COMPONENT_LEN: usize = 80;
27
28const MIN_BASE_CHARS_WITH_SUFFIX: usize = 1;
29
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum CharacterSet {
33    #[default]
34    Unicode,
35    Ascii,
36}
37
38impl FromStr for CharacterSet {
39    type Err = Error;
40
41    fn from_str(s: &str) -> Result<Self> {
42        match s.to_ascii_lowercase().as_str() {
43            "unicode" => Ok(Self::Unicode),
44            "ascii" => Ok(Self::Ascii),
45            other => Err(Error::Config(format!(
46                "unknown character_set '{other}'; expected 'unicode' or 'ascii'"
47            ))),
48        }
49    }
50}
51
52impl fmt::Display for CharacterSet {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            Self::Unicode => f.write_str("unicode"),
56            Self::Ascii => f.write_str("ascii"),
57        }
58    }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct NamingConfig {
63    pub template: String,
64    pub character_set: CharacterSet,
65    pub max_component_len: usize,
66}
67
68impl Default for NamingConfig {
69    fn default() -> Self {
70        Self {
71            template: DEFAULT_TEMPLATE.to_string(),
72            character_set: CharacterSet::Unicode,
73            max_component_len: DEFAULT_MAX_COMPONENT_LEN,
74        }
75    }
76}
77
78#[derive(Debug, Clone, Copy)]
79pub struct NamingRequest<'a> {
80    pub clip: &'a Clip,
81    pub lineage: &'a LineageContext,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct RenderedName {
86    pub relative_path: PathBuf,
87    pub base_name: String,
88}
89
90pub fn render_clip_name(request: NamingRequest<'_>, config: &NamingConfig) -> RenderedName {
91    let album = album_component(request, config);
92    render_with_album(request, config, &album)
93}
94
95pub fn render_clip_names(
96    requests: &[NamingRequest<'_>],
97    config: &NamingConfig,
98    colliding_albums: &BTreeSet<String>,
99) -> Vec<RenderedName> {
100    let albums = disambiguated_albums(requests, config, colliding_albums);
101    let mut rendered = requests
102        .iter()
103        .zip(&albums)
104        .map(|(request, album)| render_with_album(*request, config, album))
105        .collect::<Vec<_>>();
106
107    // Two passes to keep distinct clips from landing on one path.  The first
108    // pass keys on the exact rendered string; the second on the filesystem-
109    // canonical form (NFC + lowercase) so that paths differing only by case or
110    // Unicode normalisation (NFD vs NFC) are caught too — they would collide on
111    // case-insensitive or NFC-normalising filesystems (Windows, macOS default).
112    for apply_canonical in [false, true] {
113        let mut collisions = BTreeMap::<String, Vec<usize>>::new();
114        for (index, name) in rendered.iter().enumerate() {
115            let key = if apply_canonical {
116                canonical_path_key(&name.relative_path.to_string_lossy())
117            } else {
118                name.relative_path.to_string_lossy().into_owned()
119            };
120            collisions.entry(key).or_default().push(index);
121        }
122        for indexes in collisions.into_values().filter(|v| v.len() > 1) {
123            for index in indexes {
124                let suffix = &requests[index].clip.id;
125                rendered[index] = with_suffix(
126                    rendered[index].clone(),
127                    suffix,
128                    config.character_set,
129                    config.max_component_len,
130                );
131            }
132        }
133    }
134
135    rendered
136}
137
138/// The album path component for every request, with a clip whose root title
139/// collides across distinct roots disambiguated by `[{root_id8}]`.
140///
141/// Distinct roots must never share an album folder (two different upload roots
142/// titled "Break Through" exist). `colliding_albums` is the authoritative set
143/// of such shared root titles, computed once from the whole lineage store, so
144/// the decision is stable across runs and independent of which clips appear in
145/// this batch. A clip whose resolved album is in that set always gets its
146/// root's short id appended; every other clip keeps the bare album and groups
147/// with its same-root siblings.
148fn disambiguated_albums(
149    requests: &[NamingRequest<'_>],
150    config: &NamingConfig,
151    colliding_albums: &BTreeSet<String>,
152) -> Vec<String> {
153    requests
154        .iter()
155        .map(|request| album_for(*request, config, colliding_albums))
156        .collect()
157}
158
159/// The (possibly disambiguated) album component for one request.
160fn album_for(
161    request: NamingRequest<'_>,
162    config: &NamingConfig,
163    colliding_albums: &BTreeSet<String>,
164) -> String {
165    let raw_album = request.lineage.album(&title_name(request.clip));
166    let album = sanitise_component(&raw_album, config.character_set, config.max_component_len);
167    if colliding_albums.contains(raw_album.trim()) {
168        let suffix = truncate_chars(&request.lineage.root_id, 8);
169        append_suffix(
170            &album,
171            &suffix,
172            config.character_set,
173            config.max_component_len,
174        )
175    } else {
176        album
177    }
178}
179
180/// The sanitised album component: the resolved lineage album (root title, else
181/// the clip's own title).
182fn album_component(request: NamingRequest<'_>, config: &NamingConfig) -> String {
183    let album = request.lineage.album(&title_name(request.clip));
184    sanitise_component(&album, config.character_set, config.max_component_len)
185}
186
187/// Render one clip's path with an already-resolved album component.
188fn render_with_album(
189    request: NamingRequest<'_>,
190    config: &NamingConfig,
191    album: &str,
192) -> RenderedName {
193    let clip = request.clip;
194    let creator = sanitise_component(
195        &creator_name(clip),
196        config.character_set,
197        config.max_component_len,
198    );
199    let handle = sanitise_component(&clip.handle, config.character_set, config.max_component_len);
200    let title = sanitise_component(
201        &title_name(clip),
202        config.character_set,
203        config.max_component_len,
204    );
205    let id = sanitise_component(&clip.id, CharacterSet::Ascii, config.max_component_len);
206    let id8 = sanitise_component(
207        &truncate_chars(&clip.id, 8),
208        CharacterSet::Ascii,
209        config.max_component_len,
210    );
211    let root_id8 = sanitise_component(
212        &truncate_chars(&request.lineage.root_id, 8),
213        CharacterSet::Ascii,
214        config.max_component_len,
215    );
216    let substitutions = SegmentSubstitutions {
217        creator: &creator,
218        handle: &handle,
219        album,
220        title: &title,
221        root_id8: &root_id8,
222        id8: &id8,
223        id: &id,
224    };
225    let mut components = config
226        .template
227        .split('/')
228        .filter_map(|segment| {
229            let rendered = substitute_segment(segment, substitutions);
230            let sanitised = sanitise_segment(
231                &rendered,
232                config.character_set,
233                config.max_component_len,
234                [id8.as_str(), root_id8.as_str()],
235            );
236            (!sanitised.is_empty()).then_some(sanitised)
237        })
238        .collect::<Vec<_>>();
239
240    if components.is_empty() {
241        components.push(title.clone());
242    }
243
244    let mut base_name = components
245        .pop()
246        .filter(|value| !value.is_empty())
247        .unwrap_or_else(|| title.clone());
248    // Guarantee a non-empty file name even when every token sanitises away.
249    if base_name.is_empty() {
250        base_name = append_suffix(
251            &base_name,
252            &clip.id,
253            config.character_set,
254            config.max_component_len,
255        );
256    }
257
258    let mut relative_path = PathBuf::new();
259    for component in components {
260        relative_path.push(component);
261    }
262
263    relative_path.push(&base_name);
264    RenderedName {
265        relative_path,
266        base_name,
267    }
268}
269
270#[derive(Clone, Copy)]
271struct SegmentSubstitutions<'a> {
272    creator: &'a str,
273    handle: &'a str,
274    album: &'a str,
275    title: &'a str,
276    root_id8: &'a str,
277    id8: &'a str,
278    id: &'a str,
279}
280
281fn substitute_segment(segment: &str, substitutions: SegmentSubstitutions<'_>) -> String {
282    let mut rendered = String::with_capacity(segment.len());
283    let mut remainder = segment;
284    while let Some(start) = remainder.find('{') {
285        rendered.push_str(&remainder[..start]);
286        remainder = &remainder[start..];
287        if let Some((token_len, value)) = placeholder_match(remainder, substitutions) {
288            rendered.push_str(value);
289            remainder = &remainder[token_len..];
290        } else {
291            rendered.push('{');
292            remainder = &remainder[1..];
293        }
294    }
295    rendered.push_str(remainder);
296    rendered
297}
298
299fn placeholder_match<'a>(
300    segment: &str,
301    substitutions: SegmentSubstitutions<'a>,
302) -> Option<(usize, &'a str)> {
303    if segment.starts_with("{creator}") {
304        Some(("{creator}".len(), substitutions.creator))
305    } else if segment.starts_with("{handle}") {
306        Some(("{handle}".len(), substitutions.handle))
307    } else if segment.starts_with("{album}") {
308        Some(("{album}".len(), substitutions.album))
309    } else if segment.starts_with("{title}") {
310        Some(("{title}".len(), substitutions.title))
311    } else if segment.starts_with("{root_id8}") {
312        Some(("{root_id8}".len(), substitutions.root_id8))
313    } else if segment.starts_with("{id8}") {
314        Some(("{id8}".len(), substitutions.id8))
315    } else if segment.starts_with("{id}") {
316        Some(("{id}".len(), substitutions.id))
317    } else {
318        None
319    }
320}
321
322fn with_suffix(
323    mut rendered: RenderedName,
324    suffix: &str,
325    character_set: CharacterSet,
326    max_component_len: usize,
327) -> RenderedName {
328    rendered.base_name = append_suffix(
329        &rendered.base_name,
330        suffix,
331        character_set,
332        max_component_len,
333    );
334    rendered.relative_path.set_file_name(&rendered.base_name);
335    rendered
336}
337
338fn creator_name(clip: &Clip) -> String {
339    non_blank(&clip.display_name)
340        .or_else(|| non_blank(&clip.handle))
341        .unwrap_or("Unknown Creator")
342        .to_string()
343}
344
345fn title_name(clip: &Clip) -> String {
346    let title = clip.title.trim();
347    if title.is_empty() || title.eq_ignore_ascii_case("untitled") {
348        "Untitled".to_string()
349    } else {
350        title.to_string()
351    }
352}
353
354fn append_suffix(
355    base: &str,
356    suffix: &str,
357    character_set: CharacterSet,
358    max_component_len: usize,
359) -> String {
360    let suffix_pattern = format!(" [{suffix}]");
361    if base.ends_with(&suffix_pattern) {
362        return sanitise_component(base, character_set, max_component_len);
363    }
364
365    let max_len =
366        max_component_len.max(suffix_pattern.chars().count() + MIN_BASE_CHARS_WITH_SUFFIX);
367    let allowed = max_len.saturating_sub(suffix_pattern.chars().count());
368    // Sanitise the base before measuring it. The character set can expand a
369    // character (ascii turns `ß` into `ss`), so budgeting the cut on the raw
370    // length could let the sanitised prefix grow back over the room reserved for
371    // the suffix and slice through it again (#120).
372    let base = sanitise_component(base, character_set, max_len);
373    let truncated = truncate_chars(base.trim_end(), allowed);
374    let combined = format!("{truncated}{suffix_pattern}");
375    sanitise_component(&combined, character_set, max_len)
376}
377
378/// Sanitise a rendered template segment, preserving a trailing ` [id]`
379/// disambiguator (the `[{id8}]` or `[{root_id8}]` the template embeds) when the
380/// segment would otherwise be truncated through it. Only the title portion is
381/// shortened, so two long-titled siblings keep their distinguishing id and the
382/// closing bracket is never left unbalanced (#120). A segment that does not end
383/// in a disambiguator is sanitised exactly as before.
384fn sanitise_segment(
385    rendered: &str,
386    character_set: CharacterSet,
387    max_component_len: usize,
388    disambiguators: [&str; 2],
389) -> String {
390    for suffix in disambiguators {
391        if suffix.is_empty() {
392            continue;
393        }
394        let pattern = format!(" [{suffix}]");
395        if let Some(prefix) = rendered.strip_suffix(&pattern) {
396            return append_suffix(prefix, suffix, character_set, max_component_len);
397        }
398    }
399    sanitise_component(rendered, character_set, max_component_len)
400}
401
402/// Sanitise a free-form playlist name into a single safe path component.
403///
404/// Applies the same Unicode filtering and length cap as clip path components
405/// (default [`CharacterSet::Unicode`], [`DEFAULT_MAX_COMPONENT_LEN`]), so a
406/// playlist file name obeys the same filesystem rules as the rest of the
407/// library. An empty or fully-stripped name falls back to `playlist` so the
408/// caller always has a non-empty stem to append `.m3u8` to.
409pub fn sanitise_name(name: &str) -> String {
410    let cleaned = sanitise_component(name, CharacterSet::Unicode, DEFAULT_MAX_COMPONENT_LEN);
411    if cleaned.is_empty() {
412        "playlist".to_string()
413    } else {
414        cleaned
415    }
416}
417
418/// The `.stems` sub-folder that sits beside a song's audio file.
419///
420/// `base` is the song's extensionless relative path (the same value the audio
421/// and its sidecars are built from), so the folder is `{base}.stems`. It cannot
422/// collide with the audio file (`{base}.<ext>`) or any `{base}.<sidecar>`
423/// because the `.stems` suffix is distinct, mirroring the sidecar convention.
424pub fn stems_folder(base: &str) -> String {
425    format!("{base}.stems")
426}
427
428/// The relative path of one stem file inside a song's [`stems_folder`].
429///
430/// Named base+label+disambiguation rather than label-only, because Auto Split
431/// can mislabel stems and Advanced Split yields ~100 instruments, so blank or
432/// duplicate labels are expected. The file is
433/// `{song file name} - {label} [{stem id8}].{ext}`; the ` - {label}` piece is
434/// dropped when the label sanitises to empty, and the `[{stem id8}]`
435/// disambiguator (the first 8 characters of the stable stem id) keeps blank or
436/// duplicate labels collision-free. Every component is run through the same
437/// [`sanitise_component`] filter as the rest of the library, honouring
438/// `character_set`.
439pub fn stem_file_path(
440    base: &str,
441    label: &str,
442    stem_id: &str,
443    ext: &str,
444    character_set: CharacterSet,
445) -> String {
446    let folder = stems_folder(base);
447    // The song's own file-name stem (the last path component of `base`), reused
448    // so a stem stays identifiable even when viewed outside its `.stems` folder.
449    let song_stem = base.rsplit('/').next().unwrap_or(base);
450    let label = sanitise_component(label, character_set, DEFAULT_MAX_COMPONENT_LEN);
451    let id8 = sanitise_component(
452        &truncate_chars(stem_id, 8),
453        CharacterSet::Ascii,
454        DEFAULT_MAX_COMPONENT_LEN,
455    );
456
457    let mut name = song_stem.to_string();
458    if !label.is_empty() {
459        name.push_str(" - ");
460        name.push_str(&label);
461    }
462    if !id8.is_empty() {
463        name.push_str(" [");
464        name.push_str(&id8);
465        name.push(']');
466    }
467    // A degenerate base (empty song stem, blank label, empty id) must still
468    // yield a usable name rather than a hidden dotfile.
469    if name.trim().is_empty() {
470        name = "stem".to_string();
471    }
472    format!("{folder}/{name}.{}", sanitise_ext(ext))
473}
474
475/// Reduce a candidate extension to a safe lowercase alphanumeric token,
476/// defaulting to `mp3` when it is empty or fully stripped. The caller passes the
477/// resolved stem format's extension (`wav` or `mp3`); stems are stored RAW.
478fn sanitise_ext(ext: &str) -> String {
479    let cleaned: String = ext
480        .trim_start_matches('.')
481        .chars()
482        .filter(|c| c.is_ascii_alphanumeric())
483        .flat_map(char::to_lowercase)
484        .take(8)
485        .collect();
486    if cleaned.is_empty() {
487        "mp3".to_string()
488    } else {
489        cleaned
490    }
491}
492
493fn sanitise_component(
494    value: &str,
495    character_set: CharacterSet,
496    max_component_len: usize,
497) -> String {
498    // Single pass: map each char to its charset-safe form while collapsing runs
499    // of whitespace to one space and dropping leading/trailing whitespace. This
500    // fuses the old filter / split_whitespace / collect / join steps, which
501    // allocated several intermediate strings and a vector, into one buffer.
502    let mut collapsed = String::with_capacity(value.len());
503    let mut pending_space = false;
504    let push = |out: char, buf: &mut String, pending: &mut bool| {
505        if out.is_whitespace() {
506            *pending = !buf.is_empty();
507        } else {
508            if *pending {
509                buf.push(' ');
510            }
511            *pending = false;
512            buf.push(out);
513        }
514    };
515    match character_set {
516        CharacterSet::Unicode => {
517            for ch in value.chars() {
518                push(unicode_char(ch), &mut collapsed, &mut pending_space);
519            }
520        }
521        CharacterSet::Ascii => {
522            for ch in value.chars() {
523                for out in ascii_chars(ch) {
524                    push(out, &mut collapsed, &mut pending_space);
525                }
526            }
527        }
528    }
529
530    let trimmed = collapsed.trim_matches([' ', '.']);
531    if trimmed.is_empty() {
532        return String::new();
533    }
534
535    // Keep at most `max` characters, then trim any space or dot the cut exposed.
536    // Slicing at the char boundary avoids the extra String the old
537    // truncate-then-trim-then-to_string sequence built.
538    let max = max_component_len.max(1);
539    let end = trimmed
540        .char_indices()
541        .nth(max)
542        .map_or(trimmed.len(), |(index, _)| index);
543    let result = trimmed[..end].trim_matches([' ', '.']);
544    if result.is_empty() {
545        return String::new();
546    }
547    if result == "." || result == ".." {
548        return "item".to_string();
549    }
550    let mut result = result.to_string();
551    if is_reserved_name(&result) {
552        // Guard the stem, not the whole component: `NUL.mp3` must become
553        // `NUL_.mp3`, since `NUL.mp3_` keeps `NUL` as its dot-stem and stays a
554        // Windows device name.
555        let stem_end = result.find('.').unwrap_or(result.len());
556        result.insert(stem_end, '_');
557    }
558    result
559}
560
561fn unicode_char(ch: char) -> char {
562    if matches!(
563        ch,
564        '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0'
565    ) || ch.is_control()
566    {
567        ' '
568    } else {
569        ch
570    }
571}
572
573fn ascii_chars(ch: char) -> Vec<char> {
574    if ch.is_ascii() {
575        return vec![unicode_char(ch)];
576    }
577
578    match ch {
579        'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => vec!['A'],
580        'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => vec!['a'],
581        'Ç' => vec!['C'],
582        'ç' => vec!['c'],
583        'È' | 'É' | 'Ê' | 'Ë' => vec!['E'],
584        'è' | 'é' | 'ê' | 'ë' => vec!['e'],
585        'Ì' | 'Í' | 'Î' | 'Ï' => vec!['I'],
586        'ì' | 'í' | 'î' | 'ï' => vec!['i'],
587        'Ñ' => vec!['N'],
588        'ñ' => vec!['n'],
589        'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => vec!['O'],
590        'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' => vec!['o'],
591        'Ù' | 'Ú' | 'Û' | 'Ü' => vec!['U'],
592        'ù' | 'ú' | 'û' | 'ü' => vec!['u'],
593        'Ý' | 'Ÿ' => vec!['Y'],
594        'ý' | 'ÿ' => vec!['y'],
595        'Æ' => vec!['A', 'E'],
596        'æ' => vec!['a', 'e'],
597        'Œ' => vec!['O', 'E'],
598        'œ' => vec!['o', 'e'],
599        'ß' => vec!['s', 's'],
600        _ => vec![' '],
601    }
602}
603
604fn truncate_chars(value: &str, max_len: usize) -> String {
605    value.chars().take(max_len).collect()
606}
607
608fn non_blank(value: &str) -> Option<&str> {
609    let trimmed = value.trim();
610    (!trimmed.is_empty()).then_some(trimmed)
611}
612
613fn is_reserved_name(value: &str) -> bool {
614    let stem = value.split('.').next().unwrap_or(value);
615    // Every reserved device name is 3 (CON/PRN/AUX/NUL) or 4 (COMx/LPTx) ASCII
616    // bytes, so anything else cannot match without allocating an uppercased copy.
617    if !matches!(stem.len(), 3 | 4) {
618        return false;
619    }
620    const RESERVED: [&str; 22] = [
621        "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
622        "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
623    ];
624    RESERVED.iter().any(|name| name.eq_ignore_ascii_case(stem))
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use crate::lineage::{EdgeType, ResolveStatus};
631    use std::collections::{BTreeMap, BTreeSet};
632    use std::path::Path;
633
634    fn test_clip(id: &str, title: &str) -> Clip {
635        Clip {
636            id: id.to_string(),
637            title: title.to_string(),
638            display_name: "München".to_string(),
639            handle: "munchen".to_string(),
640            ..Clip::default()
641        }
642    }
643
644    fn render_own(clip: &Clip, config: &NamingConfig) -> RenderedName {
645        let lineage = LineageContext::own_root(clip);
646        render_clip_name(
647            NamingRequest {
648                clip,
649                lineage: &lineage,
650            },
651            config,
652        )
653    }
654
655    fn render_all_own(
656        clips: &[Clip],
657        config: &NamingConfig,
658        colliding: &BTreeSet<String>,
659    ) -> Vec<RenderedName> {
660        let lineages: Vec<LineageContext> = clips.iter().map(LineageContext::own_root).collect();
661        let requests: Vec<NamingRequest> = clips
662            .iter()
663            .zip(&lineages)
664            .map(|(clip, lineage)| NamingRequest { clip, lineage })
665            .collect();
666        render_clip_names(&requests, config, colliding)
667    }
668
669    #[test]
670    fn unicode_names_are_preserved_and_ascii_falls_back() {
671        let clip = test_clip("abc12345", "Beyoncé/東京");
672
673        let unicode = render_own(&clip, &NamingConfig::default());
674        assert_eq!(
675            unicode.relative_path,
676            Path::new("München/Beyoncé 東京/München-Beyoncé 東京 [abc12345]")
677        );
678
679        let ascii = render_own(
680            &clip,
681            &NamingConfig {
682                character_set: CharacterSet::Ascii,
683                ..NamingConfig::default()
684            },
685        );
686        assert_eq!(
687            ascii.relative_path,
688            Path::new("Munchen/Beyonce/Munchen-Beyonce [abc12345]")
689        );
690    }
691
692    #[test]
693    fn reserved_and_hostile_names_are_sanitised() {
694        let clip = Clip {
695            id: "deadbeef".to_string(),
696            title: "CON<>:\"/\\|?*.".to_string(),
697            display_name: "AUX".to_string(),
698            ..Clip::default()
699        };
700
701        let rendered = render_own(&clip, &NamingConfig::default());
702        assert!(
703            rendered.relative_path.starts_with("AUX_/CON_"),
704            "path was {}",
705            rendered.relative_path.display()
706        );
707        assert!(rendered.base_name.contains("[deadbeef]"));
708    }
709
710    #[test]
711    fn reserved_name_with_dotted_title_guards_the_stem_not_the_component() {
712        // A bare `{title}` file component whose title is a device name with an
713        // extension: the guard must land on the stem (`NUL_.mp3`), not the whole
714        // component (`NUL.mp3_`), which would keep `NUL` as the dot-stem.
715        let config = NamingConfig {
716            template: "{title}".to_string(),
717            ..NamingConfig::default()
718        };
719        let clip = test_clip("abcd1234-x", "NUL.mp3");
720        let rendered = render_own(&clip, &config);
721        let component = rendered.relative_path.to_string_lossy();
722        assert_eq!(component, "NUL_.mp3");
723        assert!(
724            !is_reserved_name(&component),
725            "component {component} is still a reserved device name"
726        );
727    }
728
729    #[test]
730    fn default_template_always_embeds_id8() {
731        let clip = test_clip("abcdef1234567890", "Any Title");
732        let rendered = render_own(&clip, &NamingConfig::default());
733        assert!(
734            rendered.base_name.contains("[abcdef12]"),
735            "base_name was {}",
736            rendered.base_name
737        );
738    }
739
740    #[test]
741    fn custom_template_replaces_all_known_placeholders_once() {
742        let clip = Clip {
743            id: "abcdef12-full".to_string(),
744            title: "Song".to_string(),
745            display_name: "Creator".to_string(),
746            handle: "handle".to_string(),
747            ..Clip::default()
748        };
749        let lineage = LineageContext {
750            root_id: "rootxyz9-extra".to_string(),
751            root_title: "Album".to_string(),
752            root_date: String::new(),
753            parent_id: "rootxyz9-extra".to_string(),
754            edge_type: Some(EdgeType::Cover),
755            status: ResolveStatus::Resolved,
756        };
757        let config = NamingConfig {
758            template: "{creator}-{handle}-{album}-{title}-{root_id8}-{id8}-{id}-{unknown}"
759                .to_string(),
760            ..NamingConfig::default()
761        };
762
763        let rendered = render_clip_name(
764            NamingRequest {
765                clip: &clip,
766                lineage: &lineage,
767            },
768            &config,
769        );
770
771        assert_eq!(
772            rendered.relative_path.to_string_lossy(),
773            "Creator-handle-Album-Song-rootxyz9-abcdef12-abcdef12-full-{unknown}"
774        );
775    }
776
777    #[test]
778    fn blank_titles_use_a_stable_suffix() {
779        let clip = test_clip("12345678-clip", "   ");
780
781        let rendered = render_own(&clip, &NamingConfig::default());
782        assert_eq!(rendered.base_name, "München-Untitled [12345678]");
783        assert_eq!(
784            rendered.relative_path,
785            Path::new("München/Untitled/München-Untitled [12345678]")
786        );
787    }
788
789    #[test]
790    fn very_long_titles_are_trimmed() {
791        let clip = test_clip("abcdef12", &"a".repeat(120));
792        let rendered = render_own(
793            &clip,
794            &NamingConfig {
795                max_component_len: 24,
796                ..NamingConfig::default()
797            },
798        );
799
800        for component in rendered.relative_path.components() {
801            let text = component.as_os_str().to_string_lossy();
802            assert!(
803                text.chars().count() <= 24,
804                "component {text:?} exceeds 24 chars"
805            );
806        }
807        // The trailing [id8] must survive the truncation intact (#120).
808        assert!(
809            rendered.base_name.ends_with(" [abcdef12]"),
810            "id8 disambiguator was sliced; base_name was {:?}",
811            rendered.base_name
812        );
813    }
814
815    #[test]
816    fn long_names_keep_the_full_id8_disambiguator() {
817        // A creator+title long enough to overflow the cap keeps the whole
818        // trailing [id8]: the title is shortened, not the id, so the name stays
819        // complete and the bracket stays balanced (#120).
820        let clip = test_clip("1234abcd-tail", &"a".repeat(120));
821        let config = NamingConfig {
822            max_component_len: 40,
823            ..NamingConfig::default()
824        };
825        let rendered = render_own(&clip, &config);
826
827        assert!(
828            rendered.base_name.ends_with(" [1234abcd]"),
829            "base_name must end with the full disambiguator, was {:?}",
830            rendered.base_name
831        );
832        assert_eq!(rendered.base_name.chars().count(), 40);
833    }
834
835    #[test]
836    fn long_titled_siblings_stay_distinct_with_balanced_brackets() {
837        // Two same-(long-)titled clips sharing a root must remain distinct: only
838        // the title is shortened, so their [id8] suffixes differ and neither name
839        // ends up with an unbalanced bracket (#120).
840        let lineage = LineageContext {
841            root_id: "root-42".to_string(),
842            root_title: "Origin".to_string(),
843            root_date: String::new(),
844            parent_id: "root-42".to_string(),
845            edge_type: Some(EdgeType::Cover),
846            status: ResolveStatus::Resolved,
847        };
848        let title = "z".repeat(200);
849        let first = test_clip("aaaa1111-x", &title);
850        let second = test_clip("bbbb2222-y", &title);
851        let requests = [
852            NamingRequest {
853                clip: &first,
854                lineage: &lineage,
855            },
856            NamingRequest {
857                clip: &second,
858                lineage: &lineage,
859            },
860        ];
861
862        let names = render_clip_names(&requests, &NamingConfig::default(), &BTreeSet::new());
863
864        assert!(names[0].base_name.ends_with(" [aaaa1111]"));
865        assert!(names[1].base_name.ends_with(" [bbbb2222]"));
866        assert_ne!(names[0].relative_path, names[1].relative_path);
867        for name in &names {
868            assert!(name.base_name.chars().count() <= 80);
869            assert_eq!(name.base_name.matches('[').count(), 1, "unbalanced '['");
870            assert_eq!(name.base_name.matches(']').count(), 1, "unbalanced ']'");
871        }
872    }
873
874    #[test]
875    fn long_colliding_album_keeps_its_root_id8() {
876        // The album [root_id8] disambiguator is preserved when a long album title
877        // must be truncated, mirroring the file-name fix (#120).
878        let long = "Break Through ".repeat(20);
879        let title = long.trim().to_string();
880        let clip = Clip {
881            id: "aaaa1111-x".to_string(),
882            title: title.clone(),
883            display_name: "München".to_string(),
884            ..Clip::default()
885        };
886        let colliding: BTreeSet<String> = [title].into_iter().collect();
887        let names = render_all_own(&[clip], &NamingConfig::default(), &colliding);
888
889        let album = names[0]
890            .relative_path
891            .components()
892            .nth(1)
893            .map(|component| component.as_os_str().to_string_lossy().into_owned())
894            .unwrap_or_default();
895        assert!(album.ends_with(" [aaaa1111]"), "album was {album:?}");
896        assert!(album.chars().count() <= 80);
897    }
898
899    #[test]
900    fn ascii_expanding_chars_do_not_slice_the_disambiguator() {
901        // A literal expanding character (`ß` -> `ss` under ascii) in a custom
902        // template, right before the trailing ` [{id8}]`, must not grow back over
903        // the suffix and slice it: the base is sized after expansion (#120).
904        let clip = test_clip("1234abcd", "Title");
905        let config = NamingConfig {
906            template: format!("{}{{title}} [{{id8}}]", "ß".repeat(80)),
907            character_set: CharacterSet::Ascii,
908            max_component_len: 40,
909        };
910        let rendered = render_own(&clip, &config);
911
912        assert!(
913            rendered.base_name.ends_with(" [1234abcd]"),
914            "expansion sliced the id8; base_name was {:?}",
915            rendered.base_name
916        );
917        assert!(rendered.base_name.chars().count() <= 40);
918    }
919
920    #[test]
921    fn same_title_siblings_stay_distinct_via_id8() {
922        // Two clips sharing a root (same album folder) and the same title must
923        // still land on distinct files; the default template's {id8} does that.
924        let lineage = LineageContext {
925            root_id: "root-9".to_string(),
926            root_title: "Origin".to_string(),
927            root_date: String::new(),
928            parent_id: "root-9".to_string(),
929            edge_type: Some(EdgeType::Cover),
930            status: ResolveStatus::Resolved,
931        };
932        let first = test_clip("11111111-alpha", "Shared");
933        let second = test_clip("22222222-beta", "Shared");
934        let requests = [
935            NamingRequest {
936                clip: &first,
937                lineage: &lineage,
938            },
939            NamingRequest {
940                clip: &second,
941                lineage: &lineage,
942            },
943        ];
944
945        let names = render_clip_names(&requests, &NamingConfig::default(), &BTreeSet::new());
946
947        assert_eq!(
948            names[0].relative_path,
949            Path::new("München/Origin/München-Shared [11111111]")
950        );
951        assert_eq!(
952            names[1].relative_path,
953            Path::new("München/Origin/München-Shared [22222222]")
954        );
955    }
956
957    #[test]
958    fn id8_prefix_collision_falls_back_to_full_id() {
959        // Custom template without {id8} so identical titles collide and the
960        // filename fallback (full id) has to keep them distinct.
961        let config = NamingConfig {
962            template: "{creator}/{title}".to_string(),
963            ..NamingConfig::default()
964        };
965        let first = test_clip("abcd1234-first", "Untitled");
966        let second = test_clip("abcd1234-second", "Untitled");
967
968        let names = render_all_own(&[first.clone(), second.clone()], &config, &BTreeSet::new());
969        let swapped = render_all_own(&[second.clone(), first.clone()], &config, &BTreeSet::new());
970
971        assert_ne!(
972            names[0].relative_path.to_string_lossy(),
973            names[1].relative_path.to_string_lossy()
974        );
975
976        let ordered = |rendered: &[RenderedName], clips: &[Clip]| {
977            clips
978                .iter()
979                .zip(rendered)
980                .map(|(clip, name)| {
981                    (
982                        clip.id.clone(),
983                        name.relative_path.to_string_lossy().into_owned(),
984                    )
985                })
986                .collect::<BTreeMap<_, _>>()
987        };
988        assert_eq!(
989            ordered(&names, &[first.clone(), second.clone()]),
990            ordered(&swapped, &[second, first])
991        );
992    }
993
994    #[test]
995    fn album_is_root_title_for_a_remix() {
996        let clip = Clip {
997            id: "child".to_string(),
998            title: "Remix".to_string(),
999            display_name: "München".to_string(),
1000            ..Clip::default()
1001        };
1002        let lineage = LineageContext {
1003            root_id: "root-1".to_string(),
1004            root_title: "Original".to_string(),
1005            root_date: String::new(),
1006            parent_id: "root-1".to_string(),
1007            edge_type: Some(EdgeType::Cover),
1008            status: ResolveStatus::Resolved,
1009        };
1010
1011        let rendered = render_clip_name(
1012            NamingRequest {
1013                clip: &clip,
1014                lineage: &lineage,
1015            },
1016            &NamingConfig::default(),
1017        );
1018        assert_eq!(
1019            rendered.relative_path,
1020            Path::new("München/Original/München-Remix [child]")
1021        );
1022    }
1023
1024    #[test]
1025    fn album_is_own_title_for_a_root() {
1026        let clip = Clip {
1027            id: "root-1".to_string(),
1028            title: "Original".to_string(),
1029            display_name: "München".to_string(),
1030            ..Clip::default()
1031        };
1032
1033        let rendered = render_own(&clip, &NamingConfig::default());
1034        assert_eq!(
1035            rendered.relative_path,
1036            Path::new("München/Original/München-Original [root-1]")
1037        );
1038    }
1039
1040    #[test]
1041    fn shared_album_title_from_distinct_roots_is_disambiguated() {
1042        let first = Clip {
1043            id: "aaaa1111-x".to_string(),
1044            title: "Break Through".to_string(),
1045            display_name: "München".to_string(),
1046            ..Clip::default()
1047        };
1048        let second = Clip {
1049            id: "bbbb2222-y".to_string(),
1050            title: "Break Through".to_string(),
1051            display_name: "München".to_string(),
1052            ..Clip::default()
1053        };
1054
1055        // The colliding set is authoritative (store-driven), so disambiguation
1056        // does not depend on both roots appearing in the same batch.
1057        let colliding: BTreeSet<String> = ["Break Through".to_string()].into_iter().collect();
1058        let names = render_all_own(
1059            &[first.clone(), second.clone()],
1060            &NamingConfig::default(),
1061            &colliding,
1062        );
1063        let swapped = render_all_own(
1064            &[second.clone(), first.clone()],
1065            &NamingConfig::default(),
1066            &colliding,
1067        );
1068
1069        let album_of = |rendered: &RenderedName| {
1070            rendered
1071                .relative_path
1072                .components()
1073                .nth(1)
1074                .map(|component| component.as_os_str().to_string_lossy().into_owned())
1075                .unwrap_or_default()
1076        };
1077
1078        assert_eq!(album_of(&names[0]), "Break Through [aaaa1111]");
1079        assert_eq!(album_of(&names[1]), "Break Through [bbbb2222]");
1080        // Deterministic regardless of input order.
1081        assert_eq!(album_of(&swapped[0]), "Break Through [bbbb2222]");
1082        assert_eq!(album_of(&swapped[1]), "Break Through [aaaa1111]");
1083
1084        // The MEDIUM fix: a narrowed run showing only one of the two roots
1085        // still gets the suffixed folder, so folders never oscillate.
1086        let alone = render_all_own(
1087            std::slice::from_ref(&first),
1088            &NamingConfig::default(),
1089            &colliding,
1090        );
1091        assert_eq!(album_of(&alone[0]), "Break Through [aaaa1111]");
1092    }
1093
1094    #[test]
1095    fn unique_root_title_stays_a_bare_album() {
1096        // A title absent from the colliding set keeps its bare folder even when
1097        // the batch happens to hold a same-titled sibling of the same root.
1098        let clip = Clip {
1099            id: "solo-1".to_string(),
1100            title: "Solo".to_string(),
1101            display_name: "München".to_string(),
1102            ..Clip::default()
1103        };
1104        let names = render_all_own(&[clip], &NamingConfig::default(), &BTreeSet::new());
1105        assert_eq!(
1106            names[0].relative_path,
1107            Path::new("München/Solo/München-Solo [solo-1]")
1108        );
1109    }
1110
1111    #[test]
1112    fn sanitise_name_strips_separators_and_falls_back_when_empty() {
1113        assert_eq!(sanitise_name("Road/Trip: 2024"), "Road Trip 2024");
1114        assert_eq!(sanitise_name(""), "playlist");
1115        // A name made only of illegal characters strips to nothing, so the
1116        // caller still gets a usable, non-empty stem.
1117        assert_eq!(sanitise_name("///"), "playlist");
1118    }
1119
1120    #[test]
1121    fn stems_folder_is_a_sibling_suffix_of_the_song_base() {
1122        assert_eq!(
1123            stems_folder("Creator/Album/Creator-Song [abcd1234]"),
1124            "Creator/Album/Creator-Song [abcd1234].stems"
1125        );
1126    }
1127
1128    #[test]
1129    fn stem_file_path_combines_song_stem_label_and_disambiguator() {
1130        let path = stem_file_path(
1131            "Creator/Album/Creator-Song [abcd1234]",
1132            "Vocals",
1133            "stem-vocals-9f8e7d6c",
1134            "mp3",
1135            CharacterSet::Unicode,
1136        );
1137        assert_eq!(
1138            path,
1139            "Creator/Album/Creator-Song [abcd1234].stems/Creator-Song [abcd1234] - Vocals [stem-voc].mp3"
1140        );
1141    }
1142
1143    #[test]
1144    fn stem_file_path_disambiguates_blank_and_duplicate_labels_by_id() {
1145        // Two stems with the SAME (blank) label must not collide: the stem-id
1146        // disambiguator keeps them distinct even with no usable label.
1147        let a = stem_file_path("song", "", "id-aaaaaaaa", "wav", CharacterSet::Unicode);
1148        let b = stem_file_path("song", "", "id-bbbbbbbb", "wav", CharacterSet::Unicode);
1149        assert_eq!(a, "song.stems/song [id-aaaaa].wav");
1150        assert_eq!(b, "song.stems/song [id-bbbbb].wav");
1151        assert_ne!(a, b);
1152    }
1153
1154    #[test]
1155    fn stem_file_path_sanitises_label_and_extension_and_honours_ascii() {
1156        // Illegal path characters in the label are stripped, the extension is
1157        // reduced to a safe lowercase token, and ASCII folding applies.
1158        let path = stem_file_path(
1159            "song",
1160            "Lead/Vocal: Æ",
1161            "STEMID12",
1162            ".FLAC",
1163            CharacterSet::Ascii,
1164        );
1165        assert_eq!(path, "song.stems/song - Lead Vocal AE [STEMID12].flac");
1166        // A junk extension falls back to mp3 (defensive; callers pass wav/mp3).
1167        let fallback = stem_file_path("s", "Bass", "x", "??", CharacterSet::Unicode);
1168        assert_eq!(fallback, "s.stems/s - Bass [x].mp3");
1169    }
1170
1171    #[test]
1172    fn case_only_path_difference_is_a_canonical_collision() {
1173        // A custom template without {id8}: clips whose titles differ only in
1174        // case produce different exact paths but the same canonical path and
1175        // must be disambiguated to avoid clobbering on case-insensitive FSes.
1176        let config = NamingConfig {
1177            template: "{creator}/{title}".to_string(),
1178            ..NamingConfig::default()
1179        };
1180        let first = test_clip("aaaa1111-x", "sunrise");
1181        let second = test_clip("bbbb2222-y", "SUNRISE");
1182
1183        let names = render_all_own(&[first, second], &config, &BTreeSet::new());
1184
1185        assert_ne!(
1186            names[0].relative_path.to_string_lossy(),
1187            names[1].relative_path.to_string_lossy(),
1188            "canonical collision was not disambiguated"
1189        );
1190    }
1191
1192    #[test]
1193    fn nfc_nfd_path_difference_is_a_canonical_collision() {
1194        // The same character encoded as NFC vs NFD produces different byte
1195        // strings but the same file on NFC-normalising filesystems (macOS APFS).
1196        let config = NamingConfig {
1197            template: "{creator}/{title}".to_string(),
1198            ..NamingConfig::default()
1199        };
1200        // "é" as NFC (U+00E9) vs NFD (e + U+0301).
1201        let nfc_title = "\u{00e9}toile";
1202        let nfd_title = "e\u{0301}toile";
1203        let first = test_clip("aaaa1111-x", nfc_title);
1204        let second = test_clip("bbbb2222-y", nfd_title);
1205
1206        let names = render_all_own(&[first, second], &config, &BTreeSet::new());
1207
1208        assert_ne!(
1209            names[0].relative_path.to_string_lossy(),
1210            names[1].relative_path.to_string_lossy(),
1211            "NFC/NFD canonical collision was not disambiguated"
1212        );
1213    }
1214
1215    #[test]
1216    fn genuinely_distinct_paths_are_never_wrongly_disambiguated() {
1217        // Clips with distinct titles (not even canonically equivalent) must not
1218        // receive unnecessary suffixes — the canonical check must not produce
1219        // false positives.
1220        let config = NamingConfig {
1221            template: "{creator}/{title}".to_string(),
1222            ..NamingConfig::default()
1223        };
1224        let first = test_clip("aaaa1111-x", "Alpha");
1225        let second = test_clip("bbbb2222-y", "Beta");
1226
1227        let names = render_all_own(&[first, second], &config, &BTreeSet::new());
1228
1229        assert_eq!(
1230            names[0].relative_path,
1231            Path::new("München/Alpha"),
1232            "distinct path was wrongly suffixed"
1233        );
1234        assert_eq!(
1235            names[1].relative_path,
1236            Path::new("München/Beta"),
1237            "distinct path was wrongly suffixed"
1238        );
1239    }
1240}