1use 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
15pub 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 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
138fn 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
159fn 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
180fn 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
187fn 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 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 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
378fn 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
402pub 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
418pub fn stems_folder(base: &str) -> String {
425 format!("{base}.stems")
426}
427
428pub 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 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 if name.trim().is_empty() {
470 name = "stem".to_string();
471 }
472 format!("{folder}/{name}.{}", sanitise_ext(ext))
473}
474
475fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(album_of(&swapped[0]), "Break Through [bbbb2222]");
1082 assert_eq!(album_of(&swapped[1]), "Break Through [aaaa1111]");
1083
1084 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 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 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 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 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 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 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 let config = NamingConfig {
1197 template: "{creator}/{title}".to_string(),
1198 ..NamingConfig::default()
1199 };
1200 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 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}