Skip to main content

scrobble_scrubber/
rewrite.rs

1use crate::config::ReleaseFilterConfig;
2use lastfm_edit::{ScrobbleEdit, Track};
3use serde::{Deserialize, Serialize};
4
5/// Create a no-op `ScrobbleEdit` from a Track (no changes, just a baseline)
6#[must_use]
7pub fn create_no_op_edit(track: &Track) -> ScrobbleEdit {
8    ScrobbleEdit {
9        track_name_original: Some(track.name.clone()),
10        album_name_original: track.album.clone(),
11        artist_name_original: track.artist.clone(),
12        album_artist_name_original: track.album_artist.clone(),
13        track_name: Some(track.name.clone()),
14        album_name: track.album.clone(),
15        artist_name: track.artist.clone(),
16        album_artist_name: track
17            .album_artist
18            .clone()
19            .or_else(|| Some(track.artist.clone())),
20        timestamp: track.timestamp,
21        edit_all: true,
22    }
23}
24
25/// Check if any of the rewrite rules would apply to the given track
26/// This checks if any rule patterns match the track
27pub fn any_rules_apply(rules: &[RewriteRule], track: &Track) -> Result<bool, RewriteError> {
28    for rule in rules {
29        if rule.matches(track)? {
30            return Ok(true);
31        }
32    }
33    Ok(false)
34}
35
36/// Check if any of the rewrite rules' patterns match the given track
37pub fn any_rules_match(rules: &[RewriteRule], track: &Track) -> Result<bool, RewriteError> {
38    for rule in rules {
39        if rule.matches(track)? {
40            return Ok(true);
41        }
42    }
43    Ok(false)
44}
45
46/// Apply all rewrite rules to a `ScrobbleEdit`, returning true if any changes were made
47/// Rules are filtered to only apply those that match the ScrobbleEdit using semantic None handling
48pub fn apply_all_rules(
49    rules: &[RewriteRule],
50    edit: &mut ScrobbleEdit,
51) -> Result<bool, RewriteError> {
52    let mut any_changes = false;
53    for rule in rules {
54        // Filter: only apply rules that match the ScrobbleEdit with semantic None handling
55        if rule.matches_scrobble_edit(edit)? {
56            let changed = rule.apply(edit)?;
57            if changed {
58                any_changes = true;
59                let rule_name = rule.name.as_deref().unwrap_or("unnamed rule");
60                log::debug!(
61                    "Applied rewrite rule '{}' to track '{}' by '{}'",
62                    rule_name,
63                    edit.track_name_original.as_deref().unwrap_or("unknown"),
64                    &edit.artist_name_original
65                );
66            }
67        }
68    }
69    Ok(any_changes)
70}
71
72/// A single find-and-replace transformation with whole-string replacement behavior
73///
74/// ## Behavior
75///
76/// When a pattern matches anywhere in the input string, the **entire input string**
77/// is replaced with the replacement text (not just the matched portion).
78///
79/// ## Pattern Matching
80///
81/// - Uses regular expressions for pattern matching
82/// - Pattern can match anywhere in the input string
83/// - If pattern matches, entire string is replaced
84///
85/// ## Replacement Syntax
86///
87/// The replacement string supports the following substitutions:
88///
89/// ### Numbered Capture Groups
90/// - `$0` - The entire match
91/// - `$1` - First capture group
92/// - `$2` - Second capture group
93/// - `$n` - nth capture group
94///
95/// ### Named Capture Groups
96/// - `${name}` - Named capture group
97/// - Example: `(?P<artist>.+)` can be referenced as `${artist}`
98///
99/// ### Literal Characters and Escaping
100/// - `\$` - Literal dollar sign (escaped)
101/// - `$$` - Literal dollar sign (alternative syntax)
102/// - `\{` - Literal left brace (escaped)
103/// - `\}` - Literal right brace (escaped)
104/// - `\\` - Literal backslash (escaped)
105///
106/// ## Examples
107///
108/// ```rust
109/// use scrobble_scrubber::rewrite::SdRule;
110///
111/// // Basic replacement: "Vulfpeck ft. anyone" -> "Vulfpeck"
112/// let rule = SdRule::new("Vulfpeck", "Vulfpeck");
113/// assert_eq!(rule.apply("Vulfpeck ft. Antwaun Stanley").unwrap(), "Vulfpeck");
114///
115/// // Capture groups: extract artist from "Artist - Song"
116/// let rule = SdRule::new(r"(.+) - .+", "$1");
117/// assert_eq!(rule.apply("The Beatles - Yesterday").unwrap(), "The Beatles");
118///
119/// // Named capture groups: reformat track info
120/// let rule = SdRule::new(
121///     r"(?P<artist>.+) - (?P<song>.+)",
122///     "${song} by ${artist}"
123/// );
124/// assert_eq!(rule.apply("Queen - Bohemian Rhapsody").unwrap(), "Bohemian Rhapsody by Queen");
125/// ```
126///
127/// ## Regex Flags
128///
129/// - `i` - Case insensitive matching
130/// - `m` - Multiline mode (default)
131/// - `s` - Dot matches newline
132/// - `c` - Case sensitive (explicit)
133/// - `e` - Single line mode (disables multiline)
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct SdRule {
136    /// The pattern to search for (always treated as regex)
137    pub find: String,
138    /// The replacement string (supports capture group substitution)
139    pub replace: String,
140    /// Regex flags (e.g., "i" for case insensitive)
141    pub flags: Option<String>,
142}
143
144impl SdRule {
145    /// Create a new rule (always regex-based)
146    #[must_use]
147    pub fn new(find: &str, replace: &str) -> Self {
148        Self {
149            find: find.to_string(),
150            replace: replace.to_string(),
151            flags: None,
152        }
153    }
154
155    /// Add regex flags
156    #[must_use]
157    pub fn with_flags(mut self, flags: &str) -> Self {
158        self.flags = Some(flags.to_string());
159        self
160    }
161
162    /// Apply this rule to a string, returning the result
163    /// If the pattern matches anywhere in the input, the entire string is replaced
164    pub fn apply(&self, input: &str) -> Result<String, RewriteError> {
165        // Always use regex mode - if pattern matches anywhere, replace entire string
166        // But allow capture group substitution from the original input
167        let mut regex_builder = regex::RegexBuilder::new(&self.find);
168        regex_builder.multi_line(true);
169
170        // Apply flags if present
171        if let Some(flags) = &self.flags {
172            for c in flags.chars() {
173                match c {
174                    'c' => {
175                        regex_builder.case_insensitive(false);
176                    }
177                    'i' => {
178                        regex_builder.case_insensitive(true);
179                    }
180                    'm' => {}
181                    'e' => {
182                        regex_builder.multi_line(false);
183                    }
184                    's' => {
185                        if !flags.contains('m') {
186                            regex_builder.multi_line(false);
187                        }
188                        regex_builder.dot_matches_new_line(true);
189                    }
190                    _ => {}
191                }
192            }
193        }
194
195        let regex = regex_builder.build().map_err(RewriteError::RegexError)?;
196
197        if let Some(captures) = regex.captures(input) {
198            // Pattern matches - replace entire string, expanding capture groups
199            let mut result = self.replace.clone();
200
201            // Handle escaped characters first (convert to placeholders)
202            let escaped_dollar_placeholder = "\u{E000}ESCAPED_DOLLAR\u{E000}"; // Use private use area
203            let escaped_lbrace_placeholder = "\u{E000}ESCAPED_LBRACE\u{E000}";
204            let escaped_rbrace_placeholder = "\u{E000}ESCAPED_RBRACE\u{E000}";
205            let escaped_backslash_placeholder = "\u{E000}ESCAPED_BACKSLASH\u{E000}";
206
207            // Handle backslashes first to prevent double-processing
208            result = result.replace(r"\\", escaped_backslash_placeholder);
209            result = result.replace(r"\$", escaped_dollar_placeholder);
210            result = result.replace("$$", escaped_dollar_placeholder);
211            result = result.replace(r"\{", escaped_lbrace_placeholder);
212            result = result.replace(r"\}", escaped_rbrace_placeholder);
213
214            // Replace numbered capture group references ($0, $1, $2, etc.)
215            for i in 0..captures.len() {
216                let placeholder = format!("${i}");
217                if let Some(capture) = captures.get(i) {
218                    result = result.replace(&placeholder, capture.as_str());
219                }
220            }
221
222            // Replace named capture group references (${name})
223            for name in regex.capture_names().flatten() {
224                let placeholder = format!("${{{name}}}");
225                if let Some(capture) = captures.name(name) {
226                    result = result.replace(&placeholder, capture.as_str());
227                }
228            }
229
230            // Restore escaped characters
231            result = result.replace(escaped_dollar_placeholder, "$");
232            result = result.replace(escaped_lbrace_placeholder, "{");
233            result = result.replace(escaped_rbrace_placeholder, "}");
234            result = result.replace(escaped_backslash_placeholder, "\\");
235
236            Ok(result)
237        } else {
238            // Pattern doesn't match - return input unchanged
239            Ok(input.to_string())
240        }
241    }
242
243    /// Check if this rule's pattern matches the input string (regardless of whether it would modify it)
244    pub fn matches(&self, input: &str) -> Result<bool, RewriteError> {
245        let mut regex_builder = regex::RegexBuilder::new(&self.find);
246        regex_builder.multi_line(true);
247
248        // Apply flags if present
249        if let Some(flags) = &self.flags {
250            for c in flags.chars() {
251                match c {
252                    'c' => {
253                        regex_builder.case_insensitive(false);
254                    }
255                    'i' => {
256                        regex_builder.case_insensitive(true);
257                    }
258                    'm' => {}
259                    'e' => {
260                        regex_builder.multi_line(false);
261                    }
262                    's' => {
263                        if !flags.contains('m') {
264                            regex_builder.multi_line(false);
265                        }
266                        regex_builder.dot_matches_new_line(true);
267                    }
268                    _ => {}
269                }
270            }
271        }
272
273        let regex = regex_builder.build().map_err(RewriteError::RegexError)?;
274        Ok(regex.is_match(input))
275    }
276}
277
278/// A comprehensive rewrite rule that can transform fields of a scrobble
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280pub struct RewriteRule {
281    /// Optional name for this rule
282    pub name: Option<String>,
283    /// Optional transformation for track name
284    pub track_name: Option<SdRule>,
285    /// Optional transformation for album name
286    pub album_name: Option<SdRule>,
287    /// Optional transformation for artist name
288    pub artist_name: Option<SdRule>,
289    /// Optional transformation for album artist name
290    pub album_artist_name: Option<SdRule>,
291    /// Whether this rule requires user confirmation before applying
292    pub requires_confirmation: bool,
293    /// Whether this rule requires MusicBrainz confirmation of the rewritten metadata
294    /// If true, the rewritten values must be validated against MusicBrainz before the edit is suggested/applied
295    #[serde(default)]
296    pub requires_musicbrainz_confirmation: bool,
297    /// Release filter configuration for MusicBrainz verification (only applies when requires_musicbrainz_confirmation is true)
298    /// If None, uses default MusicBrainz provider filters. These filters are NEVER applied to search operations.
299    #[serde(default)]
300    pub musicbrainz_release_filters: Option<ReleaseFilterConfig>,
301}
302
303impl RewriteRule {
304    /// Create a new empty rewrite rule
305    #[must_use]
306    pub const fn new() -> Self {
307        Self {
308            name: None,
309            track_name: None,
310            album_name: None,
311            artist_name: None,
312            album_artist_name: None,
313            requires_confirmation: false,
314            requires_musicbrainz_confirmation: false,
315            musicbrainz_release_filters: None,
316        }
317    }
318
319    /// Set name for this rule
320    #[must_use]
321    pub fn with_name<S: Into<String>>(mut self, name: S) -> Self {
322        self.name = Some(name.into());
323        self
324    }
325
326    /// Set transformation for track name
327    #[must_use]
328    pub fn with_track_name(mut self, rule: SdRule) -> Self {
329        self.track_name = Some(rule);
330        self
331    }
332
333    /// Set transformation for album name
334    #[must_use]
335    pub fn with_album_name(mut self, rule: SdRule) -> Self {
336        self.album_name = Some(rule);
337        self
338    }
339
340    /// Set transformation for artist name
341    #[must_use]
342    pub fn with_artist_name(mut self, rule: SdRule) -> Self {
343        self.artist_name = Some(rule);
344        self
345    }
346
347    /// Set transformation for album artist name
348    #[must_use]
349    pub fn with_album_artist_name(mut self, rule: SdRule) -> Self {
350        self.album_artist_name = Some(rule);
351        self
352    }
353
354    /// Set whether this rule requires confirmation
355    #[must_use]
356    pub const fn with_confirmation_required(mut self, requires_confirmation: bool) -> Self {
357        self.requires_confirmation = requires_confirmation;
358        self
359    }
360
361    /// Set whether this rule requires MusicBrainz confirmation
362    #[must_use]
363    pub const fn with_musicbrainz_confirmation_required(
364        mut self,
365        requires_musicbrainz_confirmation: bool,
366    ) -> Self {
367        self.requires_musicbrainz_confirmation = requires_musicbrainz_confirmation;
368        self
369    }
370
371    /// Set MusicBrainz release filters for verification (only applies when requires_musicbrainz_confirmation is true)
372    /// These filters are NEVER applied to search operations, only to confirmation/verification.
373    #[must_use]
374    pub fn with_musicbrainz_release_filters(mut self, filters: ReleaseFilterConfig) -> Self {
375        self.musicbrainz_release_filters = Some(filters);
376        self
377    }
378
379    /// Check if this rule's patterns match the given track (regardless of whether it would modify it)
380    ///
381    /// A rule matches when:
382    /// - All None fields are considered as always matching
383    /// - All Some fields must match their respective track fields (pattern matching only)
384    /// - A rule with all None fields is treated as always matching (acts as a catch-all)
385    /// - If any Some field's pattern doesn't match, the rule doesn't match
386    pub fn matches(&self, track: &Track) -> Result<bool, RewriteError> {
387        let rule_name = self.name.as_deref().unwrap_or("unnamed");
388        let mut matched_fields = Vec::new();
389        let mut failed_fields = Vec::new();
390        let mut checked_fields = Vec::new();
391
392        // Check track name pattern if present
393        if let Some(rule) = &self.track_name {
394            checked_fields.push("track_name");
395            if rule.matches(&track.name)? {
396                let track_name = &track.name;
397                matched_fields.push(format!("track_name('{track_name}')"));
398            } else {
399                let track_name = &track.name;
400                let pattern = &rule.find;
401                failed_fields.push(format!("track_name('{track_name}' ≠ pattern '{pattern}')"));
402            }
403        }
404
405        // Check artist name pattern if present
406        if let Some(rule) = &self.artist_name {
407            checked_fields.push("artist_name");
408            if rule.matches(&track.artist)? {
409                let artist_name = &track.artist;
410                matched_fields.push(format!("artist_name('{artist_name}')"));
411            } else {
412                let artist_name = &track.artist;
413                let pattern = &rule.find;
414                failed_fields.push(format!(
415                    "artist_name('{artist_name}' ≠ pattern '{pattern}')"
416                ));
417            }
418        }
419
420        // Check album name pattern if present
421        if let Some(rule) = &self.album_name {
422            checked_fields.push("album_name");
423            let album_name = track.album.as_deref().unwrap_or("");
424            if rule.matches(album_name)? {
425                matched_fields.push(format!("album_name('{album_name}')"));
426            } else {
427                failed_fields.push(format!(
428                    "album_name('{album_name}' ≠ pattern '{}')",
429                    rule.find
430                ));
431            }
432        }
433
434        // Check album artist name pattern if present (always empty for Track)
435        if let Some(rule) = &self.album_artist_name {
436            checked_fields.push("album_artist_name");
437            if rule.matches("")? {
438                matched_fields.push("album_artist_name('')".to_string());
439            } else {
440                let pattern = &rule.find;
441                failed_fields.push(format!("album_artist_name('' ≠ pattern '{pattern}')"));
442            }
443        }
444
445        let rule_matches = failed_fields.is_empty();
446
447        // Log comprehensive summary
448        if checked_fields.is_empty() {
449            let track_name = &track.name;
450            let track_artist = &track.artist;
451            log::debug!(
452                "Rule '{rule_name}' matches track '{track_name}' by '{track_artist}' (catch-all rule with no patterns)"
453            );
454        } else if rule_matches {
455            let track_name = &track.name;
456            let track_artist = &track.artist;
457            let matched = matched_fields.join(", ");
458            log::debug!(
459                "Rule '{rule_name}' matches track '{track_name}' by '{track_artist}' | Matched: [{matched}]"
460            );
461        } else {
462            let track_name = &track.name;
463            let track_artist = &track.artist;
464            let matched = if matched_fields.is_empty() {
465                "none".to_string()
466            } else {
467                matched_fields.join(", ")
468            };
469            let failed = failed_fields.join(", ");
470            log::debug!(
471                "Rule '{rule_name}' does not match track '{track_name}' by '{track_artist}' | Matched: [{matched}] | Failed: [{failed}]"
472            );
473        }
474
475        Ok(rule_matches)
476    }
477
478    /// Helper function to check if a single field matches between rule and ScrobbleEdit
479    /// with semantic None handling
480    fn check_field_match(
481        field_name: &str,
482        rule: &SdRule,
483        edit_field: Option<&str>,
484        matched_fields: &mut Vec<String>,
485        failed_fields: &mut Vec<String>,
486    ) -> Result<(), RewriteError> {
487        if let Some(field_value) = edit_field {
488            if rule.matches(field_value)? {
489                matched_fields.push(format!("{field_name}('{field_value}')"));
490            } else {
491                let pattern = &rule.find;
492                failed_fields.push(format!(
493                    "{field_name}('{field_value}' ≠ pattern '{pattern}')"
494                ));
495            }
496        } else {
497            // ScrobbleEdit field is None - check for special .* pattern that matches anything including None
498            let pattern = &rule.find;
499            if pattern == ".*" {
500                // Special case: .* pattern matches None (conceptually "match anything, including nothing")
501                matched_fields.push(format!("{field_name}(None - matched by .* pattern)"));
502            } else {
503                // Rule field is Some but ScrobbleEdit field is None - NO MATCH for other patterns
504                failed_fields.push(format!("{field_name}(None ≠ pattern '{pattern}' - cannot match pattern against None value)"));
505            }
506        }
507        Ok(())
508    }
509
510    /// Check if this rule's patterns match the given ScrobbleEdit with semantic None handling
511    ///
512    /// A rule matches when:
513    /// - All None rule fields are considered as always matching (no constraint)
514    /// - For Some rule fields, they must match their respective ScrobbleEdit fields
515    /// - **Semantic None Handling**:
516    ///   - If rule field is None and ScrobbleEdit field is None: **MATCH** (no constraint, no value)
517    ///   - If rule field is None and ScrobbleEdit field is Some: **MATCH** (no constraint, any value OK)
518    ///   - If rule field is Some and ScrobbleEdit field is None: **NO MATCH** (constraint exists but no value to match)
519    ///   - If rule field is Some and ScrobbleEdit field is Some: **Check pattern match**
520    /// - If any Some field's pattern doesn't match, the rule doesn't match
521    pub fn matches_scrobble_edit(&self, edit: &ScrobbleEdit) -> Result<bool, RewriteError> {
522        let rule_name = self.name.as_deref().unwrap_or("unnamed");
523        let mut matched_fields = Vec::new();
524        let mut failed_fields = Vec::new();
525        let mut checked_fields = Vec::new();
526
527        // Check track name pattern if present
528        if let Some(rule) = &self.track_name {
529            checked_fields.push("track_name");
530            Self::check_field_match(
531                "track_name",
532                rule,
533                edit.track_name.as_deref(),
534                &mut matched_fields,
535                &mut failed_fields,
536            )?;
537        }
538
539        // Check artist name pattern if present
540        if let Some(rule) = &self.artist_name {
541            checked_fields.push("artist_name");
542            // artist_name is always Some in ScrobbleEdit (it's not Option<String>)
543            Self::check_field_match(
544                "artist_name",
545                rule,
546                Some(&edit.artist_name),
547                &mut matched_fields,
548                &mut failed_fields,
549            )?;
550        }
551
552        // Check album name pattern if present
553        if let Some(rule) = &self.album_name {
554            checked_fields.push("album_name");
555            Self::check_field_match(
556                "album_name",
557                rule,
558                edit.album_name.as_deref(),
559                &mut matched_fields,
560                &mut failed_fields,
561            )?;
562        }
563
564        // Check album artist name pattern if present
565        if let Some(rule) = &self.album_artist_name {
566            checked_fields.push("album_artist_name");
567            Self::check_field_match(
568                "album_artist_name",
569                rule,
570                edit.album_artist_name.as_deref(),
571                &mut matched_fields,
572                &mut failed_fields,
573            )?;
574        }
575
576        let rule_matches = failed_fields.is_empty();
577
578        // Log comprehensive summary
579        if checked_fields.is_empty() {
580            log::debug!(
581                "Rule '{rule_name}' matches ScrobbleEdit (catch-all rule with no patterns)"
582            );
583        } else if rule_matches {
584            let matched = matched_fields.join(", ");
585            log::debug!("Rule '{rule_name}' matches ScrobbleEdit | Matched: [{matched}]");
586        } else {
587            let matched = if matched_fields.is_empty() {
588                "none".to_string()
589            } else {
590                matched_fields.join(", ")
591            };
592            let failed = failed_fields.join(", ");
593            log::debug!("Rule '{rule_name}' does not match ScrobbleEdit | Matched: [{matched}] | Failed: [{failed}]");
594        }
595
596        Ok(rule_matches)
597    }
598
599    /// Apply this rule to an existing `ScrobbleEdit`, modifying it in place
600    /// Returns true if any changes were made
601    ///
602    /// IMPORTANT: This method assumes the rule has already been checked to match the ScrobbleEdit.
603    /// Rules should be filtered using matches_scrobble_edit() before calling apply().
604    pub fn apply(&self, edit: &mut ScrobbleEdit) -> Result<bool, RewriteError> {
605        let mut has_changes = false;
606
607        // Apply track name transformation if present
608        if let Some(rule) = &self.track_name {
609            if let Some(current_value) = &edit.track_name {
610                let new_value = rule.apply(current_value)?;
611                if new_value != *current_value {
612                    edit.track_name = Some(new_value);
613                    has_changes = true;
614                }
615            }
616        }
617
618        // Apply artist name transformation if present
619        if let Some(rule) = &self.artist_name {
620            let current_value = &edit.artist_name;
621            let new_value = rule.apply(current_value)?;
622            if new_value != *current_value {
623                edit.artist_name = new_value;
624                has_changes = true;
625            }
626        }
627
628        // Apply album name transformation if present
629        if let Some(rule) = &self.album_name {
630            if let Some(current_value) = &edit.album_name {
631                let new_value = rule.apply(current_value)?;
632                if new_value != *current_value {
633                    edit.album_name = Some(new_value);
634                    has_changes = true;
635                }
636            }
637        }
638
639        // Apply album artist name transformation if present
640        if let Some(rule) = &self.album_artist_name {
641            if let Some(current_value) = &edit.album_artist_name {
642                let new_value = rule.apply(current_value)?;
643                if new_value != *current_value {
644                    edit.album_artist_name = Some(new_value);
645                    has_changes = true;
646                }
647            }
648        }
649
650        Ok(has_changes)
651    }
652}
653
654impl Default for RewriteRule {
655    fn default() -> Self {
656        Self::new()
657    }
658}
659
660/// Errors that can occur during rewrite operations
661#[derive(Debug, thiserror::Error)]
662pub enum RewriteError {
663    #[error("Regex error: {0}")]
664    RegexError(#[from] regex::Error),
665    #[error("Invalid replacement capture: {0}")]
666    InvalidReplaceCapture(String),
667    #[error("Invalid UTF-8 in result: {0}")]
668    InvalidUtf8(#[from] std::string::FromUtf8Error),
669}
670
671/// Load comprehensive default rewrite rules from the embedded JSON files
672///
673/// This loads the full set of remaster and special edition cleanup rules from the JSON files.
674/// If the files cannot be loaded or parsed, falls back to the basic default_rules().
675#[must_use]
676pub fn load_comprehensive_default_rules() -> Vec<RewriteRule> {
677    use crate::default_rules::load_all_default_rules;
678
679    match load_all_default_rules() {
680        Ok(rules) => {
681            let rewrite_rules: Vec<RewriteRule> =
682                rules.into_iter().map(|rule| rule.into()).collect();
683            log::info!(
684                "Loaded {} comprehensive default rewrite rules (including special editions)",
685                rewrite_rules.len()
686            );
687            rewrite_rules
688        }
689        Err(e) => {
690            log::warn!(
691                "Failed to load comprehensive default rules: {e}, falling back to basic rules"
692            );
693            default_rules()
694        }
695    }
696}
697
698/// Default rewrite rules for common cleanup tasks
699#[must_use]
700pub fn default_rules() -> Vec<RewriteRule> {
701    vec![
702        // Remove remaster suffixes from track names
703        RewriteRule::new()
704            .with_track_name(SdRule::new(
705                r" - \d{4} [Rr]emaster| - [Rr]emaster \d{4}| - [Rr]emaster| \(\d{4} [Rr]emaster\)| \([Rr]emaster \d{4}\)| \([Rr]emaster\)",
706                ""
707            )),
708
709        // Normalize featuring formats in artist names
710        RewriteRule::new()
711            .with_artist_name(SdRule::new(r" [Ff]t\. | [Ff]eaturing ", " feat. ")),
712
713        // Clean up extra whitespace in track names
714        RewriteRule::new()
715            .with_track_name(SdRule::new(r"\s+", " "))
716            .with_artist_name(SdRule::new(r"\s+", " ")),
717
718        // Remove leading/trailing whitespace
719        RewriteRule::new()
720            .with_track_name(SdRule::new(r"^\s+|\s+$", ""))
721            .with_artist_name(SdRule::new(r"^\s+|\s+$", "")),
722
723        // Remove explicit content warnings
724        RewriteRule::new()
725            .with_track_name(SdRule::new(r" \(Explicit\)$| - Explicit$", "")),
726    ]
727}