batch_renamer/
lib.rs

1// Copyright (C) 2024 Daniel Mueller <deso@posteo.net>
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4#![allow(clippy::let_and_return, clippy::let_unit_value)]
5
6use std::ffi::OsStr;
7use std::ffi::OsString;
8use std::path::Path;
9use std::path::PathBuf;
10use std::process::Output;
11use std::process::Stdio;
12
13use anyhow::bail;
14use anyhow::Context as _;
15use anyhow::Result;
16
17use tempfile::tempdir;
18
19use tokio::fs::canonicalize;
20use tokio::fs::read_dir;
21use tokio::fs::write;
22use tokio::process::Command;
23
24
25/// Concatenate a command and its arguments into a single string.
26fn concat_command<C, A, S>(command: C, args: A) -> OsString
27where
28  C: AsRef<OsStr>,
29  A: IntoIterator<Item = S>,
30  S: AsRef<OsStr>,
31{
32  args
33    .into_iter()
34    .fold(command.as_ref().to_os_string(), |mut cmd, arg| {
35      cmd.push(OsStr::new(" "));
36      cmd.push(arg.as_ref());
37      cmd
38    })
39}
40
41/// Format a command with the given list of arguments as a string.
42#[doc(hidden)]
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#[doc(hidden)]
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    .args(args.clone())
97    .output()
98    .await
99    .with_context(|| {
100      format!(
101        "failed to run `{}`",
102        format_command(command.as_ref(), args.clone())
103      )
104    })?;
105
106  let () = evaluate(&output, command, args)?;
107  Ok(output)
108}
109
110/// Run a command with the provided arguments.
111async fn run_in<C, A, S, D>(command: C, args: A, dir: D) -> Result<()>
112where
113  C: AsRef<OsStr>,
114  A: IntoIterator<Item = S> + Clone,
115  S: AsRef<OsStr>,
116  D: AsRef<Path>,
117{
118  let _output = run_in_impl(command, args, dir, Stdio::null()).await?;
119  Ok(())
120}
121
122
123/// Rename a file using the provided command.
124///
125/// The function returns the new name. If `dry_run` is `true`, don't
126/// actually perform the rename but just "simulate" it.
127pub async fn rename(file: &Path, command: &[OsString], dry_run: bool) -> Result<PathBuf> {
128  let tmp = tempdir().context("failed to create temporary directory")?;
129  let path = canonicalize(file)
130    .await
131    .with_context(|| format!("failed to canonicalize `{}`", file.display()))?;
132  let dir = path
133    .parent()
134    .with_context(|| format!("`{}` does not contain a parent", path.display()))?;
135  let file = path
136    .file_name()
137    .with_context(|| format!("path `{}` does not have file name", path.display()))?;
138  let tmp_file = tmp.path().join(file);
139  let () = write(&tmp_file, b"")
140    .await
141    .with_context(|| format!("failed to create `{}`", tmp_file.display()))?;
142
143  let (cmd, cmd_args) = command.split_first().context("rename command is missing")?;
144  // Perform the rename in our temporary directory.
145  let () = run_in(
146    cmd,
147    cmd_args.iter().chain([&file.to_os_string()]),
148    tmp.path(),
149  )
150  .await?;
151
152  let new = read_dir(tmp.path())
153    .await
154    .with_context(|| {
155      format!(
156        "failed to read contents of directory `{}`",
157        tmp.path().display()
158      )
159    })?
160    .next_entry()
161    .await
162    .with_context(|| {
163      format!(
164        "no file found in `{}`; did the rename operation delete instead?",
165        tmp.path().display()
166      )
167    })?
168    .with_context(|| format!("failed to read first file of `{}`", tmp.path().display()))?;
169
170  if !dry_run {
171    // Perform the rename on the live data.
172    let () = run_in(cmd, cmd_args.iter().chain([&file.to_os_string()]), dir).await?;
173  }
174
175  let new_path = dir.join(new.file_name());
176  Ok(new_path)
177}