//! Filename sanitizer for device-aware export. //! //! Applies [`NamingRules`] to produce filenames safe for hardware samplers: //! case conversion, separator normalization, special character stripping, //! and length truncation. use super::profile::{NamingCase, NamingRules}; /// Sanitize a filename stem according to device naming rules. /// /// This operates on the stem only (no extension). The caller is responsible /// for appending the file extension after sanitization. pub fn sanitize_filename(name: &str, rules: &NamingRules) -> String { let mut result = String::with_capacity(name.len()); for ch in name.chars() { if ch.is_ascii_alphanumeric() { result.push(ch); } else if ch == ' ' || ch == '-' || ch == '_' || ch == '.' { // Normalize word separators to the configured separator // Avoid consecutive separators if !result.ends_with(rules.separator) { result.push(rules.separator); } } else if !rules.strip_special { result.push(ch); } // If strip_special is true, non-alphanum non-separator chars are dropped } // Trim leading/trailing separators let trimmed = result.trim_matches(rules.separator); let mut result = trimmed.to_string(); // Apply case result = match rules.case { NamingCase::Lower => result.to_lowercase(), NamingCase::Upper => result.to_uppercase(), NamingCase::Original => result, }; // Fall back to "untitled" if sanitization produced an empty stem if result.is_empty() { result = "untitled".to_string(); } // Truncate to max_length if result.len() > rules.max_length { // Truncate at char boundary let mut end = rules.max_length; while !result.is_char_boundary(end) && end > 0 { end -= 1; } // Don't end on a separator let truncated = result[..end].trim_end_matches(rules.separator); result = truncated.to_string(); } result } #[cfg(test)] mod tests { use super::*; fn rules(case: NamingCase, sep: char, max_len: usize, strip: bool) -> NamingRules { NamingRules { case, separator: sep, max_length: max_len, strip_special: strip, } } #[test] fn basic_uppercase() { let r = rules(NamingCase::Upper, '_', 12, true); assert_eq!(sanitize_filename("my kick 01", &r), "MY_KICK_01"); } #[test] fn basic_lowercase() { let r = rules(NamingCase::Lower, '_', 64, true); assert_eq!(sanitize_filename("My_Sample", &r), "my_sample"); } #[test] fn strips_special_chars() { let r = rules(NamingCase::Original, '_', 64, true); assert_eq!(sanitize_filename("kick (boom)!!", &r), "kick_boom"); } #[test] fn keeps_special_when_not_stripping() { let r = rules(NamingCase::Original, '_', 64, false); assert_eq!(sanitize_filename("kick(v2)", &r), "kick(v2)"); } #[test] fn truncates_to_max_length() { let r = rules(NamingCase::Upper, '_', 8, true); assert_eq!(sanitize_filename("very long sample name", &r), "VERY_LON"); } #[test] fn truncate_avoids_trailing_separator() { let r = rules(NamingCase::Upper, '_', 5, true); // "AB_CD_EF" truncated at 5 = "AB_CD", but if it were "AB_C_" we'd trim the trailing _ assert_eq!(sanitize_filename("ab cd ef", &r), "AB_CD"); } #[test] fn collapses_consecutive_separators() { let r = rules(NamingCase::Lower, '_', 64, true); assert_eq!(sanitize_filename("a - - b", &r), "a_b"); } #[test] fn empty_input() { let r = rules(NamingCase::Lower, '_', 64, true); assert_eq!(sanitize_filename("", &r), "untitled"); } #[test] fn only_special_chars_stripped() { let r = rules(NamingCase::Lower, '_', 64, true); assert_eq!(sanitize_filename("!!!", &r), "untitled"); } #[test] fn dot_separator_in_name() { let r = rules(NamingCase::Upper, '_', 12, true); assert_eq!(sanitize_filename("kick.v2.hard", &r), "KICK_V2_HARD"); } }