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;
13
14pub 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 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
131fn 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
152fn 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
173fn 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
180fn 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 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 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
317fn 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
341pub 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
357pub fn stems_folder(base: &str) -> String {
364 format!("{base}.stems")
365}
366
367pub 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 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 if name.trim().is_empty() {
409 name = "stem".to_string();
410 }
411 format!("{folder}/{name}.{}", sanitise_ext(ext))
412}
413
414fn 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 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 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 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 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 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 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 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 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 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 assert_eq!(album_of(&swapped[0]), "Break Through [bbbb2222]");
971 assert_eq!(album_of(&swapped[1]), "Break Through [aaaa1111]");
972
973 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 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 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 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 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 let fallback = stem_file_path("s", "Bass", "x", "??", CharacterSet::Unicode);
1057 assert_eq!(fallback, "s.stems/s - Bass [x].mp3");
1058 }
1059}