1use chrono::{DateTime, Local, TimeZone};
7use regex::{Regex, RegexBuilder};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Method {
12 Format { template: String, start: u32 },
18 Replace { find: String, with: String },
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum TextCase {
27 #[default]
28 Keep,
29 Upper,
30 Lower,
31 Capitalize,
33}
34
35#[derive(Debug, Clone, Default)]
37pub struct Subject<'a> {
38 pub name: &'a str,
39 pub modified: u64,
41 pub created: u64,
42 pub tags: &'a [String],
43 pub folders: &'a [String],
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Rename {
50 pub method: Method,
51 pub case: TextCase,
52}
53
54const MAX_CHARS: usize = 255;
56
57impl Rename {
58 pub fn is_ready(&self) -> bool {
60 match &self.method {
61 Method::Format { template, .. } => !template.is_empty(),
62 Method::Replace { find, .. } => !find.is_empty(),
63 }
64 }
65
66 pub fn apply(&self, s: &Subject, index: usize, now: DateTime<Local>) -> String {
70 let name = match &self.method {
71 Method::Format { template, start } => format_name(template, s, *start as usize + index, now),
72 Method::Replace { find, with } => replace_name(s.name, find, with),
73 };
74 let name = if name.is_empty() { s.name.to_string() } else { name };
77 change_case(&name, self.case).chars().take(MAX_CHARS).collect()
78 }
79}
80
81fn parse_regex(find: &str) -> Option<(Regex, bool)> {
83 let rest = find.strip_prefix('/')?;
84 let end = rest.rfind('/')?;
85 let (pattern, flags) = (&rest[..end], &rest[end + 1..]);
86 if pattern.is_empty() || !flags.chars().all(|c| c.is_ascii_lowercase()) {
87 return None;
88 }
89 let re = RegexBuilder::new(pattern).case_insensitive(flags.contains('i')).multi_line(flags.contains('m')).build().ok()?;
90 Some((re, flags.contains('g')))
91}
92
93fn replace_name(name: &str, find: &str, with: &str) -> String {
94 match parse_regex(find) {
95 Some((re, true)) => re.replace_all(name, with).into_owned(),
96 Some((re, false)) => re.replace(name, with).into_owned(),
97 None if find.is_empty() => name.to_string(),
98 None => name.replace(find, with),
99 }
100}
101
102fn replace_token(text: &mut String, letters: &str, more: bool, with: &str) {
106 let lower = text.to_ascii_lowercase();
107 let want = format!("%{}", letters.to_ascii_lowercase());
108 let mut from = 0;
109 while let Some(hit) = lower[from..].find(&want) {
110 let at = from + hit;
111 let mut end = at + want.len();
112 if more {
113 let last = want.as_bytes()[want.len() - 1];
114 let extra = lower.as_bytes()[end..].iter().take_while(|&&b| b == last).count();
115 if extra == 0 {
116 from = at + 1;
117 continue;
118 }
119 end += extra;
120 }
121 text.replace_range(at..end, with);
122 return;
123 }
124}
125
126fn format_name(template: &str, s: &Subject, n: usize, now: DateTime<Local>) -> String {
127 let mut out = template.to_string();
128 let pad = |w: usize| format!("{n:0w$}");
129 let counters: [(&str, bool, usize); 10] = [
131 ("NNNNNNNN", true, 9),
132 ("NNNNNNNNN", false, 8),
133 ("NNNNNNNN", false, 8),
134 ("NNNNNNN", false, 7),
135 ("NNNNNN", false, 6),
136 ("NNNNN", false, 5),
137 ("NNNN", false, 4),
138 ("NNN", false, 3),
139 ("NN", false, 2),
140 ("N", false, 1),
141 ];
142 for (letters, more, width) in counters {
145 for _ in 0..2 {
146 replace_token(&mut out, letters, more, &pad(width));
147 }
148 }
149 let local = |ms: u64| Local.timestamp_millis_opt(ms as i64).single().unwrap_or(now);
150 let dates = [("D", now, '-'), ("M", local(s.modified), '_'), ("B", local(s.created), '_')];
151 for (letter, t, sep) in dates {
152 let ymd = format!("%Y{sep}%m{sep}%d");
153 let forms = [
154 (format!("{letter}{letter}"), true, t.format("%b %-d, %Y").to_string()),
155 (format!("{letter}{letter}{letter}"), false, t.format("%b %-d, %Y").to_string()),
156 (format!("{letter}{letter}"), false, t.format(&format!("%m{sep}%d")).to_string()),
157 (format!("{letter}HMS"), false, t.format(&format!("{ymd} %H_%M_%S")).to_string()),
158 (format!("{letter}HM"), false, t.format(&format!("{ymd} %H_%M")).to_string()),
159 (letter.to_string(), false, t.format(&ymd).to_string()),
160 ];
161 for _ in 0..2 {
162 for (letters, more, value) in &forms {
163 replace_token(&mut out, letters, *more, value);
164 }
165 }
166 }
167 if let Some(at) = out.find('*') {
168 out.replace_range(at..at + 1, s.name);
169 }
170 for (token, list) in [("%T", s.tags), ("%F", s.folders)] {
172 if out.contains(token) {
173 let mut sorted = list.to_vec();
174 sorted.sort();
175 replace_token(&mut out, &token[1..], false, &sorted.join("-"));
176 }
177 }
178 out
179}
180
181fn change_case(name: &str, case: TextCase) -> String {
182 match case {
183 TextCase::Keep => name.to_string(),
184 TextCase::Upper => name.to_uppercase(),
185 TextCase::Lower => name.to_lowercase(),
186 TextCase::Capitalize => {
187 let mut out = String::with_capacity(name.len());
190 let mut in_word = false;
191 for c in name.chars() {
192 if c.is_whitespace() {
193 in_word = false;
194 out.push(c);
195 } else if in_word {
196 out.extend(c.to_lowercase());
197 } else if c.is_ascii_alphanumeric() || c == '_' {
198 in_word = true;
199 out.push(c.to_ascii_uppercase());
200 } else {
201 out.push(c);
202 }
203 }
204 out
205 }
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 fn now() -> DateTime<Local> {
214 Local.with_ymd_and_hms(2026, 3, 7, 9, 5, 2).unwrap()
215 }
216
217 fn fmt(template: &str, start: u32) -> Rename {
218 Rename { method: Method::Format { template: template.into(), start }, case: TextCase::Keep }
219 }
220
221 fn subject(name: &str) -> Subject<'_> {
222 Subject { name, ..Default::default() }
223 }
224
225 #[test]
226 fn counters_pad_and_count_from_the_start() {
227 let r = fmt("Shot %NNN", 1);
228 assert_eq!(r.apply(&subject("a"), 0, now()), "Shot 001");
229 assert_eq!(r.apply(&subject("a"), 41, now()), "Shot 042");
230 assert_eq!(fmt("%n-%NN", 7).apply(&subject("a"), 2, now()), "9-09");
231 assert_eq!(fmt("x%NNNNNNNNNNNN", 0).apply(&subject("a"), 5, now()), "x000000005");
232 assert_eq!(fmt("%N", 1).apply(&subject("a"), 11, now()), "12");
234 }
235
236 #[test]
237 fn original_name_dates_and_tags() {
238 let tags = vec!["sky".to_string(), "blue".to_string()];
239 let modified = Local.with_ymd_and_hms(2024, 12, 31, 23, 59, 58).unwrap().timestamp_millis() as u64;
240 let s = Subject { name: "Logo", modified, created: modified, tags: &tags, folders: &[] };
241 assert_eq!(fmt("* (%D)", 1).apply(&s, 0, now()), "Logo (2026-03-07)");
242 assert_eq!(fmt("%dd %DHM", 1).apply(&s, 0, now()), "03-07 2026-03-07 09_05");
243 assert_eq!(fmt("%DHMS", 1).apply(&s, 0, now()), "2026-03-07 09_05_02");
244 assert_eq!(fmt("%DDD", 1).apply(&s, 0, now()), "Mar 7, 2026");
245 assert_eq!(fmt("%M / %bb / %MHMS", 1).apply(&s, 0, now()), "2024_12_31 / 12_31 / 2024_12_31 23_59_58");
246 assert_eq!(fmt("* %T", 1).apply(&s, 0, now()), "Logo blue-sky");
247 assert_eq!(fmt("* %t", 1).apply(&s, 0, now()), "Logo %t");
249 assert_eq!(fmt("*%F", 1).apply(&s, 0, now()), "Logo");
251 assert_eq!(fmt("**", 1).apply(&s, 0, now()), "Logo*");
253 assert_eq!(fmt("* %N", 1).apply(&subject("%D"), 0, now()), "%D 1");
255 }
256
257 #[test]
258 fn find_and_replace_plain_and_regex() {
259 let r = |find: &str, with: &str| Rename { method: Method::Replace { find: find.into(), with: with.into() }, case: TextCase::Keep };
260 assert_eq!(r("a", "o").apply(&subject("banana"), 0, now()), "bonono");
261 assert_eq!(r("/a/", "o").apply(&subject("banana"), 0, now()), "bonana");
262 assert_eq!(r("/A/gi", "o").apply(&subject("banana"), 0, now()), "bonono");
263 assert_eq!(r("/(\\d+)/g", "#$1").apply(&subject("v1 v22"), 0, now()), "v#1 v#22");
264 assert_eq!(r("/(/", "x").apply(&subject("a/(/b"), 0, now()), "axb");
266 assert_eq!(r("banana", "").apply(&subject("banana"), 0, now()), "banana");
268 assert!(!r("", "x").is_ready());
269 assert!(!fmt("", 1).is_ready());
270 }
271
272 #[test]
273 fn text_case_and_length() {
274 let cased = |case| Rename { method: Method::Format { template: "*".into(), start: 1 }, case };
275 let s = subject("hello wORLD (draft) élan");
276 assert_eq!(cased(TextCase::Upper).apply(&s, 0, now()), "HELLO WORLD (DRAFT) ÉLAN");
277 assert_eq!(cased(TextCase::Lower).apply(&s, 0, now()), "hello world (draft) élan");
278 assert_eq!(cased(TextCase::Capitalize).apply(&s, 0, now()), "Hello World (Draft) éLan");
279 let long = "x".repeat(300);
280 assert_eq!(cased(TextCase::Keep).apply(&subject(&long), 0, now()).chars().count(), 255);
281 }
282}