Skip to main content

batch_renamer/
lib.rs

1// Copyright (C) 2024-2026 Daniel Mueller <deso@posteo.net>
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Building blocks for batch renaming of files.
5
6#![doc(hidden)]
7
8use std::ffi::OsStr;
9use std::ffi::OsString;
10use std::path::Path;
11use std::process::Output;
12use std::process::Stdio;
13
14use anyhow::bail;
15use anyhow::Context as _;
16use anyhow::Result;
17
18use tempfile::tempdir;
19
20use tokio::fs::canonicalize;
21use tokio::fs::read_dir;
22use tokio::fs::write;
23use tokio::process::Command;
24
25
26/// Concatenate a command and its arguments into a single string.
27fn concat_command<C, A, S>(command: C, args: A) -> OsString
28where
29  C: AsRef<OsStr>,
30  A: IntoIterator<Item = S>,
31  S: AsRef<OsStr>,
32{
33  args
34    .into_iter()
35    .fold(command.as_ref().to_os_string(), |mut cmd, arg| {
36      cmd.push(OsStr::new(" "));
37      cmd.push(arg.as_ref());
38      cmd
39    })
40}
41
42/// Format a command with the given list of arguments as a string.
43pub fn format_command<C, A, S>(command: C, args: A) -> String
44where
45  C: AsRef<OsStr>,
46  A: IntoIterator<Item = S>,
47  S: AsRef<OsStr>,
48{
49  concat_command(command, args).to_string_lossy().to_string()
50}
51
52
53/// Evaluate the result of a command invocation.
54pub fn evaluate<C, A, S>(output: &Output, command: C, args: A) -> Result<()>
55where
56  C: AsRef<OsStr>,
57  A: IntoIterator<Item = S>,
58  S: AsRef<OsStr>,
59{
60  if !output.status.success() {
61    let code = if let Some(code) = output.status.code() {
62      format!(" ({code})")
63    } else {
64      " (terminated by signal)".to_string()
65    };
66
67    let stderr = String::from_utf8_lossy(&output.stderr);
68    let stderr = stderr.trim_end();
69    let stderr = if !stderr.is_empty() {
70      format!(": {stderr}")
71    } else {
72      String::new()
73    };
74
75    bail!(
76      "`{}` reported non-zero exit-status{code}{stderr}",
77      format_command(command, args),
78    );
79  }
80  Ok(())
81}
82
83
84/// Run a command with the provided arguments.
85async fn run_in_impl<C, A, S, D>(command: C, args: A, dir: D, stdout: Stdio) -> Result<Output>
86where
87  C: AsRef<OsStr>,
88  A: IntoIterator<Item = S> + Clone,
89  S: AsRef<OsStr>,
90  D: AsRef<Path>,
91{
92  let output = Command::new(command.as_ref())
93    .current_dir(dir)
94    .stdin(Stdio::null())
95    .stdout(stdout)
96    .stderr(Stdio::piped())
97    .args(args.clone())
98    .output()
99    .await
100    .with_context(|| {
101      format!(
102        "failed to run `{}`",
103        format_command(command.as_ref(), args.clone())
104      )
105    })?;
106
107  let () = evaluate(&output, command, args)?;
108  Ok(output)
109}
110
111/// Run a command with the provided arguments.
112async fn run_in<C, A, S, D>(command: C, args: A, dir: D) -> Result<()>
113where
114  C: AsRef<OsStr>,
115  A: IntoIterator<Item = S> + Clone,
116  S: AsRef<OsStr>,
117  D: AsRef<Path>,
118{
119  let _output = run_in_impl(command, args, dir, Stdio::null()).await?;
120  Ok(())
121}
122
123
124/// Simulate a rename of a file using the provided command.
125///
126/// The rename is performed in a temporary directory and returned is
127/// only the new file name, excluding any path.
128pub async fn simulate_rename(path: &Path, command: &[OsString]) -> Result<OsString> {
129  let tmp = tempdir().context("failed to create temporary directory")?;
130  let path = canonicalize(path)
131    .await
132    .with_context(|| format!("failed to canonicalize `{}`", path.display()))?;
133  let file = path
134    .file_name()
135    .with_context(|| format!("path `{}` does not have file name", path.display()))?;
136  let tmp_file = tmp.path().join(file);
137  let () = write(&tmp_file, b"")
138    .await
139    .with_context(|| format!("failed to create `{}`", tmp_file.display()))?;
140
141  let (cmd, cmd_args) = command.split_first().context("rename command is missing")?;
142  // Perform the rename in our temporary directory.
143  let () = run_in(
144    cmd,
145    cmd_args.iter().chain([&file.to_os_string()]),
146    tmp.path(),
147  )
148  .await?;
149
150  let new = read_dir(tmp.path())
151    .await
152    .with_context(|| {
153      format!(
154        "failed to read contents of directory `{}`",
155        tmp.path().display()
156      )
157    })?
158    .next_entry()
159    .await
160    .with_context(|| {
161      format!(
162        "no file found in `{}`; did the rename operation delete instead?",
163        tmp.path().display()
164      )
165    })?
166    .with_context(|| format!("failed to read first file of `{}`", tmp.path().display()))?;
167
168  Ok(new.file_name())
169}