aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--Cargo.lock181
-rw-r--r--Cargo.toml14
-rw-r--r--rustfmt.toml2
-rw-r--r--src/error.rs63
-rw-r--r--src/main.rs255
-rw-r--r--src/main_result.rs32
7 files changed, 549 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4db575c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+testdata/
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..dd0880f
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,181 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42"
+
+[[package]]
+name = "cc"
+version = "1.0.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "errno"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a"
+dependencies = [
+ "errno-dragonfly",
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "errno-dragonfly"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764"
+
+[[package]]
+name = "lexopt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c5d9b5843e8c9311ff602e6bd50855015e99e75159c2c54fe104cfac241f552"
+
+[[package]]
+name = "libc"
+version = "0.2.147"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0"
+
+[[package]]
+name = "mmv"
+version = "0.1.0"
+dependencies = [
+ "lexopt",
+ "tempfile",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a962918ea88d644592894bc6dc55acc6c0956488adcebbfb6e273506b7fd6e5"
+dependencies = [
+ "bitflags 2.3.3",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5486094ee78b2e5038a6382ed7645bc084dc2ec433426ca4c3cb61e2007b8998"
+dependencies = [
+ "cfg-if",
+ "fastrand",
+ "redox_syscall",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..55763a3
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "mmv"
+version = "0.1.0"
+edition = "2021"
+license = "0BSD"
+description = "Move multiple/mapped files"
+repository = "https://git.thomasvoss.com/mmv"
+authors = [
+ "Thomas Voss <mail@thomasvoss.com>",
+]
+
+[dependencies]
+lexopt = "0.1.0"
+tempfile = "3.7.0"
diff --git a/rustfmt.toml b/rustfmt.toml
new file mode 100644
index 0000000..bc81510
--- /dev/null
+++ b/rustfmt.toml
@@ -0,0 +1,2 @@
+edition = "2021"
+hard_tabs = true \ No newline at end of file
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..e592e50
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,63 @@
+use std::{
+ env,
+ ffi::OsString,
+ fmt::{self, Display, Formatter},
+ io,
+ path::PathBuf,
+};
+
+pub enum Error {
+ BadArgs(Option<lexopt::Error>),
+ BadDecoding(String),
+ BadLengths,
+ DuplicateInput(PathBuf),
+ DuplicateOutput(PathBuf),
+ IO(io::Error),
+ Nop,
+ SpawnFailed(OsString, io::Error),
+}
+
+impl Display for Error {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ let p = env::args().next().unwrap();
+ match self {
+ Self::BadArgs(o) => {
+ if let Some(v) = o {
+ writeln!(f, "{p}: {v}")?;
+ }
+ writeln!(f, "Usage: {p} [-0eiv] command [argument ...]")
+ }
+ Self::BadDecoding(s) => writeln!(f, "{p}: Decoding the file “{s}” failed!"),
+ Self::BadLengths => writeln!(f, "{p}: Files have been added or removed during editing"),
+ Self::DuplicateInput(s) => writeln!(
+ f,
+ "{p}: Input file “{}” specified more than once",
+ s.to_string_lossy()
+ ),
+ Self::DuplicateOutput(s) => writeln!(
+ f,
+ "{p}: Output file “{}” specified more than once",
+ s.to_string_lossy()
+ ),
+ Self::IO(e) => writeln!(f, "{p}: {e}"),
+ Self::Nop => Ok(()),
+ Self::SpawnFailed(ed, e) => writeln!(
+ f,
+ "{p}: Failed to spawn utility “{}”: {e}",
+ ed.to_string_lossy()
+ ),
+ }
+ }
+}
+
+impl From<io::Error> for Error {
+ fn from(e: io::Error) -> Self {
+ Self::IO(e)
+ }
+}
+
+impl From<lexopt::Error> for Error {
+ fn from(e: lexopt::Error) -> Self {
+ Self::BadArgs(Some(e))
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..c841a2f
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,255 @@
+mod error;
+mod main_result;
+
+use std::{
+ cmp::Reverse,
+ collections::{HashSet, hash_map::DefaultHasher},
+ env,
+ ffi::OsString,
+ fs,
+ io::{self, BufRead, BufReader, BufWriter, Write},
+ iter,
+ path::{Component, Path, PathBuf},
+ process::{Command, Stdio}, hash::{Hash, Hasher}, fmt::{Display, Formatter, self},
+};
+
+use tempfile::tempdir;
+
+use {error::Error, main_result::MainResult};
+
+#[derive(Default)]
+struct Flags {
+ pub dryrun: bool,
+ pub encode: bool,
+ pub individual: bool,
+ pub nul: bool,
+ pub verbose: bool,
+}
+
+fn main() -> MainResult {
+ work().into()
+}
+
+fn work() -> Result<(), Error> {
+ let (flags, rest) = parse_args()?;
+ let (cmd, args) = rest.split_first().ok_or(Error::BadArgs(None))?;
+
+ // Collect sources from standard input
+ let srcs = io::stdin()
+ .lines()
+ .map(|l| {
+ l.map_err(Error::from).and_then(|l| {
+ if l.is_empty() {
+ Err(Error::BadArgs(None))
+ } else {
+ Ok(l)
+ }
+ })
+ })
+ .collect::<Result<Vec<String>, Error>>()?;
+
+ // Spawn the child process
+ let mut child = Command::new(cmd)
+ .args(args)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .spawn()
+ .map_err(|e| Error::SpawnFailed(cmd.to_owned(), e))?;
+
+ // Pass the source files to the child process.
+ // TODO: Don’t use expect; create a custom error
+ {
+ let ci = child
+ .stdin
+ .take()
+ .expect("Could not open the child process’ stdin");
+ let mut ci = BufWriter::new(ci);
+ if flags.encode {
+ srcs.iter()
+ .try_for_each(|src| writeln!(ci, "{}", encode_string(src)))?;
+ } else {
+ srcs.iter().try_for_each(|src| writeln!(ci, "{}", src))?;
+ }
+ }
+
+ // Read the destination file list from the process.
+ let mut dsts = Vec::with_capacity(srcs.len());
+ {
+ let co = child
+ .stdout
+ .take()
+ .expect("Could not open the child process’ stdout.");
+ let co = BufReader::new(co);
+
+ // TODO: Don’t allocate an intermediary String per line, by using the BufReader buffer.
+ co.lines().try_for_each(|dst| -> Result<(), Error> {
+ if flags.encode {
+ dsts.push(decode_string(&dst?)?);
+ } else {
+ dsts.push(dst?);
+ }
+ Ok(())
+ })?;
+
+ if dsts.len() != srcs.len() {
+ return Err(Error::BadLengths);
+ }
+ }
+
+ /* If the process failed, it is expected to print an error message; as such,
+ we exit directly. */
+ if !child.wait()?.success() {
+ return Err(Error::Nop);
+ }
+
+ let mut uniq_srcs: HashSet<PathBuf> = HashSet::with_capacity(srcs.len());
+ let mut uniq_dsts: HashSet<PathBuf> = HashSet::with_capacity(dsts.len());
+
+ let mut src_dst = srcs
+ .iter()
+ .zip(dsts)
+ .map(|(s, d)| -> Result<(PathBuf, PathBuf), Error> {
+ let s = fs::canonicalize(s)?;
+ let d = env::current_dir()?.join(Path::new(&d));
+ let d = normalize_path(&d);
+
+ if !uniq_srcs.insert(s.clone()) {
+ Err(Error::DuplicateInput(s))
+ } else if !uniq_dsts.insert(d.clone()) {
+ Err(Error::DuplicateOutput(d))
+ } else {
+ Ok((s, d))
+ }
+ })
+ .collect::<Result<Vec<_>, Error>>()?;
+
+ /* Sort the src/dst pairs so that the sources with the longest componenets
+ * come first.
+ */
+ src_dst.sort_by_key(|(s, _)| Reverse(s.components().count()));
+
+ let dir = tempdir()?;
+ for s_d in src_dst.iter_mut() {
+ let (s, d) = s_d;
+ let mut hasher = DefaultHasher::new();
+ s.hash(&mut hasher);
+ let file = hasher.finish().to_string();
+ let dst = dir.path().join(&file);
+
+ if flags.dryrun {
+ println!("{} -> {}", s.as_path().display(), dst.as_path().display());
+ } else {
+ fs::rename(s, &dst)?;
+ }
+
+ *s_d = (dst, d.to_path_buf())
+ }
+
+ for (s, d) in src_dst.iter().rev() {
+ if flags.dryrun {
+ println!("{} -> {}", s.as_path().display(), d.as_path().display());
+ } else {
+ fs::rename(s, d)?;
+ }
+ }
+
+ Ok(())
+}
+
+fn parse_args() -> Result<(Flags, Vec<OsString>), lexopt::Error> {
+ use lexopt::prelude::*;
+
+ let mut rest = Vec::with_capacity(env::args().len());
+ let mut flags = Flags::default();
+ let mut parser = lexopt::Parser::from_env();
+ while let Some(arg) = parser.next()? {
+ match arg {
+ Short('0') | Long("nul") => flags.nul = true,
+ Short('d') | Long("dryrun") => flags.dryrun = true,
+ Short('e') | Long("encode") => flags.encode = true,
+ Short('i') | Long("individual") => flags.individual = true,
+ Short('v') | Long("verbose") => flags.verbose = true,
+ Value(v) => {
+ rest.push(v);
+ rest.extend(iter::from_fn(|| parser.value().ok()));
+ }
+ _ => return Err(arg.unexpected()),
+ }
+ }
+
+ Ok((flags, rest))
+}
+
+fn encode_string(s: &str) -> String {
+ s.chars()
+ .flat_map(|c| {
+ let cs = match c {
+ '\\' => ['\\', '\\'],
+ '\n' => ['\\', 'n'],
+ _ => [c, '\0'],
+ };
+ cs.into_iter()
+ .enumerate()
+ .filter(|(i, c)| *i != 1 || *c != '\0')
+ .map(|(_, c)| c)
+ })
+ .collect::<String>()
+}
+
+fn decode_string(s: &str) -> Result<String, Error> {
+ let mut pv = false;
+
+ s.chars()
+ .map(|c| {
+ Ok(match (pv, c) {
+ (true, '\\') => {
+ pv = false;
+ Some('\\')
+ }
+ (true, 'n') => {
+ pv = false;
+ Some('\n')
+ }
+ (true, _) => {
+ pv = false;
+ return Err(());
+ }
+ (false, '\\') => {
+ pv = true;
+ None
+ }
+ (false, _) => Some(c),
+ })
+ })
+ .filter_map(Result::transpose)
+ .collect::<Result<String, ()>>()
+ .map_err(|()| Error::BadDecoding(s.to_string()))
+}
+
+/* Blatantly stolen from the Cargo source code. This is MIT licensed. */
+fn normalize_path(path: &Path) -> PathBuf {
+ let mut components = path.components().peekable();
+ let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() {
+ components.next();
+ PathBuf::from(c.as_os_str())
+ } else {
+ PathBuf::new()
+ };
+
+ for component in components {
+ match component {
+ Component::Prefix(..) => unreachable!(),
+ Component::RootDir => {
+ ret.push(component.as_os_str());
+ }
+ Component::CurDir => {}
+ Component::ParentDir => {
+ ret.pop();
+ }
+ Component::Normal(c) => {
+ ret.push(c);
+ }
+ }
+ }
+ ret
+}
diff --git a/src/main_result.rs b/src/main_result.rs
new file mode 100644
index 0000000..36e3096
--- /dev/null
+++ b/src/main_result.rs
@@ -0,0 +1,32 @@
+use std::{
+ io::{self, Write},
+ process::{ExitCode, Termination},
+};
+
+use super::error::Error;
+
+pub enum MainResult {
+ Success,
+ Failure(Error),
+}
+
+impl Termination for MainResult {
+ fn report(self) -> ExitCode {
+ match self {
+ Self::Success => ExitCode::SUCCESS,
+ Self::Failure(e) => {
+ let _ = write!(io::stderr(), "{e}");
+ ExitCode::FAILURE
+ }
+ }
+ }
+}
+
+impl From<Result<(), Error>> for MainResult {
+ fn from(r: Result<(), Error>) -> Self {
+ match r {
+ Ok(()) => Self::Success,
+ Err(e) => Self::Failure(e),
+ }
+ }
+}