1use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, thiserror::Error)]
12pub enum RewriteProcessorError {
13 #[error("Regex compilation failed: {0}")]
14 RegexError(#[from] regex::Error),
15 #[error("Pattern must match entire input: got partial match for '{0}'")]
16 PartialMatchError(String),
17 #[error("Pattern must use anchors (^ and $) or match full string: '{0}'")]
18 MissingAnchorsError(String),
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct TransformRule {
24 pub pattern: String,
26 pub replacement: String,
28 pub flags: Option<String>,
30 pub max_applications: usize,
32}
33
34impl TransformRule {
35 pub fn new(pattern: &str, replacement: &str) -> Self {
37 Self {
38 pattern: pattern.to_string(),
39 replacement: replacement.to_string(),
40 flags: None,
41 max_applications: 0,
42 }
43 }
44
45 pub fn with_flags(mut self, flags: &str) -> Self {
47 self.flags = Some(flags.to_string());
48 self
49 }
50
51 pub const fn with_max_applications(mut self, max: usize) -> Self {
53 self.max_applications = max;
54 self
55 }
56}
57
58#[derive(Debug, Clone)]
60pub struct RewriteProcessor {
61 compiled_rules: HashMap<String, CompiledRule>,
62}
63
64#[derive(Debug, Clone)]
65struct CompiledRule {
66 regex: Regex,
67 replacement: String,
68 max_applications: usize,
69}
70
71impl RewriteProcessor {
72 pub fn new(rules: Vec<TransformRule>) -> Result<Self, RewriteProcessorError> {
74 let mut compiled_rules = HashMap::new();
75
76 for rule in rules {
77 let (pattern, replacement) = (rule.pattern.clone(), rule.replacement.clone());
78
79 let mut regex_builder = regex::RegexBuilder::new(&pattern);
80
81 if let Some(flags) = &rule.flags {
83 for flag in flags.chars() {
84 match flag {
85 'i' => {
86 regex_builder.case_insensitive(true);
87 }
88 'm' => {
89 regex_builder.multi_line(true);
90 }
91 's' => {
92 regex_builder.dot_matches_new_line(true);
93 }
94 _ => {} }
96 }
97 }
98
99 let regex = regex_builder.build()?;
100
101 let compiled = CompiledRule {
102 regex,
103 replacement,
104 max_applications: rule.max_applications,
105 };
106
107 compiled_rules.insert(rule.pattern.clone(), compiled);
108 }
109
110 Ok(Self { compiled_rules })
111 }
112
113 pub fn process(&self, input: &str) -> Result<String, RewriteProcessorError> {
115 let mut result = input.to_string();
116
117 for compiled_rule in self.compiled_rules.values() {
118 result = self.apply_rule(&result, compiled_rule)?;
119 }
120
121 Ok(result)
122 }
123
124 fn apply_rule(
126 &self,
127 input: &str,
128 rule: &CompiledRule,
129 ) -> Result<String, RewriteProcessorError> {
130 if let Some(captures) = rule.regex.captures(input) {
132 if captures.get(0).unwrap().as_str() != input {
133 return Err(RewriteProcessorError::PartialMatchError(input.to_string()));
134 }
135
136 let result = if rule.max_applications == 0 {
137 rule.regex.replace_all(input, &rule.replacement)
138 } else {
139 rule.regex
140 .replacen(input, rule.max_applications, &rule.replacement)
141 };
142 Ok(result.to_string())
143 } else {
144 Ok(input.to_string())
146 }
147 }
148
149 pub fn would_modify(&self, input: &str) -> Result<bool, RewriteProcessorError> {
151 let result = self.process(input)?;
152 Ok(result != input)
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct MetadataRewriteRule {
159 pub track_name: Option<TransformRule>,
161 pub artist_name: Option<TransformRule>,
163 pub album_name: Option<TransformRule>,
165 pub album_artist_name: Option<TransformRule>,
167 pub requires_confirmation: bool,
169}
170
171#[derive(Debug, Clone)]
172pub struct MetadataRewriteProcessor {
173 track_processor: Option<RewriteProcessor>,
174 artist_processor: Option<RewriteProcessor>,
175 album_processor: Option<RewriteProcessor>,
176 album_artist_processor: Option<RewriteProcessor>,
177}
178
179impl MetadataRewriteProcessor {
180 pub fn from_rule(rule: MetadataRewriteRule) -> Result<Self, RewriteProcessorError> {
182 let track_processor = if let Some(rule) = rule.track_name {
183 Some(RewriteProcessor::new(vec![rule])?)
184 } else {
185 None
186 };
187
188 let artist_processor = if let Some(rule) = rule.artist_name {
189 Some(RewriteProcessor::new(vec![rule])?)
190 } else {
191 None
192 };
193
194 let album_processor = if let Some(rule) = rule.album_name {
195 Some(RewriteProcessor::new(vec![rule])?)
196 } else {
197 None
198 };
199
200 let album_artist_processor = if let Some(rule) = rule.album_artist_name {
201 Some(RewriteProcessor::new(vec![rule])?)
202 } else {
203 None
204 };
205
206 Ok(Self {
207 track_processor,
208 artist_processor,
209 album_processor,
210 album_artist_processor,
211 })
212 }
213
214 pub fn process_track_name(&self, track_name: &str) -> Result<String, RewriteProcessorError> {
216 if let Some(processor) = &self.track_processor {
217 processor.process(track_name)
218 } else {
219 Ok(track_name.to_string())
220 }
221 }
222
223 pub fn process_artist_name(&self, artist_name: &str) -> Result<String, RewriteProcessorError> {
225 if let Some(processor) = &self.artist_processor {
226 processor.process(artist_name)
227 } else {
228 Ok(artist_name.to_string())
229 }
230 }
231
232 pub fn process_album_name(&self, album_name: &str) -> Result<String, RewriteProcessorError> {
234 if let Some(processor) = &self.album_processor {
235 processor.process(album_name)
236 } else {
237 Ok(album_name.to_string())
238 }
239 }
240
241 pub fn process_album_artist_name(
243 &self,
244 album_artist_name: &str,
245 ) -> Result<String, RewriteProcessorError> {
246 if let Some(processor) = &self.album_artist_processor {
247 processor.process(album_artist_name)
248 } else {
249 Ok(album_artist_name.to_string())
250 }
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test_log::test]
259 fn should_replace_full_string_using_regex() {
260 let rule = TransformRule::new(r"^(.*)feat\.(.*)$", "${1}featuring${2}");
262 let processor = RewriteProcessor::new(vec![rule]).unwrap();
263
264 let result = processor.process("Song Title (feat. Artist)").unwrap();
265 assert_eq!(result, "Song Title (featuring Artist)");
266 }
267
268 #[test_log::test]
269 fn should_perform_case_insensitive_replacement() {
270 let rule = TransformRule::new(r"^(.*)FEAT\.(.*)$", "${1}featuring${2}").with_flags("i");
271 let processor = RewriteProcessor::new(vec![rule]).unwrap();
272
273 let result = processor.process("Song Title (feat. Artist)").unwrap();
274 assert_eq!(result, "Song Title (featuring Artist)");
275 }
276
277 #[test_log::test]
278 fn should_leave_string_unchanged_when_no_match() {
279 let rule = TransformRule::new(r"^(.*)feat\.(.*)$", "$1featuring$2");
280 let processor = RewriteProcessor::new(vec![rule]).unwrap();
281
282 let result = processor.process("Song Title").unwrap();
283 assert_eq!(result, "Song Title");
284 }
285
286 #[test_log::test]
287 fn should_process_metadata_fields_correctly() {
288 let rule = MetadataRewriteRule {
289 track_name: Some(TransformRule::new(r"^(.*)feat\.(.*)$", "${1}featuring${2}")),
290 artist_name: Some(TransformRule::new(r"^(.*)&(.*)$", "${1} and ${2}")),
291 album_name: None,
292 album_artist_name: None,
293 requires_confirmation: false,
294 };
295
296 let processor = MetadataRewriteProcessor::from_rule(rule).unwrap();
297
298 let track_result = processor.process_track_name("Song (feat. Artist)").unwrap();
299 assert_eq!(track_result, "Song (featuring Artist)");
300
301 let artist_result = processor.process_artist_name("Artist A&Artist B").unwrap();
302 assert_eq!(artist_result, "Artist A and Artist B");
303
304 let album_result = processor.process_album_name("Album Name").unwrap();
305 assert_eq!(album_result, "Album Name"); }
307}