use regex::Regex; use serde::{Deserialize, Serialize}; use crate::{RenameContext, RenameRule}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum MoveTarget { None, Position(usize), Start, End, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum SelectionMode { Chars, Words, Regex, } impl Default for SelectionMode { fn default() -> Self { SelectionMode::Chars } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MovePartsRule { pub source_from: usize, pub source_length: usize, pub target: MoveTarget, pub separator: String, pub copy_mode: bool, pub enabled: bool, #[serde(default)] pub selection_mode: SelectionMode, #[serde(default)] pub regex_pattern: Option, #[serde(default)] pub regex_group: usize, #[serde(default)] pub swap_with_from: Option, #[serde(default)] pub swap_with_length: Option, } impl MovePartsRule { pub fn new() -> Self { Self { source_from: 0, source_length: 0, target: MoveTarget::None, separator: String::new(), copy_mode: false, enabled: true, selection_mode: SelectionMode::Chars, regex_pattern: None, regex_group: 0, swap_with_from: None, swap_with_length: None, } } } fn split_words(s: &str) -> Vec<(usize, usize)> { let mut spans = Vec::new(); let chars: Vec = s.chars().collect(); let mut i = 0; while i < chars.len() { if chars[i].is_whitespace() || matches!(chars[i], '_' | '-' | '.') { i += 1; continue; } let start = i; while i < chars.len() && !chars[i].is_whitespace() && !matches!(chars[i], '_' | '-' | '.') { i += 1; } spans.push((start, i)); } spans } impl RenameRule for MovePartsRule { fn apply(&self, filename: &str, _context: &RenameContext) -> String { // swap mode if let (Some(sw_from), Some(sw_len)) = (self.swap_with_from, self.swap_with_length) { return self.apply_swap(filename, sw_from, sw_len); } match self.selection_mode { SelectionMode::Chars => self.apply_chars(filename), SelectionMode::Words => self.apply_words(filename), SelectionMode::Regex => self.apply_regex(filename), } } fn display_name(&self) -> &str { "Move/Copy" } fn rule_type(&self) -> &str { "move_parts" } fn is_enabled(&self) -> bool { self.enabled } } impl MovePartsRule { fn apply_chars(&self, filename: &str) -> String { if self.target == MoveTarget::None || self.source_length == 0 { return filename.to_string(); } let chars: Vec = filename.chars().collect(); if self.source_from >= chars.len() { return filename.to_string(); } let end = (self.source_from + self.source_length).min(chars.len()); let extracted: String = chars[self.source_from..end].iter().collect(); let remaining: String = if self.copy_mode { filename.to_string() } else { let mut r = String::new(); for (i, c) in chars.iter().enumerate() { if i < self.source_from || i >= end { r.push(*c); } } r }; self.place_at_target(&extracted, &remaining) } fn apply_words(&self, filename: &str) -> String { if self.target == MoveTarget::None || self.source_length == 0 { return filename.to_string(); } let words = split_words(filename); if self.source_from >= words.len() { return filename.to_string(); } let word_end = (self.source_from + self.source_length).min(words.len()); let byte_start = words[self.source_from].0; let byte_end = words[word_end - 1].1; let chars: Vec = filename.chars().collect(); let extracted: String = chars[byte_start..byte_end].iter().collect(); let remaining: String = if self.copy_mode { filename.to_string() } else { // remove extracted span, trimming adjacent separators let mut r = String::new(); let mut skip_start = byte_start; let mut skip_end = byte_end; // extend skip to eat one adjacent separator if skip_end < chars.len() && (chars[skip_end].is_whitespace() || matches!(chars[skip_end], '_' | '-' | '.')) { skip_end += 1; } else if skip_start > 0 && (chars[skip_start - 1].is_whitespace() || matches!(chars[skip_start - 1], '_' | '-' | '.')) { skip_start -= 1; } for (i, c) in chars.iter().enumerate() { if i < skip_start || i >= skip_end { r.push(*c); } } r }; self.place_at_target(&extracted, &remaining) } fn apply_regex(&self, filename: &str) -> String { let pat = match &self.regex_pattern { Some(p) if !p.is_empty() => p, _ => return filename.to_string(), }; let re = match Regex::new(pat) { Ok(r) => r, Err(_) => return filename.to_string(), }; let caps = match re.captures(filename) { Some(c) => c, None => return filename.to_string(), }; let full_match = match caps.get(0) { Some(m) => m, None => return filename.to_string(), }; let extracted = match caps.get(self.regex_group) { Some(m) => m.as_str().to_string(), None => return filename.to_string(), }; if self.target == MoveTarget::None { return filename.to_string(); } // remove the full match from filename let remaining = if self.copy_mode { filename.to_string() } else { let mut r = String::new(); r.push_str(&filename[..full_match.start()]); r.push_str(&filename[full_match.end()..]); r }; self.place_at_target(&extracted, &remaining) } fn apply_swap(&self, filename: &str, sw_from: usize, sw_len: usize) -> String { let chars: Vec = filename.chars().collect(); let len = chars.len(); let a_start = self.source_from; let a_end = (a_start + self.source_length).min(len); let b_start = sw_from; let b_end = (b_start + sw_len).min(len); if a_start >= len || b_start >= len || self.source_length == 0 || sw_len == 0 { return filename.to_string(); } // ranges must not overlap if a_start < b_end && b_start < a_end { return filename.to_string(); } // ensure first < second let (r1_start, r1_end, r2_start, r2_end) = if a_start < b_start { (a_start, a_end, b_start, b_end) } else { (b_start, b_end, a_start, a_end) }; let part1: String = chars[r1_start..r1_end].iter().collect(); let part2: String = chars[r2_start..r2_end].iter().collect(); let mut result = String::new(); let before: String = chars[..r1_start].iter().collect(); let mid: String = chars[r1_end..r2_start].iter().collect(); let after: String = chars[r2_end..].iter().collect(); result.push_str(&before); result.push_str(&part2); result.push_str(&mid); result.push_str(&part1); result.push_str(&after); result } fn place_at_target(&self, extracted: &str, remaining: &str) -> String { match &self.target { MoveTarget::None => remaining.to_string(), MoveTarget::Start => { if self.separator.is_empty() { format!("{}{}", extracted, remaining) } else { format!("{}{}{}", extracted, self.separator, remaining) } } MoveTarget::End => { if self.separator.is_empty() { format!("{}{}", remaining, extracted) } else { format!("{}{}{}", remaining, self.separator, extracted) } } MoveTarget::Position(pos) => { let rem_chars: Vec = remaining.chars().collect(); let p = (*pos).min(rem_chars.len()); let before: String = rem_chars[..p].iter().collect(); let after: String = rem_chars[p..].iter().collect(); format!("{}{}{}", before, extracted, after) } } } } #[cfg(test)] mod tests { use super::*; #[test] fn move_to_end() { let rule = MovePartsRule { source_from: 0, source_length: 3, target: MoveTarget::End, separator: "_".into(), copy_mode: false, ..MovePartsRule::new() }; let ctx = RenameContext::dummy(0); assert_eq!(rule.apply("abcdef", &ctx), "def_abc"); } #[test] fn copy_to_start() { let rule = MovePartsRule { source_from: 3, source_length: 3, target: MoveTarget::Start, separator: "-".into(), copy_mode: true, ..MovePartsRule::new() }; let ctx = RenameContext::dummy(0); assert_eq!(rule.apply("abcdef", &ctx), "def-abcdef"); } }