Skip to main content

max / audiofiles

6.7 KB · 237 lines History Blame Raw
1 //! Shared path and file utilities used across audiofiles crates.
2
3 use std::path::Path;
4
5 /// Audio file extensions recognised throughout audiofiles.
6 pub const AUDIO_EXTENSIONS: &[&str] = &["wav", "flac", "mp3", "ogg", "aiff", "aif"];
7
8 /// Get the lowercase file extension, or empty string if none.
9 pub fn get_extension(path: &Path) -> String {
10 path.extension()
11 .and_then(|e| e.to_str())
12 .unwrap_or("")
13 .to_lowercase()
14 }
15
16 /// Extract the filename as a string, with a fallback default.
17 pub fn get_filename(path: &Path, default: &str) -> String {
18 path.file_name()
19 .and_then(|n| n.to_str())
20 .unwrap_or(default)
21 .to_string()
22 }
23
24 /// Split a filename into its stem and extension at the last dot.
25 ///
26 /// Returns `("name", "ext")` for `"name.ext"`, and `("name", "")` for
27 /// extensionless names. Dotfiles like `".hidden"` are treated as having no
28 /// extension (the leading dot is part of the stem, not a separator) because
29 /// `rfind('.')` at position 0 is excluded by the `pos > 0` guard.
30 pub fn split_name_ext(filename: &str) -> (String, String) {
31 match filename.rfind('.') {
32 Some(pos) if pos > 0 => (filename[..pos].to_string(), filename[pos + 1..].to_string()),
33 _ => (filename.to_string(), String::new()),
34 }
35 }
36
37 /// Check whether a path has an audio file extension.
38 ///
39 /// Rejects macOS resource fork sidecar files (`._*.wav` etc.) which carry an
40 /// audio extension but contain binary metadata, not audio data. These files
41 /// are invisible on macOS but appear as regular files on Linux.
42 pub fn is_audio_file(path: &Path) -> bool {
43 if is_macos_resource_fork(path) {
44 return false;
45 }
46 let ext = get_extension(path);
47 AUDIO_EXTENSIONS.contains(&ext.as_str())
48 }
49
50 /// Returns `true` for macOS resource fork sidecar files (`._*`) and `.DS_Store`.
51 ///
52 /// These are metadata files that macOS creates alongside real files. They often
53 /// survive in zip archives and extracted folders transferred to Linux, where
54 /// they are visible as regular files.
55 pub fn is_macos_resource_fork(path: &Path) -> bool {
56 let name = path
57 .file_name()
58 .and_then(|n| n.to_str())
59 .unwrap_or("");
60 name.starts_with("._") || name == ".DS_Store"
61 }
62
63 /// Returns `true` for macOS system directories that should be skipped during
64 /// directory traversal (e.g. `__MACOSX` from zip extraction, Spotlight indexes).
65 pub fn is_macos_metadata_dir(path: &Path) -> bool {
66 let name = path
67 .file_name()
68 .and_then(|n| n.to_str())
69 .unwrap_or("");
70 matches!(name, "__MACOSX" | ".Spotlight-V100" | ".fseventsd" | ".Trashes")
71 }
72
73 #[cfg(test)]
74 mod tests {
75 use super::*;
76 use std::path::Path;
77
78 #[test]
79 fn get_extension_wav() {
80 assert_eq!(get_extension(Path::new("kick.wav")), "wav");
81 }
82
83 #[test]
84 fn get_extension_uppercase() {
85 assert_eq!(get_extension(Path::new("kick.WAV")), "wav");
86 }
87
88 #[test]
89 fn get_extension_no_ext() {
90 assert_eq!(get_extension(Path::new("noext")), "");
91 }
92
93 #[test]
94 fn get_extension_dotfile() {
95 assert_eq!(get_extension(Path::new(".hidden")), "");
96 }
97
98 #[test]
99 fn get_extension_double_dot() {
100 assert_eq!(get_extension(Path::new("file.tar.gz")), "gz");
101 }
102
103 #[test]
104 fn get_filename_normal() {
105 assert_eq!(get_filename(Path::new("/home/user/kick.wav"), "unknown"), "kick.wav");
106 }
107
108 #[test]
109 fn get_filename_root_path() {
110 assert_eq!(get_filename(Path::new("/"), "fallback"), "fallback");
111 }
112
113 #[test]
114 fn get_filename_empty_path() {
115 assert_eq!(get_filename(Path::new(""), "default"), "default");
116 }
117
118 #[test]
119 fn is_audio_file_wav() {
120 assert!(is_audio_file(Path::new("kick.wav")));
121 }
122
123 #[test]
124 fn is_audio_file_flac_uppercase() {
125 assert!(is_audio_file(Path::new("pad.FLAC")));
126 }
127
128 #[test]
129 fn is_audio_file_mp3() {
130 assert!(is_audio_file(Path::new("song.mp3")));
131 }
132
133 #[test]
134 fn is_audio_file_ogg() {
135 assert!(is_audio_file(Path::new("loop.ogg")));
136 }
137
138 #[test]
139 fn is_audio_file_aiff() {
140 assert!(is_audio_file(Path::new("strings.aiff")));
141 }
142
143 #[test]
144 fn is_audio_file_aif() {
145 assert!(is_audio_file(Path::new("brass.AIF")));
146 }
147
148 #[test]
149 fn is_audio_file_txt_rejected() {
150 assert!(!is_audio_file(Path::new("readme.txt")));
151 }
152
153 #[test]
154 fn is_audio_file_png_rejected() {
155 assert!(!is_audio_file(Path::new("photo.png")));
156 }
157
158 #[test]
159 fn is_audio_file_no_ext_rejected() {
160 assert!(!is_audio_file(Path::new("noext")));
161 }
162
163 #[test]
164 fn split_name_ext_normal() {
165 let (stem, ext) = split_name_ext("file.wav");
166 assert_eq!(stem, "file");
167 assert_eq!(ext, "wav");
168 }
169
170 #[test]
171 fn split_name_ext_no_extension() {
172 let (stem, ext) = split_name_ext("file");
173 assert_eq!(stem, "file");
174 assert_eq!(ext, "");
175 }
176
177 #[test]
178 fn split_name_ext_multiple_dots() {
179 let (stem, ext) = split_name_ext("my.file.wav");
180 assert_eq!(stem, "my.file");
181 assert_eq!(ext, "wav");
182 }
183
184 // --- macOS resource fork / metadata filtering ---
185
186 #[test]
187 fn resource_fork_wav_rejected() {
188 assert!(!is_audio_file(Path::new("._kick.wav")));
189 }
190
191 #[test]
192 fn resource_fork_aiff_rejected() {
193 assert!(!is_audio_file(Path::new("._strings.aiff")));
194 }
195
196 #[test]
197 fn resource_fork_flac_rejected() {
198 assert!(!is_audio_file(Path::new("._pad.flac")));
199 }
200
201 #[test]
202 fn resource_fork_in_subdir_rejected() {
203 assert!(!is_audio_file(Path::new("/samples/drums/._snare.wav")));
204 }
205
206 #[test]
207 fn resource_fork_detected() {
208 assert!(is_macos_resource_fork(Path::new("._kick.wav")));
209 assert!(is_macos_resource_fork(Path::new("/path/to/._file.aiff")));
210 assert!(is_macos_resource_fork(Path::new(".DS_Store")));
211 }
212
213 #[test]
214 fn normal_files_not_resource_fork() {
215 assert!(!is_macos_resource_fork(Path::new("kick.wav")));
216 assert!(!is_macos_resource_fork(Path::new(".hidden.wav")));
217 assert!(!is_macos_resource_fork(Path::new("my_file.flac")));
218 }
219
220 #[test]
221 fn macos_metadata_dirs_detected() {
222 assert!(is_macos_metadata_dir(Path::new("__MACOSX")));
223 assert!(is_macos_metadata_dir(Path::new("/path/to/__MACOSX")));
224 assert!(is_macos_metadata_dir(Path::new(".Spotlight-V100")));
225 assert!(is_macos_metadata_dir(Path::new(".fseventsd")));
226 assert!(is_macos_metadata_dir(Path::new(".Trashes")));
227 }
228
229 #[test]
230 fn normal_dirs_not_metadata() {
231 assert!(!is_macos_metadata_dir(Path::new("samples")));
232 assert!(!is_macos_metadata_dir(Path::new("drums")));
233 assert!(!is_macos_metadata_dir(Path::new(".hidden_dir")));
234 }
235
236 }
237