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(&self, title: Option<&str>, fallback: &str, extension: Option<&str>) -> This::Output {
21        self.output_name(portable_file_component(title, fallback, extension))
22    }
23
24    /// Defines a portable edited file name.
25    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/// Derives a portable single-component file stem from a title and fallback identity.
33#[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/// Derives a portable file name from a title, fallback identity, and selected extension.
63#[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/// Normalizes an edited file name while preserving a valid explicit extension.
69#[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
74/// Normalizes an edited name into one portable file component.
75fn 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}