1use crate::MediaSorts;
2use alux_ext::ext;
3use ambassador::delegatable_trait;
4use std::path::PathBuf;
5
6#[delegatable_trait]
8pub trait OutputNameAlg: MediaSorts {
9 fn output_name(&self, component: String) -> Self::Output;
11}
12
13#[ext(name = OutputNameExt)]
15pub impl<This> This
16where
17 This: OutputNameAlg,
18{
19 fn portable_output_name(&self, title: Option<&str>, fallback: &str, extension: Option<&str>) -> This::Output {
21 self.output_name(portable_file_component(title, fallback, extension))
22 }
23
24 fn portable_user_output_name(&self, input: &str, fallback_extension: Option<&str>) -> This::Output {
26 self.output_name(portable_user_file_component(input, fallback_extension))
27 }
28}
29
30const MAX_STEM_BYTES: usize = 200;
31
32#[must_use]
34pub fn portable_file_stem(title: Option<&str>, fallback: &str) -> String {
35 let candidate = title.filter(|title| !title.trim().is_empty()).unwrap_or(fallback);
36 let mut stem = String::new();
37 let mut whitespace = false;
38 for character in candidate.chars() {
39 let character =
40 if character.is_control() || matches!(character, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
41 '_'
42 } else if character.is_whitespace() {
43 whitespace = true;
44 continue;
45 } else {
46 character
47 };
48 if whitespace && !stem.is_empty() && stem.len() < MAX_STEM_BYTES {
49 stem.push(' ');
50 }
51 whitespace = false;
52 if stem.len().saturating_add(character.len_utf8()) > MAX_STEM_BYTES {
53 break;
54 }
55 stem.push(character);
56 }
57 let stem = stem.trim_matches([' ', '.']);
58 let stem = if stem.is_empty() { "download" } else { stem };
59 if is_windows_reserved(stem) { format!("_{stem}") } else { stem.to_owned() }
60}
61
62#[must_use]
64pub fn portable_file_name(title: Option<&str>, fallback: &str, extension: Option<&str>) -> PathBuf {
65 PathBuf::from(portable_file_component(title, fallback, extension))
66}
67
68#[must_use]
70pub fn portable_user_file_name(input: &str, fallback_extension: Option<&str>) -> PathBuf {
71 PathBuf::from(portable_user_file_component(input, fallback_extension))
72}
73
74fn portable_user_file_component(input: &str, fallback_extension: Option<&str>) -> String {
76 let trimmed = input.trim();
77 let (stem, extension) = trimmed.rsplit_once('.').map_or((trimmed, fallback_extension), |(stem, extension)| {
78 if valid_extension(extension) { (stem, Some(extension)) } else { (trimmed, fallback_extension) }
79 });
80 portable_file_component(Some(stem), "download", extension)
81}
82
83fn portable_file_component(title: Option<&str>, fallback: &str, extension: Option<&str>) -> String {
84 let stem = portable_file_stem(title, fallback);
85 let extension = extension.filter(|extension| valid_extension(extension)).unwrap_or("bin");
86 format!("{stem}.{extension}")
87}
88
89fn valid_extension(extension: &str) -> bool {
90 !extension.is_empty() && extension.chars().all(|character| character.is_ascii_alphanumeric())
91}
92
93fn is_windows_reserved(stem: &str) -> bool {
94 let basename = stem.split('.').next().unwrap_or(stem).to_ascii_uppercase();
95 matches!(basename.as_str(), "CON" | "PRN" | "AUX" | "NUL")
96 || basename
97 .strip_prefix("COM")
98 .or_else(|| basename.strip_prefix("LPT"))
99 .is_some_and(|number| matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
100}
101
102#[cfg(test)]
103mod tests {
104 use super::{portable_file_name, portable_file_stem, portable_user_file_name};
105 use std::path::PathBuf;
106
107 #[test]
108 fn title_name_is_portable_across_linux_and_windows() {
109 assert_eq!(portable_file_name(Some(" A/B: C? "), "id", Some("mp4")), PathBuf::from("A_B_ C_.mp4"));
110 assert_eq!(portable_file_stem(Some("CON.txt"), "id"), "_CON.txt");
111 assert_eq!(portable_file_stem(Some("..."), "id"), "download");
112 }
113
114 #[test]
115 fn absent_title_uses_the_media_identity() {
116 assert_eq!(portable_file_name(None, "video/id", None), PathBuf::from("video_id.bin"));
117 }
118
119 #[test]
120 fn edited_name_preserves_a_portable_extension() {
121 assert_eq!(portable_user_file_name("My: video.webm", Some("mp4")), PathBuf::from("My_ video.webm"));
122 assert_eq!(portable_user_file_name("My video", Some("mp4")), PathBuf::from("My video.mp4"));
123 }
124}