use crate::MediaSorts;
use alux_ext::ext;
use ambassador::delegatable_trait;
use std::path::PathBuf;
#[delegatable_trait]
pub trait OutputNameAlg: MediaSorts {
fn output_name(&self, component: String) -> Self::Output;
}
#[ext(name = OutputNameExt)]
pub impl<This> This
where
This: OutputNameAlg,
{
fn portable_output_name(&self, title: Option<&str>, fallback: &str, extension: Option<&str>) -> This::Output {
self.output_name(portable_file_component(title, fallback, extension))
}
fn portable_user_output_name(&self, input: &str, fallback_extension: Option<&str>) -> This::Output {
self.output_name(portable_user_file_component(input, fallback_extension))
}
}
const MAX_STEM_BYTES: usize = 200;
#[must_use]
pub fn portable_file_stem(title: Option<&str>, fallback: &str) -> String {
let candidate = title.filter(|title| !title.trim().is_empty()).unwrap_or(fallback);
let mut stem = String::new();
let mut whitespace = false;
for character in candidate.chars() {
let character =
if character.is_control() || matches!(character, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
'_'
} else if character.is_whitespace() {
whitespace = true;
continue;
} else {
character
};
if whitespace && !stem.is_empty() && stem.len() < MAX_STEM_BYTES {
stem.push(' ');
}
whitespace = false;
if stem.len().saturating_add(character.len_utf8()) > MAX_STEM_BYTES {
break;
}
stem.push(character);
}
let stem = stem.trim_matches([' ', '.']);
let stem = if stem.is_empty() { "download" } else { stem };
if is_windows_reserved(stem) { format!("_{stem}") } else { stem.to_owned() }
}
#[must_use]
pub fn portable_file_name(title: Option<&str>, fallback: &str, extension: Option<&str>) -> PathBuf {
PathBuf::from(portable_file_component(title, fallback, extension))
}
#[must_use]
pub fn portable_user_file_name(input: &str, fallback_extension: Option<&str>) -> PathBuf {
PathBuf::from(portable_user_file_component(input, fallback_extension))
}
fn portable_user_file_component(input: &str, fallback_extension: Option<&str>) -> String {
let trimmed = input.trim();
let (stem, extension) = trimmed.rsplit_once('.').map_or((trimmed, fallback_extension), |(stem, extension)| {
if valid_extension(extension) { (stem, Some(extension)) } else { (trimmed, fallback_extension) }
});
portable_file_component(Some(stem), "download", extension)
}
fn portable_file_component(title: Option<&str>, fallback: &str, extension: Option<&str>) -> String {
let stem = portable_file_stem(title, fallback);
let extension = extension.filter(|extension| valid_extension(extension)).unwrap_or("bin");
format!("{stem}.{extension}")
}
fn valid_extension(extension: &str) -> bool {
!extension.is_empty() && extension.chars().all(|character| character.is_ascii_alphanumeric())
}
fn is_windows_reserved(stem: &str) -> bool {
let basename = stem.split('.').next().unwrap_or(stem).to_ascii_uppercase();
matches!(basename.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| basename
.strip_prefix("COM")
.or_else(|| basename.strip_prefix("LPT"))
.is_some_and(|number| matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
}
#[cfg(test)]
mod tests {
use super::{portable_file_name, portable_file_stem, portable_user_file_name};
use std::path::PathBuf;
#[test]
fn title_name_is_portable_across_linux_and_windows() {
assert_eq!(portable_file_name(Some(" A/B: C? "), "id", Some("mp4")), PathBuf::from("A_B_ C_.mp4"));
assert_eq!(portable_file_stem(Some("CON.txt"), "id"), "_CON.txt");
assert_eq!(portable_file_stem(Some("..."), "id"), "download");
}
#[test]
fn absent_title_uses_the_media_identity() {
assert_eq!(portable_file_name(None, "video/id", None), PathBuf::from("video_id.bin"));
}
#[test]
fn edited_name_preserves_a_portable_extension() {
assert_eq!(portable_user_file_name("My: video.webm", Some("mp4")), PathBuf::from("My_ video.webm"));
assert_eq!(portable_user_file_name("My video", Some("mp4")), PathBuf::from("My video.mp4"));
}
}