Files
oh-my-pi/crates/pi-uu-diff/src/lib.rs
T
can1357 0ae8efd649 feat: integrated coreutils as in-process shell builtins
- Integrated 17 new coreutils-based shell builtins including `diff`, `date`, `ln`, `stat`, `seq`, `touch`, and others.
- Refactored vendored utilities to execute as in-process shell builtins by routing I/O, environment access, and path resolution through `pi_uutils_ctx`.
- Disabled process-level modifications (e.g., clock setting, hostname modification) to ensure safety and scope adherence within the shell environment.
- Implemented shell-specific features such as cancellation polling, custom exit code management, and efficient output streaming for all new builtins.
2026-07-11 20:37:52 +02:00

609 lines
20 KiB
Rust

//! `diff` implemented as an in-process shell builtin on top of the `similar`
//! diffing library. All I/O and path resolution is routed through
//! `pi-uutils-ctx` so the builtin writes to the command's redirected file
//! descriptors and resolves relative paths against the shell's working
//! directory, while operands are printed as typed.
//!
//! Scope: unified output only (`-u` is accepted and implied, `-U N` controls
//! the context size), `-q/--brief`, `-N/--new-file` (absent files compare as
//! empty), binary detection, `-` for the context stdin, and unconditional
//! recursive directory comparison (`Only in <dir>: <name>` lines plus
//! `diff -r A/x B/x`-headed per-pair diffs).
//!
//! Entry point: [`run`]. It never calls `std::process::exit`; clap
//! help/usage/error output is rendered to the context streams and an exit code
//! is returned following the GNU convention (0 = identical, 1 = differences
//! found, 2 = trouble).
use std::{
collections::BTreeSet,
ffi::{OsStr, OsString},
fs,
io::{Read, Write},
path::{Path, PathBuf},
};
use clap::{Arg, ArgAction, ArgMatches, Command};
use pi_uutils_ctx::format_usage;
use similar::TextDiff;
const OPT_UNIFIED_FLAG: &str = "unified-flag";
const OPT_UNIFIED: &str = "unified";
const OPT_BRIEF: &str = "brief";
const OPT_RECURSIVE: &str = "recursive";
const OPT_NEW_FILE: &str = "new-file";
const OPT_COLOR: &str = "color";
const ARG_FILES: &str = "files";
/// In-process builtin entry point. Parses the arguments directly, renders clap
/// help/usage/version to the context streams, and maps errors to the GNU diff
/// exit-code convention, so it is safe to run inside the host shell process.
pub fn run(argv: Vec<OsString>) -> i32 {
let matches = match uu_app().try_get_matches_from(argv) {
Ok(matches) => matches,
Err(err) => {
let rendered = err.to_string();
if err.use_stderr() {
let _ = write!(pi_uutils_ctx::stderr(), "{rendered}");
return 2;
}
let _ = write!(pi_uutils_ctx::stdout(), "{rendered}");
return 0;
},
};
match diff_main(&matches) {
Ok(code) => code,
Err(msg) => {
let _ = writeln!(pi_uutils_ctx::stderr(), "diff: {msg}");
2
},
}
}
pub fn uu_app() -> Command {
Command::new("diff")
.version(concat!("diff (pi-uu-diff) ", env!("CARGO_PKG_VERSION")))
.about("Compare files line by line.")
.override_usage(format_usage("diff [OPTION]... FILE1 FILE2"))
.infer_long_args(true)
.arg(
Arg::new(OPT_UNIFIED_FLAG)
.short('u')
.help("output 3 lines of unified context (the default output format)")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new(OPT_UNIFIED)
.short('U')
.long(OPT_UNIFIED)
.value_name("NUM")
.help("output NUM lines of unified context")
.value_parser(clap::value_parser!(usize)),
)
.arg(
Arg::new(OPT_BRIEF)
.short('q')
.long(OPT_BRIEF)
.help("report only when files differ")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new(OPT_RECURSIVE)
.short('r')
.long(OPT_RECURSIVE)
.help("recursively compare subdirectories (always on for directories)")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new(OPT_NEW_FILE)
.short('N')
.long(OPT_NEW_FILE)
.help("treat absent files as empty")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new(OPT_COLOR)
.long(OPT_COLOR)
.value_name("WHEN")
.num_args(0..=1)
.require_equals(true)
.default_missing_value("auto")
.help("accepted for compatibility; output is never colorized"),
)
.arg(
Arg::new(ARG_FILES)
.required(true)
.num_args(2)
.value_parser(clap::value_parser!(OsString))
.value_hint(clap::ValueHint::AnyPath),
)
}
#[derive(Clone, Copy)]
struct Options {
context: usize,
brief: bool,
new_file: bool,
}
/// A classified operand: what the name as typed refers to on disk after
/// resolution against the scope working directory.
enum Operand {
/// The context stdin (`-`).
Stdin,
/// A regular (or other non-directory) file at the resolved path.
File(PathBuf),
/// A directory at the resolved path.
Dir(PathBuf),
/// A missing file tolerated by `-N` and compared as empty.
Absent,
}
fn diff_main(matches: &ArgMatches) -> Result<i32, String> {
let files: Vec<&OsString> = matches.get_many::<OsString>(ARG_FILES).unwrap().collect();
let opts = Options {
context: matches.get_one::<usize>(OPT_UNIFIED).copied().unwrap_or(3),
brief: matches.get_flag(OPT_BRIEF),
new_file: matches.get_flag(OPT_NEW_FILE),
};
let (mut name_a, mut name_b) = (PathBuf::from(files[0]), PathBuf::from(files[1]));
let mut op_a = classify(&name_a, opts.new_file)?;
let mut op_b = classify(&name_b, opts.new_file)?;
// GNU: comparing a directory with a non-directory compares
// <dir>/<basename-of-other> with the other operand.
let a_is_dir = matches!(op_a, Operand::Dir(_));
let b_is_dir = matches!(op_b, Operand::Dir(_));
if a_is_dir != b_is_dir {
if matches!(op_a, Operand::Stdin) || matches!(op_b, Operand::Stdin) {
return Err("cannot compare '-' to a directory".to_string());
}
if a_is_dir {
name_a = descend(&name_a, &name_b)?;
op_a = classify(&name_a, opts.new_file)?;
} else {
name_b = descend(&name_b, &name_a)?;
op_b = classify(&name_b, opts.new_file)?;
}
}
let differed = if let (Operand::Dir(res_a), Operand::Dir(res_b)) = (&op_a, &op_b) {
diff_dirs(&name_a, res_a, &name_b, res_b, opts)?
} else {
let bytes_a = read_operand(&op_a, &name_a)?;
let bytes_b = read_operand(&op_b, &name_b)?;
diff_pair(&name_a, &bytes_a, &name_b, &bytes_b, opts, None)?
};
Ok(i32::from(differed))
}
/// Replaces a directory operand with `<dir>/<basename of other>` for the GNU
/// dir-vs-file comparison form.
fn descend(dir: &Path, other: &Path) -> Result<PathBuf, String> {
let base = other
.file_name()
.ok_or_else(|| format!("cannot compare {} to a directory", other.display()))?;
Ok(dir.join(base))
}
fn classify(name: &Path, new_file: bool) -> Result<Operand, String> {
if name.as_os_str() == OsStr::new("-") {
return Ok(Operand::Stdin);
}
// Resolve the operand against the shell working directory; `name` is kept
// for display (GNU prints operands as typed).
let resolved = pi_uutils_ctx::resolve(name);
match fs::metadata(&resolved) {
Ok(meta) if meta.is_dir() => Ok(Operand::Dir(resolved)),
Ok(_) => Ok(Operand::File(resolved)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound && new_file => Ok(Operand::Absent),
Err(err) => Err(format!("{}: {}", name.display(), io_msg(&err))),
}
}
fn read_operand(op: &Operand, name: &Path) -> Result<Vec<u8>, String> {
match op {
Operand::Stdin => {
let mut buf = Vec::new();
pi_uutils_ctx::stdin()
.read_to_end(&mut buf)
.map_err(|err| format!("-: {}", io_msg(&err)))?;
Ok(buf)
},
Operand::File(resolved) => {
fs::read(resolved).map_err(|err| format!("{}: {}", name.display(), io_msg(&err)))
},
Operand::Dir(_) => unreachable!("directories are handled by diff_dirs"),
Operand::Absent => Ok(Vec::new()),
}
}
/// Diffs one pair of already-read inputs, writing to the context stdout.
/// `prefix` is the `diff -r A/x B/x` line emitted before per-pair output in
/// directory mode. Returns whether the inputs differed.
fn diff_pair(
name_a: &Path,
bytes_a: &[u8],
name_b: &Path,
bytes_b: &[u8],
opts: Options,
prefix: Option<&str>,
) -> Result<bool, String> {
if bytes_a == bytes_b {
return Ok(false);
}
let mut out = pi_uutils_ctx::stdout();
let (label_a, label_b) = (name_a.display().to_string(), name_b.display().to_string());
if opts.brief {
writeln!(out, "Files {label_a} and {label_b} differ").map_err(|e| io_msg(&e))?;
return Ok(true);
}
if is_binary(bytes_a) || is_binary(bytes_b) {
writeln!(out, "Binary files {label_a} and {label_b} differ").map_err(|e| io_msg(&e))?;
return Ok(true);
}
if let Some(line) = prefix {
writeln!(out, "{line}").map_err(|e| io_msg(&e))?;
}
let old = String::from_utf8_lossy(bytes_a);
let new = String::from_utf8_lossy(bytes_b);
let diff = TextDiff::from_lines(old.as_ref(), new.as_ref());
write!(
out,
"{}",
diff
.unified_diff()
.context_radius(opts.context)
.header(&label_a, &label_b)
)
.map_err(|e| io_msg(&e))?;
Ok(true)
}
/// Recursively compares two directories over the sorted union of their
/// entries, GNU `diff -r` style. Returns whether any difference was found.
fn diff_dirs(
name_a: &Path,
res_a: &Path,
name_b: &Path,
res_b: &Path,
opts: Options,
) -> Result<bool, String> {
let mut names: BTreeSet<OsString> = BTreeSet::new();
for (dir_name, dir_res) in [(name_a, res_a), (name_b, res_b)] {
let entries = fs::read_dir(dir_res)
.map_err(|err| format!("{}: {}", dir_name.display(), io_msg(&err)))?;
for entry in entries {
let entry = entry.map_err(|err| format!("{}: {}", dir_name.display(), io_msg(&err)))?;
names.insert(entry.file_name());
}
}
let mut differed = false;
for name in names {
if pi_uutils_ctx::is_cancelled() {
return Err("interrupted".to_string());
}
let (child_name_a, child_res_a) = (name_a.join(&name), res_a.join(&name));
let (child_name_b, child_res_b) = (name_b.join(&name), res_b.join(&name));
let meta_a = fs::metadata(&child_res_a).ok();
let meta_b = fs::metadata(&child_res_b).ok();
match (meta_a.as_ref(), meta_b.as_ref()) {
(Some(ma), Some(mb)) if ma.is_dir() && mb.is_dir() => {
differed |= diff_dirs(&child_name_a, &child_res_a, &child_name_b, &child_res_b, opts)?;
},
(Some(ma), Some(mb)) if ma.is_dir() != mb.is_dir() => {
let (dir, file) = if ma.is_dir() {
(&child_name_a, &child_name_b)
} else {
(&child_name_b, &child_name_a)
};
writeln!(
pi_uutils_ctx::stdout(),
"File {} is a directory while file {} is a regular file",
dir.display(),
file.display()
)
.map_err(|e| io_msg(&e))?;
differed = true;
},
(Some(_), Some(_)) => {
let bytes_a = fs::read(&child_res_a)
.map_err(|err| format!("{}: {}", child_name_a.display(), io_msg(&err)))?;
let bytes_b = fs::read(&child_res_b)
.map_err(|err| format!("{}: {}", child_name_b.display(), io_msg(&err)))?;
let prefix = format!("diff -r {} {}", child_name_a.display(), child_name_b.display());
differed |=
diff_pair(&child_name_a, &bytes_a, &child_name_b, &bytes_b, opts, Some(&prefix))?;
},
(Some(meta), None) | (None, Some(meta)) => {
let in_a = meta_b.is_none();
if opts.new_file && meta.is_file() {
// -N: compare the present file against an empty absent one.
let (present_name, present_res) = if in_a {
(&child_name_a, &child_res_a)
} else {
(&child_name_b, &child_res_b)
};
let bytes = fs::read(present_res)
.map_err(|err| format!("{}: {}", present_name.display(), io_msg(&err)))?;
let prefix =
format!("diff -r {} {}", child_name_a.display(), child_name_b.display());
let (ba, bb): (&[u8], &[u8]) = if in_a { (&bytes, &[]) } else { (&[], &bytes) };
differed |= diff_pair(&child_name_a, ba, &child_name_b, bb, opts, Some(&prefix))?;
} else {
let present_dir = if in_a { name_a } else { name_b };
writeln!(
pi_uutils_ctx::stdout(),
"Only in {}: {}",
present_dir.display(),
Path::new(&name).display()
)
.map_err(|e| io_msg(&e))?;
differed = true;
}
},
(None, None) => {},
}
}
Ok(differed)
}
/// NUL byte within the first 8 KiB marks the input as binary, matching the
/// heuristic GNU diff applies to decide between text and binary output.
fn is_binary(bytes: &[u8]) -> bool {
bytes.iter().take(8192).any(|&b| b == 0)
}
/// Renders an I/O error without the Rust-specific ` (os error N)` suffix so
/// messages read like GNU diff's (`diff: x: No such file or directory`).
fn io_msg(err: &std::io::Error) -> String {
let msg = err.to_string();
match msg.find(" (os error") {
Some(idx) => msg[..idx].to_string(),
None => msg,
}
}
#[cfg(test)]
mod tests {
use std::{collections::HashMap, io::Write, path::PathBuf, sync::Arc};
use parking_lot::Mutex;
use pi_uutils_ctx::ScopeIo;
use super::*;
fn run_with(cwd: PathBuf, stdin: &[u8], args: Vec<&str>) -> (i32, String, String) {
let stdout_buf = Arc::new(Mutex::new(Vec::new()));
let stderr_buf = Arc::new(Mutex::new(Vec::new()));
#[derive(Clone)]
struct SharedWriter {
buf: Arc<Mutex<Vec<u8>>>,
}
impl Write for SharedWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.buf.lock().write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.buf.lock().flush()
}
}
let io = ScopeIo {
stdin: Box::new(std::io::Cursor::new(stdin.to_vec())),
stdin_fd: None,
stdin_is_search_input: false,
stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }),
stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }),
cwd,
env: HashMap::new(),
cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
let argv: Vec<OsString> = std::iter::once("diff")
.chain(args)
.map(OsString::from)
.collect();
let code = pi_uutils_ctx::scope(io, || run(argv));
let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap();
let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap();
(code, out_str, err_str)
}
fn run_in(cwd: PathBuf, args: Vec<&str>) -> (i32, String, String) {
run_with(cwd, b"", args)
}
/// Canonicalized temp dir (macOS tempdirs live behind /var -> /private/var).
fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let canon = fs::canonicalize(dir.path()).unwrap();
(dir, canon)
}
#[test]
fn identical_files_print_nothing_and_exit_zero() {
let (_dir, root) = canonical_tempdir();
fs::write(root.join("a.txt"), "one\ntwo\n").unwrap();
fs::write(root.join("b.txt"), "one\ntwo\n").unwrap();
let (code, stdout, stderr) = run_in(root, vec!["a.txt", "b.txt"]);
assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "", ""));
}
/// Relative operands must resolve against the scope cwd (a tempdir), not
/// the process cwd — the pi-specific contract.
#[test]
fn differing_files_emit_unified_diff_with_typed_headers() {
let (_dir, root) = canonical_tempdir();
fs::write(root.join("a.txt"), "one\ntwo\nthree\n").unwrap();
fs::write(root.join("b.txt"), "one\nTWO\nthree\n").unwrap();
let (code, stdout, stderr) = run_in(root, vec!["a.txt", "b.txt"]);
assert_eq!(code, 1);
assert_eq!(stderr, "");
assert!(stdout.starts_with("--- a.txt\n+++ b.txt\n@@ "), "got: {stdout}");
assert!(stdout.contains("\n-two\n"), "got: {stdout}");
assert!(stdout.contains("\n+TWO\n"), "got: {stdout}");
// Context lines around the change (default -U 3).
assert!(stdout.contains("\n one\n"), "got: {stdout}");
assert!(stdout.contains("\n three\n"), "got: {stdout}");
}
#[test]
fn unified_zero_drops_context_lines() {
let (_dir, root) = canonical_tempdir();
fs::write(root.join("a.txt"), "one\ntwo\nthree\n").unwrap();
fs::write(root.join("b.txt"), "one\nTWO\nthree\n").unwrap();
let (code, stdout, _) = run_in(root, vec!["-U", "0", "a.txt", "b.txt"]);
assert_eq!(code, 1);
assert!(!stdout.contains("\n one\n"), "got: {stdout}");
assert!(!stdout.contains("\n three\n"), "got: {stdout}");
assert!(stdout.contains("\n-two\n"), "got: {stdout}");
assert!(stdout.contains("\n+TWO\n"), "got: {stdout}");
}
#[test]
fn brief_reports_one_line_per_differing_pair() {
let (_dir, root) = canonical_tempdir();
fs::write(root.join("a.txt"), "x\n").unwrap();
fs::write(root.join("b.txt"), "y\n").unwrap();
let (code, stdout, stderr) = run_in(root, vec!["-q", "a.txt", "b.txt"]);
assert_eq!(code, 1);
assert_eq!(stdout, "Files a.txt and b.txt differ\n");
assert_eq!(stderr, "");
}
#[test]
fn compat_flags_are_accepted_and_ignored() {
let (_dir, root) = canonical_tempdir();
fs::write(root.join("a.txt"), "x\n").unwrap();
fs::write(root.join("b.txt"), "y\n").unwrap();
let (code, stdout, stderr) =
run_in(root, vec!["-u", "-r", "--color=always", "a.txt", "b.txt"]);
assert_eq!(code, 1);
assert_eq!(stderr, "");
// Plain unified output, no ANSI escapes.
assert!(stdout.starts_with("--- a.txt\n+++ b.txt\n"), "got: {stdout}");
assert!(!stdout.contains('\u{1b}'), "got: {stdout}");
}
#[test]
fn binary_inputs_report_binary_difference() {
let (_dir, root) = canonical_tempdir();
fs::write(root.join("a.bin"), b"aa\x00bb").unwrap();
fs::write(root.join("b.bin"), b"aa\x00cc").unwrap();
let (code, stdout, stderr) = run_in(root, vec!["a.bin", "b.bin"]);
assert_eq!(code, 1);
assert_eq!(stdout, "Binary files a.bin and b.bin differ\n");
assert_eq!(stderr, "");
}
#[test]
fn missing_operand_file_is_trouble() {
let (_dir, root) = canonical_tempdir();
fs::write(root.join("a.txt"), "x\n").unwrap();
let (code, stdout, stderr) = run_in(root, vec!["a.txt", "nope.txt"]);
assert_eq!(code, 2);
assert_eq!(stdout, "");
assert_eq!(stderr, "diff: nope.txt: No such file or directory\n");
}
#[test]
fn missing_second_operand_is_usage_error() {
let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["only-one"]);
assert_eq!(code, 2);
assert_eq!(stdout, "");
assert!(stderr.contains("required"), "got: {stderr}");
}
#[test]
fn new_file_treats_missing_operand_as_empty() {
let (_dir, root) = canonical_tempdir();
fs::write(root.join("a.txt"), "one\n").unwrap();
let (code, stdout, stderr) = run_in(root, vec!["-N", "nope.txt", "a.txt"]);
assert_eq!(code, 1);
assert_eq!(stderr, "");
assert!(stdout.starts_with("--- nope.txt\n+++ a.txt\n"), "got: {stdout}");
assert!(stdout.contains("\n+one\n"), "got: {stdout}");
}
#[test]
fn dash_reads_context_stdin() {
let (_dir, root) = canonical_tempdir();
fs::write(root.join("a.txt"), "one\ntwo\n").unwrap();
let (code, stdout, stderr) = run_with(root.clone(), b"one\ntwo\n", vec!["a.txt", "-"]);
assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "", ""));
let (code, stdout, _) = run_with(root, b"one\nTWO\n", vec!["a.txt", "-"]);
assert_eq!(code, 1);
assert!(stdout.starts_with("--- a.txt\n+++ -\n"), "got: {stdout}");
}
#[test]
fn directories_diff_recursively_with_only_in_lines() {
let (_dir, root) = canonical_tempdir();
let (a, b) = (root.join("a"), root.join("b"));
fs::create_dir_all(a.join("sub")).unwrap();
fs::create_dir_all(b.join("sub")).unwrap();
fs::write(a.join("common.txt"), "same\n").unwrap();
fs::write(b.join("common.txt"), "same\n").unwrap();
fs::write(a.join("only.txt"), "left\n").unwrap();
fs::write(b.join("other.txt"), "right\n").unwrap();
fs::write(a.join("sub/inner.txt"), "old\n").unwrap();
fs::write(b.join("sub/inner.txt"), "new\n").unwrap();
let (code, stdout, stderr) = run_in(root, vec!["a", "b"]);
assert_eq!(code, 1);
assert_eq!(stderr, "");
assert!(stdout.contains("Only in a: only.txt\n"), "got: {stdout}");
assert!(stdout.contains("Only in b: other.txt\n"), "got: {stdout}");
assert!(
stdout.contains(
"diff -r a/sub/inner.txt b/sub/inner.txt\n--- a/sub/inner.txt\n+++ b/sub/inner.txt\n"
),
"got: {stdout}"
);
assert!(stdout.contains("\n-old\n"), "got: {stdout}");
assert!(stdout.contains("\n+new\n"), "got: {stdout}");
// Identical common.txt must not appear at all.
assert!(!stdout.contains("common.txt"), "got: {stdout}");
}
#[test]
fn identical_directories_exit_zero() {
let (_dir, root) = canonical_tempdir();
let (a, b) = (root.join("a"), root.join("b"));
fs::create_dir_all(&a).unwrap();
fs::create_dir_all(&b).unwrap();
fs::write(a.join("f.txt"), "same\n").unwrap();
fs::write(b.join("f.txt"), "same\n").unwrap();
let (code, stdout, stderr) = run_in(root, vec!["-r", "a", "b"]);
assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "", ""));
}
#[test]
fn help_renders_to_scope_stdout() {
let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["--help"]);
assert_eq!(code, 0);
assert!(stdout.contains("Usage:"));
assert!(stdout.contains("Compare files line by line"));
assert_eq!(stderr, "");
}
}