Files
oh-my-pi/crates/pi-shell/src/cmp.rs
T
can1357 269c267397 feat(pi-shell): implemented cmp utility for embedded shell
- Added cmp builtin with full POSIX-compatible flag support including `-b`, `-i`, `-l`, `-s`, `-x`, `-h`, `-z`.
- Integrated into shell builtin registry and coreutils module.
- Added comprehensive tests using tempfile for temporary directories.
2026-07-24 08:05:12 +02:00

636 lines
16 KiB
Rust

//! Context-safe, in-process `cmp` implementation for the embedded shell.
use std::{
ffi::{OsStr, OsString},
fs::{self, File},
io::{self, BufRead, BufReader, Cursor, Read, Seek, SeekFrom, Write},
};
use clap::{Arg, ArgAction, ArgMatches, Command};
use pi_uutils_ctx::format_usage;
const OPT_PRINT_BYTES: &str = "print-bytes";
const OPT_IGNORE_INITIAL: &str = "ignore-initial";
const OPT_VERBOSE: &str = "verbose";
const OPT_BYTES: &str = "bytes";
const OPT_QUIET: &str = "quiet";
const OPT_HEX: &str = "hex";
const OPT_NO_FOLLOW: &str = "no-follow";
const OPT_SIZE_FIRST: &str = "size-first";
const ARG_FILE1: &str = "file1";
const ARG_FILE2: &str = "file2";
const ARG_SKIP1: &str = "skip1";
const ARG_SKIP2: &str = "skip2";
const BUFFER_SIZE: usize = 64 * 1024;
#[derive(Clone, Copy)]
struct Options {
print_bytes: bool,
verbose: bool,
quiet: bool,
hex: bool,
no_follow: bool,
size_first: bool,
limit: Option<u64>,
skip1: u64,
skip2: u64,
}
enum InputReader {
File(File),
Stdin(pi_uutils_ctx::CtxStdin),
Bytes(Cursor<Vec<u8>>),
}
impl Read for InputReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match self {
Self::File(file) => file.read(buf),
Self::Stdin(stdin) => stdin.read(buf),
Self::Bytes(bytes) => bytes.read(buf),
}
}
}
impl InputReader {
fn skip(&mut self, count: u64) -> io::Result<()> {
match self {
Self::File(file) => {
file.seek(SeekFrom::Start(count))?;
},
Self::Bytes(bytes) => {
let length = u64::try_from(bytes.get_ref().len()).unwrap_or(u64::MAX);
bytes.set_position(count.min(length));
},
Self::Stdin(stdin) => {
io::copy(&mut stdin.take(count), &mut io::sink())?;
},
}
Ok(())
}
}
struct Input {
reader: BufReader<InputReader>,
regular_len: Option<u64>,
}
/// Runs `cmp` against invocation-scoped stdin/stdout/stderr and shell-relative
/// paths.
pub fn run(argv: Vec<OsString>) -> i32 {
let matches = match command().try_get_matches_from(argv) {
Ok(matches) => matches,
Err(err) => {
let code = err.exit_code();
let rendered = err.to_string();
if err.use_stderr() {
let _ = write!(pi_uutils_ctx::stderr(), "{rendered}");
} else {
let _ = write!(pi_uutils_ctx::stdout(), "{rendered}");
}
return code;
},
};
match compare(&matches) {
Ok(code) => code,
Err(message) => {
let _ = writeln!(pi_uutils_ctx::stderr(), "cmp: {message}");
2
},
}
}
fn command() -> Command {
Command::new("cmp")
.version(concat!("cmp (pi-shell) ", env!("CARGO_PKG_VERSION")))
.about("Compare two files byte by byte.")
.override_usage(format_usage("cmp [OPTION]... FILE1 [FILE2 [SKIP1 [SKIP2]]]"))
.disable_help_flag(true)
.disable_version_flag(true)
.infer_long_args(true)
.arg(
Arg::new(OPT_PRINT_BYTES)
.short('b')
.long(OPT_PRINT_BYTES)
.help("print differing bytes")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new(OPT_IGNORE_INITIAL)
.short('i')
.long(OPT_IGNORE_INITIAL)
.value_name("SKIP[:SKIP2]")
.help("skip initial bytes of both inputs"),
)
.arg(
Arg::new(OPT_VERBOSE)
.short('l')
.long(OPT_VERBOSE)
.help("print every differing byte")
.conflicts_with_all([OPT_QUIET, OPT_HEX])
.action(ArgAction::SetTrue),
)
.arg(
Arg::new(OPT_BYTES)
.short('n')
.long(OPT_BYTES)
.value_name("LIMIT")
.help("compare at most LIMIT bytes"),
)
.arg(
Arg::new(OPT_QUIET)
.short('s')
.long(OPT_QUIET)
.visible_alias("silent")
.help("suppress all normal output")
.conflicts_with_all([OPT_VERBOSE, OPT_HEX])
.action(ArgAction::SetTrue),
)
.arg(
Arg::new(OPT_HEX)
.short('x')
.help("print every difference in hexadecimal with zero-based offsets")
.conflicts_with_all([OPT_VERBOSE, OPT_QUIET])
.action(ArgAction::SetTrue),
)
.arg(
Arg::new(OPT_NO_FOLLOW)
.short('h')
.help("compare symbolic-link targets instead of following links")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new(OPT_SIZE_FIRST)
.short('z')
.help("compare regular-file sizes before contents")
.action(ArgAction::SetTrue),
)
.arg(Arg::new("help").long("help").action(ArgAction::Help))
.arg(
Arg::new("version")
.short('v')
.long("version")
.action(ArgAction::Version),
)
.arg(
Arg::new(ARG_FILE1)
.required(true)
.value_name("FILE1")
.value_parser(clap::value_parser!(OsString)),
)
.arg(
Arg::new(ARG_FILE2)
.value_name("FILE2")
.value_parser(clap::value_parser!(OsString)),
)
.arg(Arg::new(ARG_SKIP1).value_name("SKIP1"))
.arg(Arg::new(ARG_SKIP2).value_name("SKIP2"))
}
fn compare(matches: &ArgMatches) -> Result<i32, String> {
let name1 = matches.get_one::<OsString>(ARG_FILE1).unwrap();
let default_stdin = OsString::from("-");
let name2 = matches
.get_one::<OsString>(ARG_FILE2)
.unwrap_or(&default_stdin);
if name1 == OsStr::new("-") && name2 == OsStr::new("-") {
return Ok(0);
}
let (skip1, skip2) = skips(matches)?;
let options = Options {
print_bytes: matches.get_flag(OPT_PRINT_BYTES),
verbose: matches.get_flag(OPT_VERBOSE),
quiet: matches.get_flag(OPT_QUIET),
hex: matches.get_flag(OPT_HEX),
no_follow: matches.get_flag(OPT_NO_FOLLOW),
size_first: matches.get_flag(OPT_SIZE_FIRST),
limit: matches
.get_one::<String>(OPT_BYTES)
.map(|value| parse_count(value))
.transpose()?,
skip1,
skip2,
};
let mut input1 = open_input(name1, options.no_follow)?;
let mut input2 = open_input(name2, options.no_follow)?;
if options.size_first
&& let (Some(len1), Some(len2)) = (input1.regular_len, input2.regular_len)
&& len1 != len2
{
if !options.quiet {
writeln!(
pi_uutils_ctx::stdout(),
"{} {} differ: size",
display_name(name1),
display_name(name2)
)
.map_err(io_message)?;
}
return Ok(1);
}
input1
.reader
.get_mut()
.skip(options.skip1)
.map_err(|err| input_error(name1, &err))?;
input2
.reader
.get_mut()
.skip(options.skip2)
.map_err(|err| input_error(name2, &err))?;
compare_readers(&mut input1.reader, &mut input2.reader, name1, name2, options)
}
fn skips(matches: &ArgMatches) -> Result<(u64, u64), String> {
if let Some(value) = matches.get_one::<String>(OPT_IGNORE_INITIAL) {
return match value.split_once(':') {
Some((first, second)) if !first.is_empty() && !second.is_empty() => {
Ok((parse_count(first)?, parse_count(second)?))
},
Some(_) => Err(format!("invalid --ignore-initial value '{value}'")),
None => {
let count = parse_count(value)?;
Ok((count, count))
},
};
}
let skip1 = matches
.get_one::<String>(ARG_SKIP1)
.map(|value| parse_count(value))
.transpose()?
.unwrap_or(0);
let skip2 = matches
.get_one::<String>(ARG_SKIP2)
.map(|value| parse_count(value))
.transpose()?
.unwrap_or(0);
Ok((skip1, skip2))
}
fn parse_count(value: &str) -> Result<u64, String> {
let bytes = value.as_bytes();
let (radix, digits_start) = if value.starts_with("0x") || value.starts_with("0X") {
(16, 2)
} else if bytes.len() > 1 && bytes[0] == b'0' && bytes[1].is_ascii_digit() {
(8, 1)
} else {
(10, 0)
};
let mut digits_end = digits_start;
while bytes
.get(digits_end)
.is_some_and(|byte| char::from(*byte).is_digit(radix))
{
digits_end += 1;
}
if digits_end == digits_start {
return Err(format!("invalid byte count '{value}'"));
}
let parsed = u64::from_str_radix(&value[digits_start..digits_end], radix)
.map_err(|_| format!("invalid byte count '{value}'"))?;
let multiplier = match &value[digits_end..] {
"" => 1,
"kB" => 1_000,
"k" | "K" | "KiB" => 1 << 10,
"MB" => 1_000_000,
"M" | "MiB" => 1 << 20,
"GB" => 1_000_000_000,
"G" | "GiB" => 1 << 30,
"TB" => 1_000_000_000_000,
"T" | "TiB" => 1 << 40,
"PB" => 1_000_000_000_000_000,
"P" | "PiB" => 1 << 50,
"EB" => 1_000_000_000_000_000_000,
"E" | "EiB" => 1 << 60,
_ => return Err(format!("invalid byte count '{value}'")),
};
parsed
.checked_mul(multiplier)
.ok_or_else(|| format!("byte count '{value}' is too large"))
}
fn open_input(name: &OsStr, no_follow: bool) -> Result<Input, String> {
if name == OsStr::new("-") {
return Ok(Input {
reader: BufReader::with_capacity(
BUFFER_SIZE,
InputReader::Stdin(pi_uutils_ctx::stdin()),
),
regular_len: None,
});
}
let path = pi_uutils_ctx::resolve(name);
let metadata = if no_follow {
fs::symlink_metadata(&path)
} else {
fs::metadata(&path)
}
.map_err(|err| input_error(name, &err))?;
if no_follow && metadata.file_type().is_symlink() {
let target = fs::read_link(&path).map_err(|err| input_error(name, &err))?;
return Ok(Input {
reader: BufReader::with_capacity(
BUFFER_SIZE,
InputReader::Bytes(Cursor::new(target.as_os_str().as_encoded_bytes().to_vec())),
),
regular_len: None,
});
}
if metadata.is_dir() {
return Err(format!("{}: Is a directory", display_name(name)));
}
let regular_len = metadata.is_file().then_some(metadata.len());
let file = File::open(path).map_err(|err| input_error(name, &err))?;
Ok(Input { reader: BufReader::with_capacity(BUFFER_SIZE, InputReader::File(file)), regular_len })
}
fn compare_readers(
input1: &mut impl BufRead,
input2: &mut impl BufRead,
name1: &OsStr,
name2: &OsStr,
options: Options,
) -> Result<i32, String> {
let mut byte_number = 1u64;
let mut line_number = 1u64;
let mut compared = 0u64;
let mut different = false;
loop {
if pi_uutils_ctx::is_cancelled() {
return Err("interrupted".to_string());
}
if options.limit.is_some_and(|limit| compared >= limit) {
break;
}
let left = input1.fill_buf().map_err(io_message)?;
let right = input2.fill_buf().map_err(io_message)?;
if left.is_empty() || right.is_empty() {
if left.is_empty() && right.is_empty() {
break;
}
different = true;
if !options.quiet {
let eof_name = if left.is_empty() { name1 } else { name2 };
writeln!(
pi_uutils_ctx::stderr(),
"cmp: EOF on {} after byte {}",
display_name(eof_name),
compared
)
.map_err(io_message)?;
}
break;
}
let remaining = options.limit.map_or(u64::MAX, |limit| limit - compared);
let available = left.len().min(right.len());
let count = available.min(usize::try_from(remaining).unwrap_or(usize::MAX));
let mut consumed = 0usize;
for (&left_byte, &right_byte) in left[..count].iter().zip(&right[..count]) {
if left_byte != right_byte {
different = true;
if options.quiet {
return Ok(1);
}
report_difference(
left_byte,
right_byte,
byte_number,
line_number,
name1,
name2,
options,
)?;
if !options.verbose && !options.hex {
return Ok(1);
}
}
consumed += 1;
compared += 1;
byte_number += 1;
if left_byte == b'\n' {
line_number += 1;
}
}
input1.consume(consumed);
input2.consume(consumed);
}
Ok(i32::from(different))
}
fn report_difference(
left: u8,
right: u8,
byte_number: u64,
line_number: u64,
name1: &OsStr,
name2: &OsStr,
options: Options,
) -> Result<(), String> {
let mut out = pi_uutils_ctx::stdout();
if options.hex {
return writeln!(out, "{:08x} {left:02x} {right:02x}", byte_number - 1).map_err(io_message);
}
if options.verbose {
write!(out, "{byte_number} {left:3o}").map_err(io_message)?;
if options.print_bytes {
write!(out, " ").map_err(io_message)?;
write_visible_byte_padded(&mut out, left).map_err(io_message)?;
}
write!(out, " {right:3o}").map_err(io_message)?;
if options.print_bytes {
write!(out, " ").map_err(io_message)?;
write_visible_byte(&mut out, right).map_err(io_message)?;
}
return writeln!(out).map_err(io_message);
}
let position_name = if options.print_bytes { "byte" } else { "char" };
write!(
out,
"{} {} differ: {position_name} {byte_number}, line {line_number}",
display_name(name1),
display_name(name2)
)
.map_err(io_message)?;
if options.print_bytes {
write!(out, " is {left:3o} ").map_err(io_message)?;
write_visible_byte(&mut out, left).map_err(io_message)?;
write!(out, " {right:3o} ").map_err(io_message)?;
write_visible_byte(&mut out, right).map_err(io_message)?;
}
writeln!(out).map_err(io_message)
}
fn write_visible_byte(out: &mut impl Write, byte: u8) -> io::Result<()> {
if byte >= 128 {
out.write_all(b"M-")?;
return write_visible_byte(out, byte - 128);
}
match byte {
0..=31 => write!(out, "^{}", char::from(byte + 64)),
32..=126 => write!(out, "{}", char::from(byte)),
127 => out.write_all(b"^?"),
_ => unreachable!(),
}
}
fn write_visible_byte_padded(out: &mut impl Write, byte: u8) -> io::Result<()> {
write_visible_byte(out, byte)?;
const SPACES: &[u8] = b" ";
let padding = SPACES.len().saturating_sub(visible_byte_width(byte));
out.write_all(&SPACES[..padding])
}
fn visible_byte_width(byte: u8) -> usize {
if byte >= 128 {
2 + visible_byte_width(byte - 128)
} else if matches!(byte, 32..=126) {
1
} else {
2
}
}
fn display_name(name: &OsStr) -> String {
name.to_string_lossy().into_owned()
}
fn input_error(name: &OsStr, err: &io::Error) -> String {
format!("{}: {}", display_name(name), err)
}
fn io_message(err: io::Error) -> String {
err.to_string()
}
#[cfg(test)]
mod tests {
use std::{
collections::HashMap,
ffi::OsString,
fs,
io::{Cursor, Write},
path::PathBuf,
sync::{Arc, atomic::AtomicBool},
};
use parking_lot::Mutex;
use pi_uutils_ctx::ScopeIo;
use super::run;
#[derive(Clone)]
struct SharedWriter(Arc<Mutex<Vec<u8>>>);
impl Write for SharedWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.0.lock().flush()
}
}
fn run_in(cwd: PathBuf, stdin: &[u8], args: &[&str]) -> (i32, String, String) {
let stdout = Arc::new(Mutex::new(Vec::new()));
let stderr = Arc::new(Mutex::new(Vec::new()));
let io = ScopeIo {
stdin: Box::new(Cursor::new(stdin.to_vec())),
stdin_fd: None,
stdin_is_search_input: false,
stdout: Box::new(SharedWriter(Arc::clone(&stdout))),
stderr: Box::new(SharedWriter(Arc::clone(&stderr))),
cwd,
env: HashMap::new(),
cancel: Arc::new(AtomicBool::new(false)),
};
let argv = std::iter::once("cmp")
.chain(args.iter().copied())
.map(OsString::from)
.collect();
let code = pi_uutils_ctx::scope(io, || run(argv));
let stdout = String::from_utf8(stdout.lock().clone()).unwrap();
let stderr = String::from_utf8(stderr.lock().clone()).unwrap();
(code, stdout, stderr)
}
fn tempdir() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = fs::canonicalize(dir.path()).unwrap();
(dir, path)
}
#[test]
fn silent_mode_reports_only_status() {
let (_dir, root) = tempdir();
fs::write(root.join("a"), b"same").unwrap();
fs::write(root.join("b"), b"same").unwrap();
fs::write(root.join("c"), b"different").unwrap();
assert_eq!(run_in(root.clone(), b"", &["-s", "a", "b"]), (0, String::new(), String::new()));
assert_eq!(run_in(root, b"", &["-s", "a", "c"]), (1, String::new(), String::new()));
}
#[test]
fn default_output_reports_first_byte_and_line() {
let (_dir, root) = tempdir();
fs::write(root.join("a"), b"one\ntwo\n").unwrap();
fs::write(root.join("b"), b"one\ntXo\n").unwrap();
let (code, stdout, stderr) = run_in(root, b"", &["a", "b"]);
assert_eq!(code, 1);
assert_eq!(stdout, "a b differ: char 6, line 2\n");
assert_eq!(stderr, "");
}
#[test]
fn stdin_skips_and_limit_follow_cmp_contract() {
let (_dir, root) = tempdir();
fs::write(root.join("file"), b"abc").unwrap();
assert_eq!(
run_in(root.clone(), b"xxabc", &["-i", "0:2", "file"]),
(0, String::new(), String::new())
);
assert_eq!(run_in(root, b"abZ", &["-n", "0x2", "file"]), (0, String::new(), String::new()));
}
#[test]
fn verbose_and_bsd_hex_modes_report_every_difference() {
let (_dir, root) = tempdir();
fs::write(root.join("a"), b"abc").unwrap();
fs::write(root.join("b"), b"axd").unwrap();
let (code, stdout, stderr) = run_in(root.clone(), b"", &["-l", "a", "b"]);
assert_eq!((code, stdout.as_str(), stderr.as_str()), (1, "2 142 170\n3 143 144\n", ""));
let (code, stdout, stderr) = run_in(root, b"", &["-x", "a", "b"]);
assert_eq!(
(code, stdout.as_str(), stderr.as_str()),
(1, "00000001 62 78\n00000002 63 64\n", "")
);
}
#[test]
fn no_follow_compares_symlink_targets() {
let (_dir, root) = tempdir();
fs::write(root.join("a"), b"same").unwrap();
fs::write(root.join("b"), b"same").unwrap();
#[cfg(unix)]
{
std::os::unix::fs::symlink("a", root.join("left")).unwrap();
std::os::unix::fs::symlink("b", root.join("right")).unwrap();
let (code, stdout, stderr) = run_in(root, b"", &["-h", "left", "right"]);
assert_eq!(code, 1);
assert_eq!(stdout, "left right differ: char 1, line 1\n");
assert_eq!(stderr, "");
}
}
}