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(
21 &self,
22 title: Option<&str>,
23 fallback: &str,
24 extension: Option<&str>,
25 ) -> This::Output {
26 self.output_name(portable_file_component(title, fallback, extension))
27 }
28
29 fn portable_user_output_name(
31 &self,
32 input: &str,
33 fallback_extension: Option<&str>,
34 ) -> This::Output {
35 self.output_name(portable_user_file_component(input, fallback_extension))
36 }
37}
38
39const MAX_STEM_BYTES: usize = 200;
40
41#[must_use]
43pub fn portable_file_stem(title: Option<&str>, fallback: &str) -> String {
44 let candidate = title
45 .filter(|title| !title.trim().is_empty())
46 .unwrap_or(fallback);
47 let mut stem = String::new();
48 let mut whitespace = false;
49 for character in candidate.chars() {
50 let character = if character.is_control()
51 || matches!(
52 character,
53 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
54 ) {
55 '_'
56 } else if character.is_whitespace() {
57 whitespace = true;
58 continue;
59 } else {
60 character
61 };
62 if whitespace && !stem.is_empty() && stem.len() < MAX_STEM_BYTES {
63 stem.push(' ');
64 }
65 whitespace = false;
66 if stem.len().saturating_add(character.len_utf8()) > MAX_STEM_BYTES {
67 break;
68 }
69 stem.push(character);
70 }
71 let stem = stem.trim_matches([' ', '.']);
72 let stem = if stem.is_empty() { "download" } else { stem };
73 if is_windows_reserved(stem) {
74 format!("_{stem}")
75 } else {
76 stem.to_owned()
77 }
78}
79
80#[must_use]
82pub fn portable_file_name(title: Option<&str>, fallback: &str, extension: Option<&str>) -> PathBuf {
83 PathBuf::from(portable_file_component(title, fallback, extension))
84}
85
86#[must_use]
88pub fn portable_user_file_name(input: &str, fallback_extension: Option<&str>) -> PathBuf {
89 PathBuf::from(portable_user_file_component(input, fallback_extension))
90}
91
92fn portable_user_file_component(input: &str, fallback_extension: Option<&str>) -> String {
94 let trimmed = input.trim();
95 let (stem, extension) =
96 trimmed
97 .rsplit_once('.')
98 .map_or((trimmed, fallback_extension), |(stem, extension)| {
99 if valid_extension(extension) {
100 (stem, Some(extension))
101 } else {
102 (trimmed, fallback_extension)
103 }
104 });
105 portable_file_component(Some(stem), "download", extension)
106}
107
108fn portable_file_component(title: Option<&str>, fallback: &str, extension: Option<&str>) -> String {
109 let stem = portable_file_stem(title, fallback);
110 let extension = extension
111 .filter(|extension| valid_extension(extension))
112 .unwrap_or("bin");
113 format!("{stem}.{extension}")
114}
115
116fn valid_extension(extension: &str) -> bool {
117 !extension.is_empty()
118 && extension
119 .chars()
120 .all(|character| character.is_ascii_alphanumeric())
121}
122
123fn is_windows_reserved(stem: &str) -> bool {
124 let basename = stem.split('.').next().unwrap_or(stem).to_ascii_uppercase();
125 matches!(basename.as_str(), "CON" | "PRN" | "AUX" | "NUL")
126 || basename
127 .strip_prefix("COM")
128 .or_else(|| basename.strip_prefix("LPT"))
129 .is_some_and(|number| {
130 matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
131 })
132}
133
134#[cfg(test)]
135mod tests {
136 use super::{portable_file_name, portable_file_stem, portable_user_file_name};
137 use std::path::PathBuf;
138
139 #[test]
140 fn title_name_is_portable_across_linux_and_windows() {
141 assert_eq!(
142 portable_file_name(Some(" A/B: C? "), "id", Some("mp4")),
143 PathBuf::from("A_B_ C_.mp4")
144 );
145 assert_eq!(portable_file_stem(Some("CON.txt"), "id"), "_CON.txt");
146 assert_eq!(portable_file_stem(Some("..."), "id"), "download");
147 }
148
149 #[test]
150 fn absent_title_uses_the_media_identity() {
151 assert_eq!(
152 portable_file_name(None, "video/id", None),
153 PathBuf::from("video_id.bin")
154 );
155 }
156
157 #[test]
158 fn edited_name_preserves_a_portable_extension() {
159 assert_eq!(
160 portable_user_file_name("My: video.webm", Some("mp4")),
161 PathBuf::from("My_ video.webm")
162 );
163 assert_eq!(
164 portable_user_file_name("My video", Some("mp4")),
165 PathBuf::from("My video.mp4")
166 );
167 }
168}