Skip to main content

scrobble_scrubber/
rewrite_processor.rs

1/// Focused rewrite processing module for full string replacements
2///
3/// This module implements rewrite rules where:
4/// 1. Regex patterns should match the entire input string (with captures)
5/// 2. Replacements reconstruct the entire output string
6/// 3. This ensures predictable, testable behavior
7use 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/// A single transformation rule that operates on complete strings
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct TransformRule {
24    /// The regex pattern - should match entire input with captures
25    pub pattern: String,
26    /// The replacement - reconstructs entire output using captures
27    pub replacement: String,
28    /// Optional regex flags
29    pub flags: Option<String>,
30    /// Maximum number of applications (0 = unlimited)
31    pub max_applications: usize,
32}
33
34impl TransformRule {
35    /// Create a new regex-based transform rule
36    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    /// Add regex flags (i=case insensitive, m=multiline, s=dot matches newline)
46    pub fn with_flags(mut self, flags: &str) -> Self {
47        self.flags = Some(flags.to_string());
48        self
49    }
50
51    /// Set maximum number of applications
52    pub const fn with_max_applications(mut self, max: usize) -> Self {
53        self.max_applications = max;
54        self
55    }
56}
57
58/// A processor that applies transform rules to strings
59#[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    /// Create a new processor with the given rules
73    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            // Apply flags if specified
82            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                        _ => {} // Ignore unknown flags
95                    }
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    /// Apply all rules to the input string, returning the transformed result
114    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    /// Apply a single rule to the input
125    fn apply_rule(
126        &self,
127        input: &str,
128        rule: &CompiledRule,
129    ) -> Result<String, RewriteProcessorError> {
130        // For full string replacement, we expect exactly one match of the entire string
131        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            // No match, return input unchanged
145            Ok(input.to_string())
146        }
147    }
148
149    /// Check if any rules would modify the input
150    pub fn would_modify(&self, input: &str) -> Result<bool, RewriteProcessorError> {
151        let result = self.process(input)?;
152        Ok(result != input)
153    }
154}
155
156/// High-level interface for applying rewrite rules to different types of metadata
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct MetadataRewriteRule {
159    /// Optional rule for track names
160    pub track_name: Option<TransformRule>,
161    /// Optional rule for artist names
162    pub artist_name: Option<TransformRule>,
163    /// Optional rule for album names
164    pub album_name: Option<TransformRule>,
165    /// Optional rule for album artist names
166    pub album_artist_name: Option<TransformRule>,
167    /// Whether this rule requires confirmation
168    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    /// Create a new metadata processor from a rule
181    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    /// Apply rules to track metadata
215    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    /// Apply rules to artist metadata
224    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    /// Apply rules to album metadata
233    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    /// Apply rules to album artist metadata
242    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        // Pattern that captures everything and replaces "feat." with "featuring"
261        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"); // No change since no rule
306    }
307}