use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::Clip;
use crate::error::{Error, Result};
use crate::lineage::LineageContext;
pub const DEFAULT_TEMPLATE: &str = "{creator}/{album}/{creator}-{title} [{id8}]";
const DEFAULT_MAX_COMPONENT_LEN: usize = 80;
const MIN_BASE_CHARS_WITH_SUFFIX: usize = 1;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CharacterSet {
#[default]
Unicode,
Ascii,
}
impl FromStr for CharacterSet {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s.to_ascii_lowercase().as_str() {
"unicode" => Ok(Self::Unicode),
"ascii" => Ok(Self::Ascii),
other => Err(Error::Config(format!(
"unknown character_set '{other}'; expected 'unicode' or 'ascii'"
))),
}
}
}
impl fmt::Display for CharacterSet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unicode => f.write_str("unicode"),
Self::Ascii => f.write_str("ascii"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NamingConfig {
pub template: String,
pub character_set: CharacterSet,
pub max_component_len: usize,
}
impl Default for NamingConfig {
fn default() -> Self {
Self {
template: DEFAULT_TEMPLATE.to_string(),
character_set: CharacterSet::Unicode,
max_component_len: DEFAULT_MAX_COMPONENT_LEN,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct NamingRequest<'a> {
pub clip: &'a Clip,
pub lineage: &'a LineageContext,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedName {
pub relative_path: PathBuf,
pub base_name: String,
}
pub fn render_clip_name(request: NamingRequest<'_>, config: &NamingConfig) -> RenderedName {
let album = album_component(request, config);
render_with_album(request, config, &album)
}
pub fn render_clip_names(
requests: &[NamingRequest<'_>],
config: &NamingConfig,
colliding_albums: &BTreeSet<String>,
) -> Vec<RenderedName> {
let albums = disambiguated_albums(requests, config, colliding_albums);
let mut rendered = requests
.iter()
.zip(&albums)
.map(|(request, album)| render_with_album(*request, config, album))
.collect::<Vec<_>>();
let mut collisions = BTreeMap::<String, Vec<usize>>::new();
for (index, name) in rendered.iter().enumerate() {
collisions
.entry(name.relative_path.to_string_lossy().into_owned())
.or_default()
.push(index);
}
for indexes in collisions.into_values().filter(|indexes| indexes.len() > 1) {
for index in indexes {
let suffix = &requests[index].clip.id;
rendered[index] =
with_suffix(rendered[index].clone(), suffix, config.max_component_len);
}
}
rendered
}
fn disambiguated_albums(
requests: &[NamingRequest<'_>],
config: &NamingConfig,
colliding_albums: &BTreeSet<String>,
) -> Vec<String> {
requests
.iter()
.map(|request| album_for(*request, config, colliding_albums))
.collect()
}
fn album_for(
request: NamingRequest<'_>,
config: &NamingConfig,
colliding_albums: &BTreeSet<String>,
) -> String {
let raw_album = request.lineage.album(&title_name(request.clip));
let album = sanitise_component(&raw_album, config.character_set, config.max_component_len);
if colliding_albums.contains(raw_album.trim()) {
let suffix = truncate_chars(&request.lineage.root_id, 8);
sanitise_component(
&format!("{album} [{suffix}]"),
config.character_set,
config.max_component_len,
)
} else {
album
}
}
fn album_component(request: NamingRequest<'_>, config: &NamingConfig) -> String {
let album = request.lineage.album(&title_name(request.clip));
sanitise_component(&album, config.character_set, config.max_component_len)
}
fn render_with_album(
request: NamingRequest<'_>,
config: &NamingConfig,
album: &str,
) -> RenderedName {
let clip = request.clip;
let creator = sanitise_component(
&creator_name(clip),
config.character_set,
config.max_component_len,
);
let handle = sanitise_component(&clip.handle, config.character_set, config.max_component_len);
let title = sanitise_component(
&title_name(clip),
config.character_set,
config.max_component_len,
);
let id = sanitise_component(&clip.id, CharacterSet::Ascii, config.max_component_len);
let id8 = sanitise_component(
&truncate_chars(&clip.id, 8),
CharacterSet::Ascii,
config.max_component_len,
);
let root_id8 = sanitise_component(
&truncate_chars(&request.lineage.root_id, 8),
CharacterSet::Ascii,
config.max_component_len,
);
let mut components = config
.template
.split('/')
.filter_map(|segment| {
let rendered = segment
.replace("{creator}", &creator)
.replace("{handle}", &handle)
.replace("{album}", album)
.replace("{title}", &title)
.replace("{root_id8}", &root_id8)
.replace("{id8}", &id8)
.replace("{id}", &id);
let sanitised =
sanitise_component(&rendered, config.character_set, config.max_component_len);
(!sanitised.is_empty()).then_some(sanitised)
})
.collect::<Vec<_>>();
if components.is_empty() {
components.push(title.clone());
}
let mut base_name = components
.pop()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| title.clone());
if base_name.is_empty() {
base_name = append_suffix(&base_name, &clip.id, config.max_component_len);
}
let mut relative_path = PathBuf::new();
for component in components {
relative_path.push(component);
}
relative_path.push(&base_name);
RenderedName {
relative_path,
base_name,
}
}
fn with_suffix(mut rendered: RenderedName, suffix: &str, max_component_len: usize) -> RenderedName {
rendered.base_name = append_suffix(&rendered.base_name, suffix, max_component_len);
rendered.relative_path.set_file_name(&rendered.base_name);
rendered
}
fn creator_name(clip: &Clip) -> String {
non_blank(&clip.display_name)
.or_else(|| non_blank(&clip.handle))
.unwrap_or("Unknown Creator")
.to_string()
}
fn title_name(clip: &Clip) -> String {
let title = clip.title.trim();
if title.is_empty() || title.eq_ignore_ascii_case("untitled") {
"Untitled".to_string()
} else {
title.to_string()
}
}
fn append_suffix(base: &str, suffix: &str, max_component_len: usize) -> String {
let suffix_pattern = format!(" [{suffix}]");
if base.ends_with(&suffix_pattern) {
return sanitise_component(base, CharacterSet::Unicode, max_component_len);
}
let max_len =
max_component_len.max(suffix_pattern.chars().count() + MIN_BASE_CHARS_WITH_SUFFIX);
let allowed = max_len.saturating_sub(suffix_pattern.chars().count());
let truncated = truncate_chars(base.trim_end(), allowed);
let combined = format!("{truncated}{suffix_pattern}");
sanitise_component(&combined, CharacterSet::Unicode, max_len)
}
pub fn sanitise_name(name: &str) -> String {
let cleaned = sanitise_component(name, CharacterSet::Unicode, DEFAULT_MAX_COMPONENT_LEN);
if cleaned.is_empty() {
"playlist".to_string()
} else {
cleaned
}
}
fn sanitise_component(
value: &str,
character_set: CharacterSet,
max_component_len: usize,
) -> String {
let filtered = match character_set {
CharacterSet::Unicode => value.chars().map(unicode_char).collect::<String>(),
CharacterSet::Ascii => value.chars().flat_map(ascii_chars).collect::<String>(),
};
let collapsed = filtered.split_whitespace().collect::<Vec<_>>().join(" ");
let trimmed = collapsed.trim_matches([' ', '.']);
if trimmed.is_empty() {
return String::new();
}
let mut result = truncate_chars(trimmed, max_component_len.max(1));
result = result.trim_matches([' ', '.']).to_string();
if result.is_empty() {
return String::new();
}
if result == "." || result == ".." {
return "item".to_string();
}
if !result.ends_with('_') && is_reserved_name(&result) {
result.push('_');
}
result
}
fn unicode_char(ch: char) -> char {
if matches!(
ch,
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0'
) || ch.is_control()
{
' '
} else {
ch
}
}
fn ascii_chars(ch: char) -> Vec<char> {
if ch.is_ascii() {
return vec![unicode_char(ch)];
}
match ch {
'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => vec!['A'],
'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => vec!['a'],
'Ç' => vec!['C'],
'ç' => vec!['c'],
'È' | 'É' | 'Ê' | 'Ë' => vec!['E'],
'è' | 'é' | 'ê' | 'ë' => vec!['e'],
'Ì' | 'Í' | 'Î' | 'Ï' => vec!['I'],
'ì' | 'í' | 'î' | 'ï' => vec!['i'],
'Ñ' => vec!['N'],
'ñ' => vec!['n'],
'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => vec!['O'],
'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' => vec!['o'],
'Ù' | 'Ú' | 'Û' | 'Ü' => vec!['U'],
'ù' | 'ú' | 'û' | 'ü' => vec!['u'],
'Ý' | 'Ÿ' => vec!['Y'],
'ý' | 'ÿ' => vec!['y'],
'Æ' => vec!['A', 'E'],
'æ' => vec!['a', 'e'],
'Œ' => vec!['O', 'E'],
'œ' => vec!['o', 'e'],
'ß' => vec!['s', 's'],
_ => vec![' '],
}
}
fn truncate_chars(value: &str, max_len: usize) -> String {
value.chars().take(max_len).collect()
}
fn non_blank(value: &str) -> Option<&str> {
let trimmed = value.trim();
(!trimmed.is_empty()).then_some(trimmed)
}
fn is_reserved_name(value: &str) -> bool {
let stem = value.split('.').next().unwrap_or(value);
matches!(
stem.to_ascii_uppercase().as_str(),
"CON"
| "PRN"
| "AUX"
| "NUL"
| "COM1"
| "COM2"
| "COM3"
| "COM4"
| "COM5"
| "COM6"
| "COM7"
| "COM8"
| "COM9"
| "LPT1"
| "LPT2"
| "LPT3"
| "LPT4"
| "LPT5"
| "LPT6"
| "LPT7"
| "LPT8"
| "LPT9"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lineage::{EdgeType, ResolveStatus};
use std::collections::{BTreeMap, BTreeSet};
fn test_clip(id: &str, title: &str) -> Clip {
Clip {
id: id.to_string(),
title: title.to_string(),
display_name: "München".to_string(),
handle: "munchen".to_string(),
album_title: String::new(),
root_ancestor_id: String::new(),
..Clip::default()
}
}
fn render_own(clip: &Clip, config: &NamingConfig) -> RenderedName {
let lineage = LineageContext::own_root(clip);
render_clip_name(
NamingRequest {
clip,
lineage: &lineage,
},
config,
)
}
fn render_all_own(
clips: &[Clip],
config: &NamingConfig,
colliding: &BTreeSet<String>,
) -> Vec<RenderedName> {
let lineages: Vec<LineageContext> = clips.iter().map(LineageContext::own_root).collect();
let requests: Vec<NamingRequest> = clips
.iter()
.zip(&lineages)
.map(|(clip, lineage)| NamingRequest { clip, lineage })
.collect();
render_clip_names(&requests, config, colliding)
}
#[test]
fn unicode_names_are_preserved_and_ascii_falls_back() {
let clip = test_clip("abc12345", "Beyoncé/東京");
let unicode = render_own(&clip, &NamingConfig::default());
assert_eq!(
unicode.relative_path.to_string_lossy(),
"München/Beyoncé 東京/München-Beyoncé 東京 [abc12345]"
);
let ascii = render_own(
&clip,
&NamingConfig {
character_set: CharacterSet::Ascii,
..NamingConfig::default()
},
);
assert_eq!(
ascii.relative_path.to_string_lossy(),
"Munchen/Beyonce/Munchen-Beyonce [abc12345]"
);
}
#[test]
fn reserved_and_hostile_names_are_sanitised() {
let clip = Clip {
id: "deadbeef".to_string(),
title: "CON<>:\"/\\|?*.".to_string(),
display_name: "AUX".to_string(),
..Clip::default()
};
let rendered = render_own(&clip, &NamingConfig::default());
let path = rendered.relative_path.to_string_lossy();
assert!(path.starts_with("AUX_/CON_/"), "path was {path}");
assert!(rendered.base_name.contains("[deadbeef]"));
}
#[test]
fn default_template_always_embeds_id8() {
let clip = test_clip("abcdef1234567890", "Any Title");
let rendered = render_own(&clip, &NamingConfig::default());
assert!(
rendered.base_name.contains("[abcdef12]"),
"base_name was {}",
rendered.base_name
);
}
#[test]
fn blank_titles_use_a_stable_suffix() {
let clip = test_clip("12345678-clip", " ");
let rendered = render_own(&clip, &NamingConfig::default());
assert_eq!(rendered.base_name, "München-Untitled [12345678]");
assert_eq!(
rendered.relative_path.to_string_lossy(),
"München/Untitled/München-Untitled [12345678]"
);
}
#[test]
fn very_long_titles_are_trimmed() {
let clip = test_clip("abcdef12", &"a".repeat(120));
let rendered = render_own(
&clip,
&NamingConfig {
max_component_len: 24,
..NamingConfig::default()
},
);
for component in rendered.relative_path.components() {
let text = component.as_os_str().to_string_lossy();
assert!(
text.chars().count() <= 24,
"component {text:?} exceeds 24 chars"
);
}
}
#[test]
fn same_title_siblings_stay_distinct_via_id8() {
let lineage = LineageContext {
root_id: "root-9".to_string(),
root_title: "Origin".to_string(),
parent_id: "root-9".to_string(),
edge_type: Some(EdgeType::Cover),
status: ResolveStatus::Resolved,
};
let first = test_clip("11111111-alpha", "Shared");
let second = test_clip("22222222-beta", "Shared");
let requests = [
NamingRequest {
clip: &first,
lineage: &lineage,
},
NamingRequest {
clip: &second,
lineage: &lineage,
},
];
let names = render_clip_names(&requests, &NamingConfig::default(), &BTreeSet::new());
assert_eq!(
names[0].relative_path.to_string_lossy(),
"München/Origin/München-Shared [11111111]"
);
assert_eq!(
names[1].relative_path.to_string_lossy(),
"München/Origin/München-Shared [22222222]"
);
}
#[test]
fn id8_prefix_collision_falls_back_to_full_id() {
let config = NamingConfig {
template: "{creator}/{title}".to_string(),
..NamingConfig::default()
};
let first = test_clip("abcd1234-first", "Untitled");
let second = test_clip("abcd1234-second", "Untitled");
let names = render_all_own(&[first.clone(), second.clone()], &config, &BTreeSet::new());
let swapped = render_all_own(&[second.clone(), first.clone()], &config, &BTreeSet::new());
assert_ne!(
names[0].relative_path.to_string_lossy(),
names[1].relative_path.to_string_lossy()
);
let ordered = |rendered: &[RenderedName], clips: &[Clip]| {
clips
.iter()
.zip(rendered)
.map(|(clip, name)| {
(
clip.id.clone(),
name.relative_path.to_string_lossy().into_owned(),
)
})
.collect::<BTreeMap<_, _>>()
};
assert_eq!(
ordered(&names, &[first.clone(), second.clone()]),
ordered(&swapped, &[second, first])
);
}
#[test]
fn album_is_root_title_for_a_remix() {
let clip = Clip {
id: "child".to_string(),
title: "Remix".to_string(),
display_name: "München".to_string(),
..Clip::default()
};
let lineage = LineageContext {
root_id: "root-1".to_string(),
root_title: "Original".to_string(),
parent_id: "root-1".to_string(),
edge_type: Some(EdgeType::Cover),
status: ResolveStatus::Resolved,
};
let rendered = render_clip_name(
NamingRequest {
clip: &clip,
lineage: &lineage,
},
&NamingConfig::default(),
);
assert_eq!(
rendered.relative_path.to_string_lossy(),
"München/Original/München-Remix [child]"
);
}
#[test]
fn album_is_own_title_for_a_root() {
let clip = Clip {
id: "root-1".to_string(),
title: "Original".to_string(),
display_name: "München".to_string(),
..Clip::default()
};
let rendered = render_own(&clip, &NamingConfig::default());
assert_eq!(
rendered.relative_path.to_string_lossy(),
"München/Original/München-Original [root-1]"
);
}
#[test]
fn shared_album_title_from_distinct_roots_is_disambiguated() {
let first = Clip {
id: "aaaa1111-x".to_string(),
title: "Break Through".to_string(),
display_name: "München".to_string(),
..Clip::default()
};
let second = Clip {
id: "bbbb2222-y".to_string(),
title: "Break Through".to_string(),
display_name: "München".to_string(),
..Clip::default()
};
let colliding: BTreeSet<String> = ["Break Through".to_string()].into_iter().collect();
let names = render_all_own(
&[first.clone(), second.clone()],
&NamingConfig::default(),
&colliding,
);
let swapped = render_all_own(
&[second.clone(), first.clone()],
&NamingConfig::default(),
&colliding,
);
let album_of = |rendered: &RenderedName| {
rendered
.relative_path
.components()
.nth(1)
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.unwrap_or_default()
};
assert_eq!(album_of(&names[0]), "Break Through [aaaa1111]");
assert_eq!(album_of(&names[1]), "Break Through [bbbb2222]");
assert_eq!(album_of(&swapped[0]), "Break Through [bbbb2222]");
assert_eq!(album_of(&swapped[1]), "Break Through [aaaa1111]");
let alone = render_all_own(
std::slice::from_ref(&first),
&NamingConfig::default(),
&colliding,
);
assert_eq!(album_of(&alone[0]), "Break Through [aaaa1111]");
}
#[test]
fn unique_root_title_stays_a_bare_album() {
let clip = Clip {
id: "solo-1".to_string(),
title: "Solo".to_string(),
display_name: "München".to_string(),
..Clip::default()
};
let names = render_all_own(&[clip], &NamingConfig::default(), &BTreeSet::new());
assert_eq!(
names[0].relative_path.to_string_lossy(),
"München/Solo/München-Solo [solo-1]"
);
}
#[test]
fn sanitise_name_strips_separators_and_falls_back_when_empty() {
assert_eq!(sanitise_name("Road/Trip: 2024"), "Road Trip 2024");
assert_eq!(sanitise_name(""), "playlist");
assert_eq!(sanitise_name("///"), "playlist");
}
}