//! Shared path and file utilities used across audiofiles crates. use std::path::Path; /// Audio file extensions recognised throughout audiofiles. /// /// `bwf` is WAV with a `bext` chunk; symphonia decodes it as WAV. /// `m4a`/`alac` route through symphonia's `isomp4` format with `aac`/`alac` /// codecs. `caf` uses the `caf` format with `pcm`/`alac`. /// /// Missing on purpose: `opus` (no symphonia codec), `w64` (Wave64; no /// symphonia format). Add when upstream support lands or we swap decoders. pub const AUDIO_EXTENSIONS: &[&str] = &[ "wav", "flac", "mp3", "ogg", "aiff", "aif", "m4a", "alac", "caf", "bwf", ]; /// Get the lowercase file extension, or empty string if none. pub fn get_extension(path: &Path) -> String { path.extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase() } /// Extract the filename as a string, with a fallback default. pub fn get_filename(path: &Path, default: &str) -> String { path.file_name() .and_then(|n| n.to_str()) .unwrap_or(default) .to_string() } /// Split a filename into its stem and extension at the last dot. /// /// Returns `("name", "ext")` for `"name.ext"`, and `("name", "")` for /// extensionless names. Dotfiles like `".hidden"` are treated as having no /// extension (the leading dot is part of the stem, not a separator) because /// `rfind('.')` at position 0 is excluded by the `pos > 0` guard. pub fn split_name_ext(filename: &str) -> (String, String) { match filename.rfind('.') { Some(pos) if pos > 0 => (filename[..pos].to_string(), filename[pos + 1..].to_string()), _ => (filename.to_string(), String::new()), } } /// Check whether a path has an audio file extension. /// /// Rejects macOS resource fork sidecar files (`._*.wav` etc.) which carry an /// audio extension but contain binary metadata, not audio data. These files /// are invisible on macOS but appear as regular files on Linux. pub fn is_audio_file(path: &Path) -> bool { if is_macos_resource_fork(path) { return false; } let ext = get_extension(path); AUDIO_EXTENSIONS.contains(&ext.as_str()) } /// Returns `true` for macOS resource fork sidecar files (`._*`) and `.DS_Store`. /// /// These are metadata files that macOS creates alongside real files. They often /// survive in zip archives and extracted folders transferred to Linux, where /// they are visible as regular files. pub fn is_macos_resource_fork(path: &Path) -> bool { let name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or(""); name.starts_with("._") || name == ".DS_Store" } /// Returns `true` for macOS system directories that should be skipped during /// directory traversal (e.g. `__MACOSX` from zip extraction, Spotlight indexes). pub fn is_macos_metadata_dir(path: &Path) -> bool { let name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or(""); matches!(name, "__MACOSX" | ".Spotlight-V100" | ".fseventsd" | ".Trashes") } #[cfg(test)] mod tests { use super::*; use std::path::Path; #[test] fn get_extension_wav() { assert_eq!(get_extension(Path::new("kick.wav")), "wav"); } #[test] fn get_extension_uppercase() { assert_eq!(get_extension(Path::new("kick.WAV")), "wav"); } #[test] fn get_extension_no_ext() { assert_eq!(get_extension(Path::new("noext")), ""); } #[test] fn get_extension_dotfile() { assert_eq!(get_extension(Path::new(".hidden")), ""); } #[test] fn get_extension_double_dot() { assert_eq!(get_extension(Path::new("file.tar.gz")), "gz"); } #[test] fn get_filename_normal() { assert_eq!(get_filename(Path::new("/home/user/kick.wav"), "unknown"), "kick.wav"); } #[test] fn get_filename_root_path() { assert_eq!(get_filename(Path::new("/"), "fallback"), "fallback"); } #[test] fn get_filename_empty_path() { assert_eq!(get_filename(Path::new(""), "default"), "default"); } #[test] fn is_audio_file_wav() { assert!(is_audio_file(Path::new("kick.wav"))); } #[test] fn is_audio_file_flac_uppercase() { assert!(is_audio_file(Path::new("pad.FLAC"))); } #[test] fn is_audio_file_mp3() { assert!(is_audio_file(Path::new("song.mp3"))); } #[test] fn is_audio_file_ogg() { assert!(is_audio_file(Path::new("loop.ogg"))); } #[test] fn is_audio_file_aiff() { assert!(is_audio_file(Path::new("strings.aiff"))); } #[test] fn is_audio_file_aif() { assert!(is_audio_file(Path::new("brass.AIF"))); } #[test] fn is_audio_file_extended_formats() { assert!(is_audio_file(Path::new("vocal.m4a"))); assert!(is_audio_file(Path::new("loop.alac"))); assert!(is_audio_file(Path::new("snare.caf"))); assert!(is_audio_file(Path::new("field.bwf"))); assert!(is_audio_file(Path::new("FIELD.BWF"))); } #[test] fn is_audio_file_txt_rejected() { assert!(!is_audio_file(Path::new("readme.txt"))); } #[test] fn is_audio_file_png_rejected() { assert!(!is_audio_file(Path::new("photo.png"))); } #[test] fn is_audio_file_no_ext_rejected() { assert!(!is_audio_file(Path::new("noext"))); } #[test] fn split_name_ext_normal() { let (stem, ext) = split_name_ext("file.wav"); assert_eq!(stem, "file"); assert_eq!(ext, "wav"); } #[test] fn split_name_ext_no_extension() { let (stem, ext) = split_name_ext("file"); assert_eq!(stem, "file"); assert_eq!(ext, ""); } #[test] fn split_name_ext_multiple_dots() { let (stem, ext) = split_name_ext("my.file.wav"); assert_eq!(stem, "my.file"); assert_eq!(ext, "wav"); } // --- macOS resource fork / metadata filtering --- #[test] fn resource_fork_wav_rejected() { assert!(!is_audio_file(Path::new("._kick.wav"))); } #[test] fn resource_fork_aiff_rejected() { assert!(!is_audio_file(Path::new("._strings.aiff"))); } #[test] fn resource_fork_flac_rejected() { assert!(!is_audio_file(Path::new("._pad.flac"))); } #[test] fn resource_fork_in_subdir_rejected() { assert!(!is_audio_file(Path::new("/samples/drums/._snare.wav"))); } #[test] fn resource_fork_detected() { assert!(is_macos_resource_fork(Path::new("._kick.wav"))); assert!(is_macos_resource_fork(Path::new("/path/to/._file.aiff"))); assert!(is_macos_resource_fork(Path::new(".DS_Store"))); } #[test] fn normal_files_not_resource_fork() { assert!(!is_macos_resource_fork(Path::new("kick.wav"))); assert!(!is_macos_resource_fork(Path::new(".hidden.wav"))); assert!(!is_macos_resource_fork(Path::new("my_file.flac"))); } #[test] fn macos_metadata_dirs_detected() { assert!(is_macos_metadata_dir(Path::new("__MACOSX"))); assert!(is_macos_metadata_dir(Path::new("/path/to/__MACOSX"))); assert!(is_macos_metadata_dir(Path::new(".Spotlight-V100"))); assert!(is_macos_metadata_dir(Path::new(".fseventsd"))); assert!(is_macos_metadata_dir(Path::new(".Trashes"))); } #[test] fn normal_dirs_not_metadata() { assert!(!is_macos_metadata_dir(Path::new("samples"))); assert!(!is_macos_metadata_dir(Path::new("drums"))); assert!(!is_macos_metadata_dir(Path::new(".hidden_dir"))); } }