//! Rename pattern engine: parse token patterns and resolve them into filenames. use std::collections::HashMap; use crate::error::{CoreError, Result}; /// Context values used to resolve tokens in a rename pattern. pub struct RenameContext { pub name: String, pub extension: String, pub bpm: Option, pub musical_key: Option, pub classification: Option, pub duration: Option, pub index: usize, } /// A parsed rename pattern ready to resolve against contexts. pub struct RenamePattern { segments: Vec, } enum Segment { Literal(String), Token(Token), } enum Token { Name, Ext, Bpm, Key, Class, Duration, Index, IndexPad2, IndexPad3, } impl RenamePattern { /// Parse a pattern string like `"{name}_{bpm}"`. /// /// Tokens: `{name}`, `{ext}`, `{bpm}`, `{key}`, `{class}`, `{duration}`, /// `{n}`, `{nn}`, `{nnn}`. pub fn parse(pattern: &str) -> Result { if pattern.contains('/') || pattern.contains('\\') { return Err(CoreError::RenameInvalid( "pattern cannot contain path separators".into(), )); } let mut segments = Vec::new(); let mut remaining = pattern; while let Some(open) = remaining.find('{') { if open > 0 { segments.push(Segment::Literal(remaining[..open].to_string())); } let after_open = &remaining[open + 1..]; let close = after_open.find('}').ok_or_else(|| { CoreError::RenameInvalid("unclosed '{' in pattern".into()) })?; let token_str = &after_open[..close]; let token = match token_str { "name" => Token::Name, "ext" => Token::Ext, "bpm" => Token::Bpm, "key" => Token::Key, "class" => Token::Class, "duration" => Token::Duration, "n" => Token::Index, "nn" => Token::IndexPad2, "nnn" => Token::IndexPad3, other => { return Err(CoreError::RenameInvalid(format!( "unknown token: {{{other}}}" ))) } }; segments.push(Segment::Token(token)); remaining = &after_open[close + 1..]; } if !remaining.is_empty() { segments.push(Segment::Literal(remaining.to_string())); } if segments.is_empty() { return Err(CoreError::RenameInvalid("pattern must not be empty".into())); } Ok(Self { segments }) } /// Resolve the pattern against a context, producing a filename stem. pub fn resolve(&self, ctx: &RenameContext) -> String { let mut result = String::new(); for seg in &self.segments { match seg { Segment::Literal(s) => result.push_str(s), Segment::Token(t) => { let val = match t { Token::Name => ctx.name.clone(), Token::Ext => ctx.extension.clone(), Token::Bpm => ctx .bpm .map(|b| format!("{}", b.round() as i64)) .unwrap_or_default(), Token::Key => ctx.musical_key.clone().unwrap_or_default(), Token::Class => ctx .classification .as_ref() .map(|c| c.to_lowercase()) .unwrap_or_default(), Token::Duration => ctx .duration .map(|d| format!("{:.1}s", d)) .unwrap_or_default(), Token::Index => format!("{}", ctx.index + 1), Token::IndexPad2 => format!("{:02}", ctx.index + 1), Token::IndexPad3 => format!("{:03}", ctx.index + 1), }; result.push_str(&val); } } } collapse_separators(&result) } /// Resolve all contexts and deduplicate names by appending ` (2)`, ` (3)`, etc. pub fn resolve_all(&self, contexts: &[RenameContext]) -> Vec { let stems: Vec = contexts.iter().map(|ctx| self.resolve(ctx)).collect(); deduplicate(stems) } } /// Collapse consecutive `_` or `-` separators into one. fn collapse_separators(s: &str) -> String { let mut result = String::with_capacity(s.len()); let mut prev = '\0'; for ch in s.chars() { if (ch == '_' || ch == '-') && (prev == '_' || prev == '-') { continue; } result.push(ch); prev = ch; } // Trim leading/trailing separators result.trim_matches(|c| c == '_' || c == '-').to_string() } /// Append ` (2)`, ` (3)`, etc. to duplicate stems. fn deduplicate(stems: Vec) -> Vec { // First pass: count occurrences using owned keys let mut counts: HashMap = HashMap::new(); for stem in &stems { *counts.entry(stem.clone()).or_insert(0) += 1; } // Second pass: assign suffixes where needed let mut seen: HashMap = HashMap::new(); let mut result = Vec::with_capacity(stems.len()); for stem in stems { if counts[&stem] <= 1 { result.push(stem); } else if let Some(n) = seen.get_mut(&stem) { *n += 1; result.push(format!("{stem} ({n})")); } else { seen.insert(stem.clone(), 1); result.push(stem); } } result } #[cfg(test)] mod tests { use super::*; fn ctx(name: &str, index: usize) -> RenameContext { RenameContext { name: name.to_string(), extension: "wav".to_string(), bpm: Some(120.5), musical_key: Some("Cm".to_string()), classification: Some("Kick".to_string()), duration: Some(1.234), index, } } #[test] fn parse_simple_pattern() { let pat = RenamePattern::parse("{name}_{bpm}").unwrap(); let result = pat.resolve(&ctx("kick", 0)); assert_eq!(result, "kick_121"); } #[test] fn parse_all_tokens() { let pat = RenamePattern::parse("{nnn}_{name}_{bpm}_{key}_{class}_{duration}_{ext}").unwrap(); let result = pat.resolve(&ctx("hit", 4)); assert_eq!(result, "005_hit_121_Cm_kick_1.2s_wav"); } #[test] fn reject_path_separators() { assert!(RenamePattern::parse("foo/{name}").is_err()); assert!(RenamePattern::parse("foo\\{name}").is_err()); } #[test] fn reject_unknown_token() { assert!(RenamePattern::parse("{unknown}").is_err()); } #[test] fn reject_unclosed_brace() { assert!(RenamePattern::parse("{name").is_err()); } #[test] fn empty_token_values() { let c = RenameContext { name: "sample".to_string(), extension: "wav".to_string(), bpm: None, musical_key: None, classification: None, duration: None, index: 0, }; let pat = RenamePattern::parse("{name}_{bpm}_{key}").unwrap(); let result = pat.resolve(&c); assert_eq!(result, "sample"); } #[test] fn separator_collapsing() { let c = RenameContext { name: "sample".to_string(), extension: "wav".to_string(), bpm: None, musical_key: Some("Am".to_string()), classification: None, duration: None, index: 0, }; // {bpm} is empty, so "sample_-Am" collapses to "sample_Am" (first separator kept) let pat = RenamePattern::parse("{name}_{bpm}-{key}").unwrap(); let result = pat.resolve(&c); assert_eq!(result, "sample_Am"); } #[test] fn index_padding() { let pat_n = RenamePattern::parse("{n}").unwrap(); let pat_nn = RenamePattern::parse("{nn}").unwrap(); let pat_nnn = RenamePattern::parse("{nnn}").unwrap(); assert_eq!(pat_n.resolve(&ctx("x", 0)), "1"); assert_eq!(pat_nn.resolve(&ctx("x", 4)), "05"); assert_eq!(pat_nnn.resolve(&ctx("x", 99)), "100"); } #[test] fn deduplicate_names() { let pat = RenamePattern::parse("{name}").unwrap(); let contexts = vec![ctx("kick", 0), ctx("kick", 1), ctx("kick", 2), ctx("snare", 3)]; let results = pat.resolve_all(&contexts); assert_eq!(results, vec!["kick", "kick (2)", "kick (3)", "snare"]); } #[test] fn literal_only_pattern() { let pat = RenamePattern::parse("constant_name").unwrap(); let result = pat.resolve(&ctx("anything", 0)); assert_eq!(result, "constant_name"); } }