Skip to main content

max / audiofiles

8.8 KB · 283 lines History Blame Raw
1 //! Rename pattern engine: parse token patterns and resolve them into filenames.
2
3 use std::collections::HashMap;
4
5 use crate::error::{CoreError, Result};
6
7 /// Context values used to resolve tokens in a rename pattern.
8 pub struct RenameContext {
9 pub name: String,
10 pub extension: String,
11 pub bpm: Option<f64>,
12 pub musical_key: Option<String>,
13 pub classification: Option<String>,
14 pub duration: Option<f64>,
15 pub index: usize,
16 }
17
18 /// A parsed rename pattern ready to resolve against contexts.
19 pub struct RenamePattern {
20 segments: Vec<Segment>,
21 }
22
23 enum Segment {
24 Literal(String),
25 Token(Token),
26 }
27
28 enum Token {
29 Name,
30 Ext,
31 Bpm,
32 Key,
33 Class,
34 Duration,
35 Index,
36 IndexPad2,
37 IndexPad3,
38 }
39
40 impl RenamePattern {
41 /// Parse a pattern string like `"{name}_{bpm}"`.
42 ///
43 /// Tokens: `{name}`, `{ext}`, `{bpm}`, `{key}`, `{class}`, `{duration}`,
44 /// `{n}`, `{nn}`, `{nnn}`.
45 pub fn parse(pattern: &str) -> Result<Self> {
46 if pattern.contains('/') || pattern.contains('\\') {
47 return Err(CoreError::RenameInvalid(
48 "pattern cannot contain path separators".into(),
49 ));
50 }
51
52 let mut segments = Vec::new();
53 let mut remaining = pattern;
54
55 while let Some(open) = remaining.find('{') {
56 if open > 0 {
57 segments.push(Segment::Literal(remaining[..open].to_string()));
58 }
59 let after_open = &remaining[open + 1..];
60 let close = after_open.find('}').ok_or_else(|| {
61 CoreError::RenameInvalid("unclosed '{' in pattern".into())
62 })?;
63 let token_str = &after_open[..close];
64 let token = match token_str {
65 "name" => Token::Name,
66 "ext" => Token::Ext,
67 "bpm" => Token::Bpm,
68 "key" => Token::Key,
69 "class" => Token::Class,
70 "duration" => Token::Duration,
71 "n" => Token::Index,
72 "nn" => Token::IndexPad2,
73 "nnn" => Token::IndexPad3,
74 other => {
75 return Err(CoreError::RenameInvalid(format!(
76 "unknown token: {{{other}}}"
77 )))
78 }
79 };
80 segments.push(Segment::Token(token));
81 remaining = &after_open[close + 1..];
82 }
83
84 if !remaining.is_empty() {
85 segments.push(Segment::Literal(remaining.to_string()));
86 }
87
88 if segments.is_empty() {
89 return Err(CoreError::RenameInvalid("pattern must not be empty".into()));
90 }
91
92 Ok(Self { segments })
93 }
94
95 /// Resolve the pattern against a context, producing a filename stem.
96 pub fn resolve(&self, ctx: &RenameContext) -> String {
97 let mut result = String::new();
98 for seg in &self.segments {
99 match seg {
100 Segment::Literal(s) => result.push_str(s),
101 Segment::Token(t) => {
102 let val = match t {
103 Token::Name => ctx.name.clone(),
104 Token::Ext => ctx.extension.clone(),
105 Token::Bpm => ctx
106 .bpm
107 .map(|b| format!("{}", b.round() as i64))
108 .unwrap_or_default(),
109 Token::Key => ctx.musical_key.clone().unwrap_or_default(),
110 Token::Class => ctx
111 .classification
112 .as_ref()
113 .map(|c| c.to_lowercase())
114 .unwrap_or_default(),
115 Token::Duration => ctx
116 .duration
117 .map(|d| format!("{:.1}s", d))
118 .unwrap_or_default(),
119 Token::Index => format!("{}", ctx.index + 1),
120 Token::IndexPad2 => format!("{:02}", ctx.index + 1),
121 Token::IndexPad3 => format!("{:03}", ctx.index + 1),
122 };
123 result.push_str(&val);
124 }
125 }
126 }
127 collapse_separators(&result)
128 }
129
130 /// Resolve all contexts and deduplicate names by appending ` (2)`, ` (3)`, etc.
131 pub fn resolve_all(&self, contexts: &[RenameContext]) -> Vec<String> {
132 let stems: Vec<String> = contexts.iter().map(|ctx| self.resolve(ctx)).collect();
133 deduplicate(stems)
134 }
135 }
136
137 /// Collapse consecutive `_` or `-` separators into one.
138 fn collapse_separators(s: &str) -> String {
139 let mut result = String::with_capacity(s.len());
140 let mut prev = '\0';
141 for ch in s.chars() {
142 if (ch == '_' || ch == '-') && (prev == '_' || prev == '-') {
143 continue;
144 }
145 result.push(ch);
146 prev = ch;
147 }
148 // Trim leading/trailing separators
149 result.trim_matches(|c| c == '_' || c == '-').to_string()
150 }
151
152 /// Append ` (2)`, ` (3)`, etc. to duplicate stems.
153 fn deduplicate(stems: Vec<String>) -> Vec<String> {
154 // First pass: count occurrences using owned keys
155 let mut counts: HashMap<String, usize> = HashMap::new();
156 for stem in &stems {
157 *counts.entry(stem.clone()).or_insert(0) += 1;
158 }
159
160 // Second pass: assign suffixes where needed
161 let mut seen: HashMap<String, usize> = HashMap::new();
162 let mut result = Vec::with_capacity(stems.len());
163 for stem in stems {
164 if counts[&stem] <= 1 {
165 result.push(stem);
166 } else if let Some(n) = seen.get_mut(&stem) {
167 *n += 1;
168 result.push(format!("{stem} ({n})"));
169 } else {
170 seen.insert(stem.clone(), 1);
171 result.push(stem);
172 }
173 }
174
175 result
176 }
177
178 #[cfg(test)]
179 mod tests {
180 use super::*;
181
182 fn ctx(name: &str, index: usize) -> RenameContext {
183 RenameContext {
184 name: name.to_string(),
185 extension: "wav".to_string(),
186 bpm: Some(120.5),
187 musical_key: Some("Cm".to_string()),
188 classification: Some("Kick".to_string()),
189 duration: Some(1.234),
190 index,
191 }
192 }
193
194 #[test]
195 fn parse_simple_pattern() {
196 let pat = RenamePattern::parse("{name}_{bpm}").unwrap();
197 let result = pat.resolve(&ctx("kick", 0));
198 assert_eq!(result, "kick_121");
199 }
200
201 #[test]
202 fn parse_all_tokens() {
203 let pat = RenamePattern::parse("{nnn}_{name}_{bpm}_{key}_{class}_{duration}_{ext}").unwrap();
204 let result = pat.resolve(&ctx("hit", 4));
205 assert_eq!(result, "005_hit_121_Cm_kick_1.2s_wav");
206 }
207
208 #[test]
209 fn reject_path_separators() {
210 assert!(RenamePattern::parse("foo/{name}").is_err());
211 assert!(RenamePattern::parse("foo\\{name}").is_err());
212 }
213
214 #[test]
215 fn reject_unknown_token() {
216 assert!(RenamePattern::parse("{unknown}").is_err());
217 }
218
219 #[test]
220 fn reject_unclosed_brace() {
221 assert!(RenamePattern::parse("{name").is_err());
222 }
223
224 #[test]
225 fn empty_token_values() {
226 let c = RenameContext {
227 name: "sample".to_string(),
228 extension: "wav".to_string(),
229 bpm: None,
230 musical_key: None,
231 classification: None,
232 duration: None,
233 index: 0,
234 };
235 let pat = RenamePattern::parse("{name}_{bpm}_{key}").unwrap();
236 let result = pat.resolve(&c);
237 assert_eq!(result, "sample");
238 }
239
240 #[test]
241 fn separator_collapsing() {
242 let c = RenameContext {
243 name: "sample".to_string(),
244 extension: "wav".to_string(),
245 bpm: None,
246 musical_key: Some("Am".to_string()),
247 classification: None,
248 duration: None,
249 index: 0,
250 };
251 // {bpm} is empty, so "sample_-Am" collapses to "sample_Am" (first separator kept)
252 let pat = RenamePattern::parse("{name}_{bpm}-{key}").unwrap();
253 let result = pat.resolve(&c);
254 assert_eq!(result, "sample_Am");
255 }
256
257 #[test]
258 fn index_padding() {
259 let pat_n = RenamePattern::parse("{n}").unwrap();
260 let pat_nn = RenamePattern::parse("{nn}").unwrap();
261 let pat_nnn = RenamePattern::parse("{nnn}").unwrap();
262
263 assert_eq!(pat_n.resolve(&ctx("x", 0)), "1");
264 assert_eq!(pat_nn.resolve(&ctx("x", 4)), "05");
265 assert_eq!(pat_nnn.resolve(&ctx("x", 99)), "100");
266 }
267
268 #[test]
269 fn deduplicate_names() {
270 let pat = RenamePattern::parse("{name}").unwrap();
271 let contexts = vec![ctx("kick", 0), ctx("kick", 1), ctx("kick", 2), ctx("snare", 3)];
272 let results = pat.resolve_all(&contexts);
273 assert_eq!(results, vec!["kick", "kick (2)", "kick (3)", "snare"]);
274 }
275
276 #[test]
277 fn literal_only_pattern() {
278 let pat = RenamePattern::parse("constant_name").unwrap();
279 let result = pat.resolve(&ctx("anything", 0));
280 assert_eq!(result, "constant_name");
281 }
282 }
283