Skip to main content

linuxutils_misc/
rename.rs

1use linuxutils_common::man::ManContent;
2
3pub const MAN: ManContent = ManContent::empty();
4
5use clap::Parser;
6use std::{
7    io::{BufRead, Write},
8    path::Path,
9    process::ExitCode,
10};
11
12#[derive(Parser)]
13#[command(
14    name = "rename",
15    about = "Rename files by replacing occurrences of a string in their filenames",
16    override_usage = "rename [options] <expression> <replacement> <file>..."
17)]
18pub struct Args {
19    /// Act on the target of symlinks instead of the symlink itself
20    #[arg(short = 's', long = "symlink")]
21    symlink: bool,
22
23    /// Show which files were renamed
24    #[arg(short = 'v', long = "verbose")]
25    verbose: bool,
26
27    /// Don't actually rename, just show what would happen
28    #[arg(short = 'n', long = "no-act")]
29    no_act: bool,
30
31    /// Replace all occurrences of expression, not just the first
32    #[arg(short = 'a', long = "all")]
33    all: bool,
34
35    /// Replace the last occurrence instead of the first
36    #[arg(short = 'l', long = "last")]
37    last: bool,
38
39    /// Don't overwrite existing files
40    #[arg(short = 'o', long = "no-overwrite")]
41    no_overwrite: bool,
42
43    /// Ask before overwriting existing files
44    #[arg(short = 'i', long = "interactive")]
45    interactive: bool,
46
47    /// The expression to search for
48    expression: String,
49
50    /// The replacement string
51    replacement: String,
52
53    /// Files to rename
54    #[arg(required = true)]
55    files: Vec<String>,
56}
57
58fn replace_string(
59    input: &str,
60    expression: &str,
61    replacement: &str,
62    all: bool,
63    last: bool,
64) -> String {
65    if expression.is_empty() {
66        if all {
67            let mut result = String::with_capacity(
68                input.len() + replacement.len() * (input.chars().count() + 1),
69            );
70            result.push_str(replacement);
71            for ch in input.chars() {
72                result.push(ch);
73                result.push_str(replacement);
74            }
75            result
76        } else if last {
77            let mut result =
78                String::with_capacity(input.len() + replacement.len());
79            result.push_str(input);
80            result.push_str(replacement);
81            result
82        } else {
83            let mut result =
84                String::with_capacity(input.len() + replacement.len());
85            result.push_str(replacement);
86            result.push_str(input);
87            result
88        }
89    } else if all {
90        input.replace(expression, replacement)
91    } else if last {
92        match input.rfind(expression) {
93            Some(pos) => {
94                let mut result = String::with_capacity(
95                    input.len() - expression.len() + replacement.len(),
96                );
97                result.push_str(&input[..pos]);
98                result.push_str(replacement);
99                result.push_str(&input[pos + expression.len()..]);
100                result
101            }
102            None => input.to_string(),
103        }
104    } else {
105        match input.find(expression) {
106            Some(pos) => {
107                let mut result = String::with_capacity(
108                    input.len() - expression.len() + replacement.len(),
109                );
110                result.push_str(&input[..pos]);
111                result.push_str(replacement);
112                result.push_str(&input[pos + expression.len()..]);
113                result
114            }
115            None => input.to_string(),
116        }
117    }
118}
119
120fn prompt_overwrite(new_name: &str) -> bool {
121    eprint!("{new_name}: overwrite? ");
122    std::io::stderr().flush().ok();
123    let mut line = String::new();
124    if std::io::stdin().lock().read_line(&mut line).is_err() {
125        return false;
126    }
127    let trimmed = line.trim().to_lowercase();
128    trimmed == "y" || trimmed == "yes"
129}
130
131pub fn run(args: Args) -> ExitCode {
132    let use_full_path =
133        args.expression.contains('/') || args.replacement.contains('/');
134
135    let mut succeeded = 0u64;
136    let mut failed = 0u64;
137    let mut skipped = 0u64;
138
139    for file in &args.files {
140        let path = Path::new(file);
141
142        if args.symlink {
143            let target = match std::fs::read_link(path) {
144                Ok(t) => t,
145                Err(e) => {
146                    eprintln!("rename: {file}: not a symlink: {e}");
147                    failed += 1;
148                    continue;
149                }
150            };
151
152            let target_str = target.to_string_lossy();
153            let new_target = replace_string(
154                &target_str,
155                &args.expression,
156                &args.replacement,
157                args.all,
158                args.last,
159            );
160
161            if new_target == target_str.as_ref() {
162                skipped += 1;
163                continue;
164            }
165
166            let new_target_path = Path::new(&new_target);
167
168            if args.no_overwrite && new_target_path.exists() {
169                if args.verbose {
170                    eprintln!(
171                        "rename: {file}: not overwriting symlink target `{new_target}'"
172                    );
173                }
174                skipped += 1;
175                continue;
176            }
177
178            if args.verbose || args.no_act {
179                println!("`{file}': `{target_str}' -> `{new_target}'");
180            }
181
182            if !args.no_act {
183                if let Err(e) = std::fs::remove_file(path) {
184                    eprintln!("rename: {file}: removing symlink failed: {e}");
185                    failed += 1;
186                    continue;
187                }
188                #[cfg(unix)]
189                {
190                    if let Err(e) =
191                        std::os::unix::fs::symlink(new_target_path, path)
192                    {
193                        eprintln!(
194                            "rename: {file}: creating symlink failed: {e}"
195                        );
196                        failed += 1;
197                        continue;
198                    }
199                }
200                succeeded += 1;
201            }
202        } else {
203            let (dir, name_to_transform) = if use_full_path {
204                (String::new(), file.to_string())
205            } else {
206                let parent = path
207                    .parent()
208                    .map(|p| p.to_string_lossy().to_string())
209                    .unwrap_or_default();
210                let basename = path
211                    .file_name()
212                    .map(|n| n.to_string_lossy().to_string())
213                    .unwrap_or_else(|| file.to_string());
214                (parent, basename)
215            };
216
217            let new_name_part = replace_string(
218                &name_to_transform,
219                &args.expression,
220                &args.replacement,
221                args.all,
222                args.last,
223            );
224
225            if new_name_part == name_to_transform {
226                skipped += 1;
227                continue;
228            }
229
230            let new_path = if !use_full_path && !dir.is_empty() {
231                format!("{dir}/{new_name_part}")
232            } else {
233                new_name_part.clone()
234            };
235
236            let target_path = Path::new(&new_path);
237
238            if target_path.exists() || target_path.symlink_metadata().is_ok() {
239                if args.no_overwrite {
240                    if args.verbose {
241                        eprintln!("rename: {file}: not overwritten");
242                    }
243                    skipped += 1;
244                    continue;
245                }
246                if args.interactive && !prompt_overwrite(&new_path) {
247                    skipped += 1;
248                    continue;
249                }
250            }
251
252            if args.verbose || args.no_act {
253                println!("`{file}' -> `{new_path}'");
254            }
255
256            if !args.no_act {
257                if let Err(e) = std::fs::rename(file, &new_path) {
258                    eprintln!("rename: {file}: rename failed: {e}");
259                    failed += 1;
260                    continue;
261                }
262                succeeded += 1;
263            }
264        }
265    }
266
267    if args.no_act {
268        return ExitCode::SUCCESS;
269    }
270
271    let total = succeeded + failed + skipped;
272    if failed == total {
273        ExitCode::from(1)
274    } else if failed > 0 {
275        ExitCode::from(2)
276    } else if succeeded == 0 {
277        ExitCode::from(4)
278    } else {
279        ExitCode::SUCCESS
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn test_replace_first() {
289        assert_eq!(
290            replace_string("foo.bar.baz", "bar", "qux", false, false),
291            "foo.qux.baz"
292        );
293    }
294
295    #[test]
296    fn test_replace_all() {
297        assert_eq!(
298            replace_string("foo.bar.bar", "bar", "qux", true, false),
299            "foo.qux.qux"
300        );
301    }
302
303    #[test]
304    fn test_replace_last() {
305        assert_eq!(
306            replace_string("foo.bar.bar", "bar", "qux", false, true),
307            "foo.bar.qux"
308        );
309    }
310
311    #[test]
312    fn test_replace_no_match() {
313        assert_eq!(
314            replace_string("foobar", "xyz", "qux", false, false),
315            "foobar"
316        );
317    }
318
319    #[test]
320    fn test_replace_empty_expr() {
321        assert_eq!(replace_string("abc", "", "X", false, false), "Xabc");
322    }
323
324    #[test]
325    fn test_replace_empty_expr_last() {
326        assert_eq!(replace_string("abc", "", "X", false, true), "abcX");
327    }
328
329    #[test]
330    fn test_replace_empty_expr_all() {
331        assert_eq!(replace_string("ab", "", "X", true, false), "XaXbX");
332    }
333}