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] =
120 with_suffix(rendered[index].clone(), suffix, config.max_component_len);
121 }
122 }
123
124 rendered
125}
126
127fn disambiguated_albums(
138 requests: &[NamingRequest<'_>],
139 config: &NamingConfig,
140 colliding_albums: &BTreeSet<String>,
141) -> Vec<String> {
142 requests
143 .iter()
144 .map(|request| album_for(*request, config, colliding_albums))
145 .collect()
146}
147
148fn album_for(
150 request: NamingRequest<'_>,
151 config: &NamingConfig,
152 colliding_albums: &BTreeSet<String>,
153) -> String {
154 let raw_album = request.lineage.album(&title_name(request.clip));
155 let album = sanitise_component(&raw_album, config.character_set, config.max_component_len);
156 if colliding_albums.contains(raw_album.trim()) {
157 let suffix = truncate_chars(&request.lineage.root_id, 8);
158 sanitise_component(
159 &format!("{album} [{suffix}]"),
160 config.character_set,
161 config.max_component_len,
162 )
163 } else {
164 album
165 }
166}
167
168fn album_component(request: NamingRequest<'_>, config: &NamingConfig) -> String {
171 let album = request.lineage.album(&title_name(request.clip));
172 sanitise_component(&album, config.character_set, config.max_component_len)
173}
174
175fn render_with_album(
177 request: NamingRequest<'_>,
178 config: &NamingConfig,
179 album: &str,
180) -> RenderedName {
181 let clip = request.clip;
182 let creator = sanitise_component(
183 &creator_name(clip),
184 config.character_set,
185 config.max_component_len,
186 );
187 let handle = sanitise_component(&clip.handle, config.character_set, config.max_component_len);
188 let title = sanitise_component(
189 &title_name(clip),
190 config.character_set,
191 config.max_component_len,
192 );
193 let id = sanitise_component(&clip.id, CharacterSet::Ascii, config.max_component_len);
194 let id8 = sanitise_component(
195 &truncate_chars(&clip.id, 8),
196 CharacterSet::Ascii,
197 config.max_component_len,
198 );
199 let root_id8 = sanitise_component(
200 &truncate_chars(&request.lineage.root_id, 8),
201 CharacterSet::Ascii,
202 config.max_component_len,
203 );
204 let mut components = config
205 .template
206 .split('/')
207 .filter_map(|segment| {
208 let rendered = segment
209 .replace("{creator}", &creator)
210 .replace("{handle}", &handle)
211 .replace("{album}", album)
212 .replace("{title}", &title)
213 .replace("{root_id8}", &root_id8)
214 .replace("{id8}", &id8)
215 .replace("{id}", &id);
216 let sanitised =
217 sanitise_component(&rendered, config.character_set, config.max_component_len);
218 (!sanitised.is_empty()).then_some(sanitised)
219 })
220 .collect::<Vec<_>>();
221
222 if components.is_empty() {
223 components.push(title.clone());
224 }
225
226 let mut base_name = components
227 .pop()
228 .filter(|value| !value.is_empty())
229 .unwrap_or_else(|| title.clone());
230 if base_name.is_empty() {
232 base_name = append_suffix(&base_name, &clip.id, config.max_component_len);
233 }
234
235 let mut relative_path = PathBuf::new();
236 for component in components {
237 relative_path.push(component);
238 }
239
240 relative_path.push(&base_name);
241 RenderedName {
242 relative_path,
243 base_name,
244 }
245}
246
247fn with_suffix(mut rendered: RenderedName, suffix: &str, max_component_len: usize) -> RenderedName {
248 rendered.base_name = append_suffix(&rendered.base_name, suffix, max_component_len);
249 rendered.relative_path.set_file_name(&rendered.base_name);
250 rendered
251}
252
253fn creator_name(clip: &Clip) -> String {
254 non_blank(&clip.display_name)
255 .or_else(|| non_blank(&clip.handle))
256 .unwrap_or("Unknown Creator")
257 .to_string()
258}
259
260fn title_name(clip: &Clip) -> String {
261 let title = clip.title.trim();
262 if title.is_empty() || title.eq_ignore_ascii_case("untitled") {
263 "Untitled".to_string()
264 } else {
265 title.to_string()
266 }
267}
268
269fn append_suffix(base: &str, suffix: &str, max_component_len: usize) -> String {
270 let suffix_pattern = format!(" [{suffix}]");
271 if base.ends_with(&suffix_pattern) {
272 return sanitise_component(base, CharacterSet::Unicode, max_component_len);
273 }
274
275 let max_len =
276 max_component_len.max(suffix_pattern.chars().count() + MIN_BASE_CHARS_WITH_SUFFIX);
277 let allowed = max_len.saturating_sub(suffix_pattern.chars().count());
278 let truncated = truncate_chars(base.trim_end(), allowed);
279 let combined = format!("{truncated}{suffix_pattern}");
280 sanitise_component(&combined, CharacterSet::Unicode, max_len)
281}
282
283pub fn sanitise_name(name: &str) -> String {
291 let cleaned = sanitise_component(name, CharacterSet::Unicode, DEFAULT_MAX_COMPONENT_LEN);
292 if cleaned.is_empty() {
293 "playlist".to_string()
294 } else {
295 cleaned
296 }
297}
298
299pub fn stems_folder(base: &str) -> String {
306 format!("{base}.stems")
307}
308
309pub fn stem_file_path(
321 base: &str,
322 label: &str,
323 stem_id: &str,
324 ext: &str,
325 character_set: CharacterSet,
326) -> String {
327 let folder = stems_folder(base);
328 let song_stem = base.rsplit('/').next().unwrap_or(base);
331 let label = sanitise_component(label, character_set, DEFAULT_MAX_COMPONENT_LEN);
332 let id8 = sanitise_component(
333 &truncate_chars(stem_id, 8),
334 CharacterSet::Ascii,
335 DEFAULT_MAX_COMPONENT_LEN,
336 );
337
338 let mut name = song_stem.to_string();
339 if !label.is_empty() {
340 name.push_str(" - ");
341 name.push_str(&label);
342 }
343 if !id8.is_empty() {
344 name.push_str(" [");
345 name.push_str(&id8);
346 name.push(']');
347 }
348 if name.trim().is_empty() {
351 name = "stem".to_string();
352 }
353 format!("{folder}/{name}.{}", sanitise_ext(ext))
354}
355
356fn sanitise_ext(ext: &str) -> String {
360 let cleaned: String = ext
361 .trim_start_matches('.')
362 .chars()
363 .filter(|c| c.is_ascii_alphanumeric())
364 .flat_map(char::to_lowercase)
365 .take(8)
366 .collect();
367 if cleaned.is_empty() {
368 "mp3".to_string()
369 } else {
370 cleaned
371 }
372}
373
374fn sanitise_component(
375 value: &str,
376 character_set: CharacterSet,
377 max_component_len: usize,
378) -> String {
379 let filtered = match character_set {
380 CharacterSet::Unicode => value.chars().map(unicode_char).collect::<String>(),
381 CharacterSet::Ascii => value.chars().flat_map(ascii_chars).collect::<String>(),
382 };
383 let collapsed = filtered.split_whitespace().collect::<Vec<_>>().join(" ");
384 let trimmed = collapsed.trim_matches([' ', '.']);
385 if trimmed.is_empty() {
386 return String::new();
387 }
388
389 let mut result = truncate_chars(trimmed, max_component_len.max(1));
390 result = result.trim_matches([' ', '.']).to_string();
391 if result.is_empty() {
392 return String::new();
393 }
394 if result == "." || result == ".." {
395 return "item".to_string();
396 }
397 if !result.ends_with('_') && is_reserved_name(&result) {
398 result.push('_');
399 }
400 result
401}
402
403fn unicode_char(ch: char) -> char {
404 if matches!(
405 ch,
406 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0'
407 ) || ch.is_control()
408 {
409 ' '
410 } else {
411 ch
412 }
413}
414
415fn ascii_chars(ch: char) -> Vec<char> {
416 if ch.is_ascii() {
417 return vec![unicode_char(ch)];
418 }
419
420 match ch {
421 'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => vec!['A'],
422 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => vec!['a'],
423 'Ç' => vec!['C'],
424 'ç' => vec!['c'],
425 'È' | 'É' | 'Ê' | 'Ë' => vec!['E'],
426 'è' | 'é' | 'ê' | 'ë' => vec!['e'],
427 'Ì' | 'Í' | 'Î' | 'Ï' => vec!['I'],
428 'ì' | 'í' | 'î' | 'ï' => vec!['i'],
429 'Ñ' => vec!['N'],
430 'ñ' => vec!['n'],
431 'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => vec!['O'],
432 'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' => vec!['o'],
433 'Ù' | 'Ú' | 'Û' | 'Ü' => vec!['U'],
434 'ù' | 'ú' | 'û' | 'ü' => vec!['u'],
435 'Ý' | 'Ÿ' => vec!['Y'],
436 'ý' | 'ÿ' => vec!['y'],
437 'Æ' => vec!['A', 'E'],
438 'æ' => vec!['a', 'e'],
439 'Œ' => vec!['O', 'E'],
440 'œ' => vec!['o', 'e'],
441 'ß' => vec!['s', 's'],
442 _ => vec![' '],
443 }
444}
445
446fn truncate_chars(value: &str, max_len: usize) -> String {
447 value.chars().take(max_len).collect()
448}
449
450fn non_blank(value: &str) -> Option<&str> {
451 let trimmed = value.trim();
452 (!trimmed.is_empty()).then_some(trimmed)
453}
454
455fn is_reserved_name(value: &str) -> bool {
456 let stem = value.split('.').next().unwrap_or(value);
457 matches!(
458 stem.to_ascii_uppercase().as_str(),
459 "CON"
460 | "PRN"
461 | "AUX"
462 | "NUL"
463 | "COM1"
464 | "COM2"
465 | "COM3"
466 | "COM4"
467 | "COM5"
468 | "COM6"
469 | "COM7"
470 | "COM8"
471 | "COM9"
472 | "LPT1"
473 | "LPT2"
474 | "LPT3"
475 | "LPT4"
476 | "LPT5"
477 | "LPT6"
478 | "LPT7"
479 | "LPT8"
480 | "LPT9"
481 )
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use crate::lineage::{EdgeType, ResolveStatus};
488 use std::collections::{BTreeMap, BTreeSet};
489
490 fn test_clip(id: &str, title: &str) -> Clip {
491 Clip {
492 id: id.to_string(),
493 title: title.to_string(),
494 display_name: "München".to_string(),
495 handle: "munchen".to_string(),
496 album_title: String::new(),
497 root_ancestor_id: String::new(),
498 ..Clip::default()
499 }
500 }
501
502 fn render_own(clip: &Clip, config: &NamingConfig) -> RenderedName {
503 let lineage = LineageContext::own_root(clip);
504 render_clip_name(
505 NamingRequest {
506 clip,
507 lineage: &lineage,
508 },
509 config,
510 )
511 }
512
513 fn render_all_own(
514 clips: &[Clip],
515 config: &NamingConfig,
516 colliding: &BTreeSet<String>,
517 ) -> Vec<RenderedName> {
518 let lineages: Vec<LineageContext> = clips.iter().map(LineageContext::own_root).collect();
519 let requests: Vec<NamingRequest> = clips
520 .iter()
521 .zip(&lineages)
522 .map(|(clip, lineage)| NamingRequest { clip, lineage })
523 .collect();
524 render_clip_names(&requests, config, colliding)
525 }
526
527 #[test]
528 fn unicode_names_are_preserved_and_ascii_falls_back() {
529 let clip = test_clip("abc12345", "Beyoncé/東京");
530
531 let unicode = render_own(&clip, &NamingConfig::default());
532 assert_eq!(
533 unicode.relative_path.to_string_lossy(),
534 "München/Beyoncé 東京/München-Beyoncé 東京 [abc12345]"
535 );
536
537 let ascii = render_own(
538 &clip,
539 &NamingConfig {
540 character_set: CharacterSet::Ascii,
541 ..NamingConfig::default()
542 },
543 );
544 assert_eq!(
545 ascii.relative_path.to_string_lossy(),
546 "Munchen/Beyonce/Munchen-Beyonce [abc12345]"
547 );
548 }
549
550 #[test]
551 fn reserved_and_hostile_names_are_sanitised() {
552 let clip = Clip {
553 id: "deadbeef".to_string(),
554 title: "CON<>:\"/\\|?*.".to_string(),
555 display_name: "AUX".to_string(),
556 ..Clip::default()
557 };
558
559 let rendered = render_own(&clip, &NamingConfig::default());
560 let path = rendered.relative_path.to_string_lossy();
561 assert!(path.starts_with("AUX_/CON_/"), "path was {path}");
562 assert!(rendered.base_name.contains("[deadbeef]"));
563 }
564
565 #[test]
566 fn default_template_always_embeds_id8() {
567 let clip = test_clip("abcdef1234567890", "Any Title");
568 let rendered = render_own(&clip, &NamingConfig::default());
569 assert!(
570 rendered.base_name.contains("[abcdef12]"),
571 "base_name was {}",
572 rendered.base_name
573 );
574 }
575
576 #[test]
577 fn blank_titles_use_a_stable_suffix() {
578 let clip = test_clip("12345678-clip", " ");
579
580 let rendered = render_own(&clip, &NamingConfig::default());
581 assert_eq!(rendered.base_name, "München-Untitled [12345678]");
582 assert_eq!(
583 rendered.relative_path.to_string_lossy(),
584 "München/Untitled/München-Untitled [12345678]"
585 );
586 }
587
588 #[test]
589 fn very_long_titles_are_trimmed() {
590 let clip = test_clip("abcdef12", &"a".repeat(120));
591 let rendered = render_own(
592 &clip,
593 &NamingConfig {
594 max_component_len: 24,
595 ..NamingConfig::default()
596 },
597 );
598
599 for component in rendered.relative_path.components() {
600 let text = component.as_os_str().to_string_lossy();
601 assert!(
602 text.chars().count() <= 24,
603 "component {text:?} exceeds 24 chars"
604 );
605 }
606 }
607
608 #[test]
609 fn same_title_siblings_stay_distinct_via_id8() {
610 let lineage = LineageContext {
613 root_id: "root-9".to_string(),
614 root_title: "Origin".to_string(),
615 root_date: String::new(),
616 parent_id: "root-9".to_string(),
617 edge_type: Some(EdgeType::Cover),
618 status: ResolveStatus::Resolved,
619 };
620 let first = test_clip("11111111-alpha", "Shared");
621 let second = test_clip("22222222-beta", "Shared");
622 let requests = [
623 NamingRequest {
624 clip: &first,
625 lineage: &lineage,
626 },
627 NamingRequest {
628 clip: &second,
629 lineage: &lineage,
630 },
631 ];
632
633 let names = render_clip_names(&requests, &NamingConfig::default(), &BTreeSet::new());
634
635 assert_eq!(
636 names[0].relative_path.to_string_lossy(),
637 "München/Origin/München-Shared [11111111]"
638 );
639 assert_eq!(
640 names[1].relative_path.to_string_lossy(),
641 "München/Origin/München-Shared [22222222]"
642 );
643 }
644
645 #[test]
646 fn id8_prefix_collision_falls_back_to_full_id() {
647 let config = NamingConfig {
650 template: "{creator}/{title}".to_string(),
651 ..NamingConfig::default()
652 };
653 let first = test_clip("abcd1234-first", "Untitled");
654 let second = test_clip("abcd1234-second", "Untitled");
655
656 let names = render_all_own(&[first.clone(), second.clone()], &config, &BTreeSet::new());
657 let swapped = render_all_own(&[second.clone(), first.clone()], &config, &BTreeSet::new());
658
659 assert_ne!(
660 names[0].relative_path.to_string_lossy(),
661 names[1].relative_path.to_string_lossy()
662 );
663
664 let ordered = |rendered: &[RenderedName], clips: &[Clip]| {
665 clips
666 .iter()
667 .zip(rendered)
668 .map(|(clip, name)| {
669 (
670 clip.id.clone(),
671 name.relative_path.to_string_lossy().into_owned(),
672 )
673 })
674 .collect::<BTreeMap<_, _>>()
675 };
676 assert_eq!(
677 ordered(&names, &[first.clone(), second.clone()]),
678 ordered(&swapped, &[second, first])
679 );
680 }
681
682 #[test]
683 fn album_is_root_title_for_a_remix() {
684 let clip = Clip {
685 id: "child".to_string(),
686 title: "Remix".to_string(),
687 display_name: "München".to_string(),
688 ..Clip::default()
689 };
690 let lineage = LineageContext {
691 root_id: "root-1".to_string(),
692 root_title: "Original".to_string(),
693 root_date: String::new(),
694 parent_id: "root-1".to_string(),
695 edge_type: Some(EdgeType::Cover),
696 status: ResolveStatus::Resolved,
697 };
698
699 let rendered = render_clip_name(
700 NamingRequest {
701 clip: &clip,
702 lineage: &lineage,
703 },
704 &NamingConfig::default(),
705 );
706 assert_eq!(
707 rendered.relative_path.to_string_lossy(),
708 "München/Original/München-Remix [child]"
709 );
710 }
711
712 #[test]
713 fn overridden_album_drives_the_folder_path() {
714 let clip = Clip {
717 id: "child".to_string(),
718 title: "Remix".to_string(),
719 display_name: "München".to_string(),
720 ..Clip::default()
721 };
722 let lineage = LineageContext {
723 root_id: "root-1".to_string(),
724 root_title: "Preferred Album".to_string(),
725 root_date: String::new(),
726 parent_id: "root-1".to_string(),
727 edge_type: Some(EdgeType::Cover),
728 status: ResolveStatus::Resolved,
729 };
730
731 let rendered = render_clip_name(
732 NamingRequest {
733 clip: &clip,
734 lineage: &lineage,
735 },
736 &NamingConfig::default(),
737 );
738 assert_eq!(
739 rendered.relative_path.to_string_lossy(),
740 "München/Preferred Album/München-Remix [child]"
741 );
742 }
743
744 #[test]
745 fn album_is_own_title_for_a_root() {
746 let clip = Clip {
747 id: "root-1".to_string(),
748 title: "Original".to_string(),
749 display_name: "München".to_string(),
750 ..Clip::default()
751 };
752
753 let rendered = render_own(&clip, &NamingConfig::default());
754 assert_eq!(
755 rendered.relative_path.to_string_lossy(),
756 "München/Original/München-Original [root-1]"
757 );
758 }
759
760 #[test]
761 fn shared_album_title_from_distinct_roots_is_disambiguated() {
762 let first = Clip {
763 id: "aaaa1111-x".to_string(),
764 title: "Break Through".to_string(),
765 display_name: "München".to_string(),
766 ..Clip::default()
767 };
768 let second = Clip {
769 id: "bbbb2222-y".to_string(),
770 title: "Break Through".to_string(),
771 display_name: "München".to_string(),
772 ..Clip::default()
773 };
774
775 let colliding: BTreeSet<String> = ["Break Through".to_string()].into_iter().collect();
778 let names = render_all_own(
779 &[first.clone(), second.clone()],
780 &NamingConfig::default(),
781 &colliding,
782 );
783 let swapped = render_all_own(
784 &[second.clone(), first.clone()],
785 &NamingConfig::default(),
786 &colliding,
787 );
788
789 let album_of = |rendered: &RenderedName| {
790 rendered
791 .relative_path
792 .components()
793 .nth(1)
794 .map(|component| component.as_os_str().to_string_lossy().into_owned())
795 .unwrap_or_default()
796 };
797
798 assert_eq!(album_of(&names[0]), "Break Through [aaaa1111]");
799 assert_eq!(album_of(&names[1]), "Break Through [bbbb2222]");
800 assert_eq!(album_of(&swapped[0]), "Break Through [bbbb2222]");
802 assert_eq!(album_of(&swapped[1]), "Break Through [aaaa1111]");
803
804 let alone = render_all_own(
807 std::slice::from_ref(&first),
808 &NamingConfig::default(),
809 &colliding,
810 );
811 assert_eq!(album_of(&alone[0]), "Break Through [aaaa1111]");
812 }
813
814 #[test]
815 fn unique_root_title_stays_a_bare_album() {
816 let clip = Clip {
819 id: "solo-1".to_string(),
820 title: "Solo".to_string(),
821 display_name: "München".to_string(),
822 ..Clip::default()
823 };
824 let names = render_all_own(&[clip], &NamingConfig::default(), &BTreeSet::new());
825 assert_eq!(
826 names[0].relative_path.to_string_lossy(),
827 "München/Solo/München-Solo [solo-1]"
828 );
829 }
830
831 #[test]
832 fn sanitise_name_strips_separators_and_falls_back_when_empty() {
833 assert_eq!(sanitise_name("Road/Trip: 2024"), "Road Trip 2024");
834 assert_eq!(sanitise_name(""), "playlist");
835 assert_eq!(sanitise_name("///"), "playlist");
838 }
839
840 #[test]
841 fn stems_folder_is_a_sibling_suffix_of_the_song_base() {
842 assert_eq!(
843 stems_folder("Creator/Album/Creator-Song [abcd1234]"),
844 "Creator/Album/Creator-Song [abcd1234].stems"
845 );
846 }
847
848 #[test]
849 fn stem_file_path_combines_song_stem_label_and_disambiguator() {
850 let path = stem_file_path(
851 "Creator/Album/Creator-Song [abcd1234]",
852 "Vocals",
853 "stem-vocals-9f8e7d6c",
854 "mp3",
855 CharacterSet::Unicode,
856 );
857 assert_eq!(
858 path,
859 "Creator/Album/Creator-Song [abcd1234].stems/Creator-Song [abcd1234] - Vocals [stem-voc].mp3"
860 );
861 }
862
863 #[test]
864 fn stem_file_path_disambiguates_blank_and_duplicate_labels_by_id() {
865 let a = stem_file_path("song", "", "id-aaaaaaaa", "wav", CharacterSet::Unicode);
868 let b = stem_file_path("song", "", "id-bbbbbbbb", "wav", CharacterSet::Unicode);
869 assert_eq!(a, "song.stems/song [id-aaaaa].wav");
870 assert_eq!(b, "song.stems/song [id-bbbbb].wav");
871 assert_ne!(a, b);
872 }
873
874 #[test]
875 fn stem_file_path_sanitises_label_and_extension_and_honours_ascii() {
876 let path = stem_file_path(
879 "song",
880 "Lead/Vocal: Æ",
881 "STEMID12",
882 ".FLAC",
883 CharacterSet::Ascii,
884 );
885 assert_eq!(path, "song.stems/song - Lead Vocal AE [STEMID12].flac");
886 let fallback = stem_file_path("s", "Bass", "x", "??", CharacterSet::Unicode);
888 assert_eq!(fallback, "s.stems/s - Bass [x].mp3");
889 }
890}