diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | Cargo.lock | 181 | ||||
| -rw-r--r-- | Cargo.toml | 14 | ||||
| -rw-r--r-- | rustfmt.toml | 2 | ||||
| -rw-r--r-- | src/error.rs | 63 | ||||
| -rw-r--r-- | src/main.rs | 255 | ||||
| -rw-r--r-- | src/main_result.rs | 32 | 
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), +		} +	} +} |