Skip to main content

max / audiofiles

4.1 KB · 136 lines History Blame Raw
1 //! Filename sanitizer for device-aware export.
2 //!
3 //! Applies [`NamingRules`] to produce filenames safe for hardware samplers:
4 //! case conversion, separator normalization, special character stripping,
5 //! and length truncation.
6
7 use super::profile::{NamingCase, NamingRules};
8
9 /// Sanitize a filename stem according to device naming rules.
10 ///
11 /// This operates on the stem only (no extension). The caller is responsible
12 /// for appending the file extension after sanitization.
13 pub fn sanitize_filename(name: &str, rules: &NamingRules) -> String {
14 let mut result = String::with_capacity(name.len());
15
16 for ch in name.chars() {
17 if ch.is_ascii_alphanumeric() {
18 result.push(ch);
19 } else if ch == ' ' || ch == '-' || ch == '_' || ch == '.' {
20 // Normalize word separators to the configured separator
21 // Avoid consecutive separators
22 if !result.ends_with(rules.separator) {
23 result.push(rules.separator);
24 }
25 } else if !rules.strip_special {
26 result.push(ch);
27 }
28 // If strip_special is true, non-alphanum non-separator chars are dropped
29 }
30
31 // Trim leading/trailing separators
32 let trimmed = result.trim_matches(rules.separator);
33 let mut result = trimmed.to_string();
34
35 // Apply case
36 result = match rules.case {
37 NamingCase::Lower => result.to_lowercase(),
38 NamingCase::Upper => result.to_uppercase(),
39 NamingCase::Original => result,
40 };
41
42 // Fall back to "untitled" if sanitization produced an empty stem
43 if result.is_empty() {
44 result = "untitled".to_string();
45 }
46
47 // Truncate to max_length
48 if result.len() > rules.max_length {
49 // Truncate at char boundary
50 let mut end = rules.max_length;
51 while !result.is_char_boundary(end) && end > 0 {
52 end -= 1;
53 }
54 // Don't end on a separator
55 let truncated = result[..end].trim_end_matches(rules.separator);
56 result = truncated.to_string();
57 }
58
59 result
60 }
61
62 #[cfg(test)]
63 mod tests {
64 use super::*;
65
66 fn rules(case: NamingCase, sep: char, max_len: usize, strip: bool) -> NamingRules {
67 NamingRules {
68 case,
69 separator: sep,
70 max_length: max_len,
71 strip_special: strip,
72 }
73 }
74
75 #[test]
76 fn basic_uppercase() {
77 let r = rules(NamingCase::Upper, '_', 12, true);
78 assert_eq!(sanitize_filename("my kick 01", &r), "MY_KICK_01");
79 }
80
81 #[test]
82 fn basic_lowercase() {
83 let r = rules(NamingCase::Lower, '_', 64, true);
84 assert_eq!(sanitize_filename("My_Sample", &r), "my_sample");
85 }
86
87 #[test]
88 fn strips_special_chars() {
89 let r = rules(NamingCase::Original, '_', 64, true);
90 assert_eq!(sanitize_filename("kick (boom)!!", &r), "kick_boom");
91 }
92
93 #[test]
94 fn keeps_special_when_not_stripping() {
95 let r = rules(NamingCase::Original, '_', 64, false);
96 assert_eq!(sanitize_filename("kick(v2)", &r), "kick(v2)");
97 }
98
99 #[test]
100 fn truncates_to_max_length() {
101 let r = rules(NamingCase::Upper, '_', 8, true);
102 assert_eq!(sanitize_filename("very long sample name", &r), "VERY_LON");
103 }
104
105 #[test]
106 fn truncate_avoids_trailing_separator() {
107 let r = rules(NamingCase::Upper, '_', 5, true);
108 // "AB_CD_EF" truncated at 5 = "AB_CD", but if it were "AB_C_" we'd trim the trailing _
109 assert_eq!(sanitize_filename("ab cd ef", &r), "AB_CD");
110 }
111
112 #[test]
113 fn collapses_consecutive_separators() {
114 let r = rules(NamingCase::Lower, '_', 64, true);
115 assert_eq!(sanitize_filename("a - - b", &r), "a_b");
116 }
117
118 #[test]
119 fn empty_input() {
120 let r = rules(NamingCase::Lower, '_', 64, true);
121 assert_eq!(sanitize_filename("", &r), "untitled");
122 }
123
124 #[test]
125 fn only_special_chars_stripped() {
126 let r = rules(NamingCase::Lower, '_', 64, true);
127 assert_eq!(sanitize_filename("!!!", &r), "untitled");
128 }
129
130 #[test]
131 fn dot_separator_in_name() {
132 let r = rules(NamingCase::Upper, '_', 12, true);
133 assert_eq!(sanitize_filename("kick.v2.hard", &r), "KICK_V2_HARD");
134 }
135 }
136