Skip to main content

rsynko_media/
output.rs

1use crate::MediaSorts;
2use alux_ext::ext;
3use ambassador::delegatable_trait;
4use std::path::PathBuf;
5
6/// Provides the carrier and constructor for one portable output name.
7#[delegatable_trait]
8pub trait OutputNameAlg: MediaSorts {
9    /// Defines an output from one already normalized file component.
10    fn output_name(&self, component: String) -> Self::Output;
11}
12
13/// Derives portable output names independently of their carrier.
14#[ext(name = OutputNameExt)]
15pub impl<This> This
16where
17    This: OutputNameAlg,
18{
19    /// Defines a portable file name from title, identity, and extension.
20    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    /// Defines a portable edited file name.
30    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/// Derives a portable single-component file stem from a title and fallback identity.
42#[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/// Derives a portable file name from a title, fallback identity, and selected extension.
81#[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/// Normalizes an edited file name while preserving a valid explicit extension.
87#[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
92/// Normalizes an edited name into one portable file component.
93fn 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}