diff --git a/src/diff.rs b/src/diff.rs index d74b022..81298f2 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -6,31 +6,69 @@ use crate::color::{colorize, ColorWhen, Palette, PaletteError}; use crate::params::{parse_params, Format, MissingFile, Params}; use crate::utils::report_failure_to_read_input_file; -use crate::{context_diff, ed_diff, normal_diff, side_diff, unified_diff}; +use crate::{context_diff, dir_diff, ed_diff, normal_diff, side_diff, unified_diff}; use std::env::ArgsOs; use std::ffi::OsString; use std::fs; use std::io::{self, stdout, IsTerminal, Read, Write}; use std::iter::Peekable; -use std::process::{exit, ExitCode}; +use std::path::Path; +use std::process::ExitCode; + +/// The result of a comparison, from best to worst, so that the outcome of +/// several comparisons is their maximum. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum Outcome { + Identical, + Different, + Trouble, +} // Exit codes are documented at // https://www.gnu.org/software/diffutils/manual/html_node/Invoking-diff.html. // An exit status of 0 means no differences were found, // 1 means some differences were found, // and 2 means trouble. +impl From for ExitCode { + fn from(outcome: Outcome) -> Self { + match outcome { + Outcome::Identical => Self::SUCCESS, + Outcome::Different => Self::from(1), + Outcome::Trouble => Self::from(2), + } + } +} + pub fn main(opts: Peekable) -> ExitCode { - let mut params = parse_params(opts).unwrap_or_else(|error| { - eprintln!("{error}"); - exit(2); - }); + let (mut params, option_args) = match parse_params(opts) { + Ok(parsed) => parsed, + Err(error) => { + eprintln!("{error}"); + return ExitCode::from(2); + } + }; // diff - FILE with a directory on stdin => diff DIRECTORY/FILE FILE if params.from == "-" && params.to != "-" { if let Some(from) = stdin_directory_entry(¶ms.to) { params.from = from; } } - // if from and to are the same file, no need to perform any comparison + let both_directories = params.from != "-" + && params.to != "-" + && Path::new(¶ms.from).is_dir() + && Path::new(¶ms.to).is_dir(); + let outcome = if both_directories { + dir_diff::compare_directories(¶ms, &option_args) + } else { + compare_files(¶ms, None) + }; + outcome.into() +} + +/// Compares the files `params.from` and `params.to`, printing their +/// differences. When the files differ, `header` is printed first unless only +/// a brief report is requested. +pub fn compare_files(params: &Params, header: Option<&[u8]>) -> Outcome { let maybe_report_identical_files = || { if params.report_identical_files { println!( @@ -40,11 +78,12 @@ pub fn main(opts: Peekable) -> ExitCode { ); } }; + // if from and to are the same file, no need to perform any comparison if params.from == "-" && params.to == "-" || same_file::is_same_file(¶ms.from, ¶ms.to).unwrap_or(false) { maybe_report_identical_files(); - return ExitCode::SUCCESS; + return Outcome::Identical; } // read files @@ -84,13 +123,13 @@ pub fn main(opts: Peekable) -> ExitCode { } }; if io_error { - return ExitCode::from(2); + return Outcome::Trouble; } if !params.text && (is_binary(&from_content) || is_binary(&to_content)) { if from_content == to_content { maybe_report_identical_files(); - return ExitCode::SUCCESS; + return Outcome::Identical; } print_files_differ( if params.brief { @@ -98,33 +137,56 @@ pub fn main(opts: Peekable) -> ExitCode { } else { "Binary files" }, - ¶ms, + params, ); - return ExitCode::from(1); + return Outcome::Different; } // run diff let result: Vec = match params.format { - Format::Normal => normal_diff::diff(&from_content, &to_content, ¶ms), - Format::Unified => unified_diff::diff(&from_content, &to_content, ¶ms), - Format::Context => context_diff::diff(&from_content, &to_content, ¶ms), - Format::Ed => ed_diff::diff(&from_content, &to_content, ¶ms).unwrap_or_else(|error| { - eprintln!("{error}"); - exit(2); - }), - Format::SideBySide => { - let mut output = stdout().lock(); - side_diff::diff(&from_content, &to_content, &mut output, ¶ms) - } + Format::Normal => normal_diff::diff(&from_content, &to_content, params), + Format::Unified => unified_diff::diff(&from_content, &to_content, params), + Format::Context => context_diff::diff(&from_content, &to_content, params), + Format::Ed => match ed_diff::diff(&from_content, &to_content, params) { + Ok(result) => result, + Err(error) => { + eprintln!("{error}"); + return Outcome::Trouble; + } + }, + // Side by side output is streamed, unless a header may have to come + // first. + Format::SideBySide => match header { + None => side_diff::diff(&from_content, &to_content, &mut stdout().lock(), params), + Some(header) => { + let mut output = Vec::new(); + let result = side_diff::diff(&from_content, &to_content, &mut output, params); + if !result.is_empty() { + output.splice(0..0, header.iter().copied()); + } + if stdout().write_all(&output).is_err() { + return Outcome::Trouble; + } + result + } + }, }; if params.brief && !result.is_empty() { - print_files_differ("Files", ¶ms); - } else { - let written = match output_palette(¶ms) { - Some(palette) => stdout().write_all(&colorize(&result, params.format, &palette)), - None => stdout().write_all(&result), + print_files_differ("Files", params); + } else if params.format != Format::SideBySide { + let header = if result.is_empty() { + &[][..] + } else { + header.unwrap_or_default() }; - if let Err(error) = written.and_then(|()| stdout().flush()) { + let colored = + output_palette(params).map(|palette| colorize(&result, params.format, &palette)); + let mut stdout = stdout().lock(); + let written = stdout + .write_all(header) + .and_then(|()| stdout.write_all(colored.as_deref().unwrap_or(&result))) + .and_then(|()| stdout.flush()); + if let Err(error) = written { // A reader that went away, as with `| head`, is not worth a message. if error.kind() != io::ErrorKind::BrokenPipe { report_failure_to_read_input_file( @@ -133,14 +195,14 @@ pub fn main(opts: Peekable) -> ExitCode { &error, ); } - return ExitCode::from(2); + return Outcome::Trouble; } } if result.is_empty() { maybe_report_identical_files(); - ExitCode::SUCCESS + Outcome::Identical } else { - ExitCode::from(1) + Outcome::Different } } @@ -200,7 +262,6 @@ fn print_files_differ(kind: &str, params: &Params) { #[cfg(any(target_os = "linux", target_os = "android"))] fn stdin_directory_entry(other: &OsString) -> Option { use std::os::fd::AsFd; - use std::path::Path; let stdin_is_dir = io::stdin() .as_fd() diff --git a/src/dir_diff.rs b/src/dir_diff.rs new file mode 100644 index 0000000..d389bf4 --- /dev/null +++ b/src/dir_diff.rs @@ -0,0 +1,334 @@ +// This file is part of the uutils diffutils package. +// +// For the full copyright and license information, please view the LICENSE-* +// files that was distributed with this source code. + +//! Comparison of two directories, entry by entry, descending into +//! subdirectories with `-r`. + +use std::cmp::Ordering; +use std::ffi::{OsStr, OsString}; +use std::fs::{self, Metadata}; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::diff::{compare_files, Outcome}; +use crate::params::{MissingFile, Params}; +use crate::utils::report_failure_to_read_input_file; + +/// Compares the directories `params.from` and `params.to`. `option_args` are +/// the command line options, repeated in the header printed before the +/// differences of each pair of files. +pub fn compare_directories(params: &Params, option_args: &[OsString]) -> Outcome { + // As for subdirectories, a directory is not compared with itself. + if same_file::is_same_file(¶ms.from, ¶ms.to).unwrap_or(false) { + return Outcome::Identical; + } + let mut walker = DirectoryWalker { + params, + option_args, + ancestors: Vec::new(), + }; + walker.compare_directories(¶ms.from, ¶ms.to) +} + +struct DirectoryWalker<'a> { + params: &'a Params, + option_args: &'a [OsString], + /// Canonical paths of the directories being compared, from the top, to + /// detect symbolic links looping back to one of them. + ancestors: Vec, +} + +impl DirectoryWalker<'_> { + fn compare_directories(&mut self, from_dir: &OsStr, to_dir: &OsStr) -> Outcome { + let mut outcome = Outcome::Identical; + let mut canonical_dirs = Vec::new(); + for dir in [from_dir, to_dir] { + let Ok(canonical) = fs::canonicalize(dir) else { + // A directory missing on purpose (-N) has no entries. + continue; + }; + if self.ancestors.contains(&canonical) { + eprintln!( + "{}: {}: recursive directory loop", + self.params.executable.to_string_lossy(), + dir.to_string_lossy() + ); + return Outcome::Trouble; + } + canonical_dirs.push(canonical); + } + let (Some(from_names), Some(to_names)) = (self.list(from_dir), self.list(to_dir)) else { + return Outcome::Trouble; + }; + let ancestors_len = self.ancestors.len(); + self.ancestors.extend(canonical_dirs); + + let entries = pair_entries(&from_names, &to_names, |from, to| { + from.as_encoded_bytes().cmp(to.as_encoded_bytes()) + }); + for entry in entries { + let entry_outcome = match entry { + Entry::Both(from, to) => { + self.compare_entries(&join(from_dir, from), &join(to_dir, to)) + } + Entry::FromOnly(name) if self.params.missing_file == MissingFile::Empty => { + self.compare_entries(&join(from_dir, name), &join(to_dir, name)) + } + Entry::FromOnly(name) => report_only_in(from_dir, name), + Entry::ToOnly(name) if self.params.missing_file == MissingFile::Error => { + report_only_in(to_dir, name) + } + Entry::ToOnly(name) => { + self.compare_entries(&join(from_dir, name), &join(to_dir, name)) + } + }; + outcome = outcome.max(entry_outcome); + } + self.ancestors.truncate(ancestors_len); + outcome + } + + /// Returns the sorted names of the entries of `dir`, or `None` after + /// reporting an error. A directory missing on purpose (-N) is empty. + fn list(&self, dir: &OsStr) -> Option> { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(error) + if error.kind() == io::ErrorKind::NotFound + && self.params.missing_file != MissingFile::Error => + { + return Some(Vec::new()); + } + Err(error) => { + self.report_error(dir, &error); + return None; + } + }; + let mut names = Vec::new(); + for entry in entries { + match entry { + Ok(entry) => names.push(entry.file_name()), + Err(error) => { + self.report_error(dir, &error); + return None; + } + } + } + names.sort_unstable_by(|a, b| a.as_encoded_bytes().cmp(b.as_encoded_bytes())); + Some(names) + } + + /// Compares two entries of the compared directories, one of which may be + /// missing when missing files are treated as empty. + fn compare_entries(&mut self, from: &OsStr, to: &OsStr) -> Outcome { + let (Ok(from_metadata), Ok(to_metadata)) = (self.metadata(from), self.metadata(to)) else { + return Outcome::Trouble; + }; + match (from_metadata, to_metadata) { + (Some(from_metadata), Some(to_metadata)) + if from_metadata.is_dir() && to_metadata.is_dir() => + { + if same_file::is_same_file(from, to).unwrap_or(false) { + Outcome::Identical + } else { + self.compare_subdirectories(from, to) + } + } + (Some(from_metadata), Some(to_metadata)) + if !(from_metadata.is_file() && to_metadata.is_file()) => + { + let (from_kind, to_kind) = (file_kind(&from_metadata), file_kind(&to_metadata)); + println!( + "File {} is a {from_kind} while file {} is a {to_kind}", + from.to_string_lossy(), + to.to_string_lossy() + ); + Outcome::Different + } + // A directory on one side only, the other being missing on + // purpose (-N), is compared with an empty directory. + (Some(metadata), None) | (None, Some(metadata)) if metadata.is_dir() => { + self.compare_subdirectories(from, to) + } + _ => self.compare_files(from, to), + } + } + + /// Descends into two subdirectories with -r, or only reports them. + fn compare_subdirectories(&mut self, from: &OsStr, to: &OsStr) -> Outcome { + if self.params.recursive { + self.compare_directories(from, to) + } else { + println!( + "Common subdirectories: {} and {}", + from.to_string_lossy(), + to.to_string_lossy() + ); + Outcome::Identical + } + } + + fn compare_files(&self, from: &OsStr, to: &OsStr) -> Outcome { + let params = Params { + from: from.to_os_string(), + to: to.to_os_string(), + ..self.params.clone() + }; + let mut header = b"diff".to_vec(); + for arg in self + .option_args + .iter() + .map(OsString::as_os_str) + .chain([from, to]) + { + header.push(b' '); + header.extend_from_slice(arg.as_encoded_bytes()); + } + header.push(b'\n'); + compare_files(¶ms, Some(&header)) + } + + /// Returns the metadata of `path`, `None` if it is missing on purpose + /// (-N), or an error after reporting it. + fn metadata(&self, path: &OsStr) -> Result, ()> { + match fs::metadata(path) { + Ok(metadata) => Ok(Some(metadata)), + Err(error) + if error.kind() == io::ErrorKind::NotFound + && self.params.missing_file != MissingFile::Error + && fs::symlink_metadata(path).is_err() => + { + Ok(None) + } + Err(error) => { + self.report_error(path, &error); + Err(()) + } + } + } + + fn report_error(&self, path: &OsStr, error: &io::Error) { + report_failure_to_read_input_file(&self.params.executable, &path.to_os_string(), error); + } +} + +/// An entry of either or both of the compared directories. +#[derive(Debug, PartialEq)] +enum Entry<'a> { + Both(&'a OsStr, &'a OsStr), + FromOnly(&'a OsStr), + ToOnly(&'a OsStr), +} + +/// Merges two lists of names sorted by `order`, pairing the names that are +/// equal according to it. +fn pair_entries<'a>( + from_names: &'a [OsString], + to_names: &'a [OsString], + order: impl Fn(&OsStr, &OsStr) -> Ordering, +) -> Vec> { + let mut entries = Vec::with_capacity(from_names.len().max(to_names.len())); + let mut from_names = from_names.iter().map(OsString::as_os_str).peekable(); + let mut to_names = to_names.iter().map(OsString::as_os_str).peekable(); + loop { + let entry = match (from_names.peek(), to_names.peek()) { + (None, None) => return entries, + (Some(&from), Some(&to)) => match order(from, to) { + Ordering::Equal => Entry::Both(from, to), + Ordering::Less => Entry::FromOnly(from), + Ordering::Greater => Entry::ToOnly(to), + }, + (Some(&from), None) => Entry::FromOnly(from), + (None, Some(&to)) => Entry::ToOnly(to), + }; + match entry { + Entry::Both(..) => { + from_names.next(); + to_names.next(); + } + Entry::FromOnly(_) => { + from_names.next(); + } + Entry::ToOnly(_) => { + to_names.next(); + } + } + entries.push(entry); + } +} + +fn report_only_in(dir: &OsStr, name: &OsStr) -> Outcome { + println!( + "Only in {}: {}", + dir.to_string_lossy(), + name.to_string_lossy() + ); + Outcome::Different +} + +/// Joins a directory and an entry name, without doubling the separator when +/// the directory was given with trailing slashes. +fn join(dir: &OsStr, name: &OsStr) -> OsString { + let dir: PathBuf = Path::new(dir).components().collect(); + dir.join(name).into_os_string() +} + +/// Describes the type of a file, as in "File X is a fifo while ...". +fn file_kind(metadata: &Metadata) -> &'static str { + let file_type = metadata.file_type(); + if file_type.is_dir() { + return "directory"; + } + if file_type.is_file() { + return if metadata.len() == 0 { + "regular empty file" + } else { + "regular file" + }; + } + #[cfg(unix)] + { + use std::os::unix::fs::FileTypeExt; + if file_type.is_fifo() { + return "fifo"; + } + if file_type.is_socket() { + return "socket"; + } + if file_type.is_char_device() { + return "character special file"; + } + if file_type.is_block_device() { + return "block special file"; + } + } + "weird file" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entries_are_paired_by_name() { + let names = + |names: &[&str]| -> Vec { names.iter().map(OsString::from).collect() }; + let (from, to) = (names(&["b", "d", "e", "x"]), names(&["a", "d", "x", "z"])); + let entries = pair_entries(&from, &to, |a, b| a.cmp(b)); + let name = OsStr::new; + assert_eq!( + entries, + [ + Entry::ToOnly(name("a")), + Entry::FromOnly(name("b")), + Entry::Both(name("d"), name("d")), + Entry::FromOnly(name("e")), + Entry::Both(name("x"), name("x")), + Entry::ToOnly(name("z")), + ] + ); + assert_eq!(pair_entries(&[], &[], |a, b| a.cmp(b)), []); + } +} diff --git a/src/main.rs b/src/main.rs index 3ee3c5d..2be2809 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod cmp; mod color; mod context_diff; mod diff; +mod dir_diff; mod ed_diff; mod macros; mod normal_diff; diff --git a/src/params.rs b/src/params.rs index 578bbaa..d92346f 100644 --- a/src/params.rs +++ b/src/params.rs @@ -51,6 +51,7 @@ pub struct Params { /// Basic regular expressions matching the lines shown after hunk /// headers (`-F` and `-p`). pub function_line_patterns: Vec, + pub recursive: bool, } /// The pattern of `-p`, matching the first line of C functions. @@ -75,14 +76,22 @@ impl Default for Params { palette: None, presume_output_tty: false, function_line_patterns: Vec::new(), + recursive: false, } } } -pub fn parse_params>(mut opts: Peekable) -> Result { - // parse CLI - - let Some(executable) = opts.next() else { +/// Parses the command line, and also returns its arguments other than the +/// program name and the operands. +pub fn parse_params>( + opts: Peekable, +) -> Result<(Params, Vec), String> { + let args: Vec = opts.collect(); + // Each argument comes with its position, to tell the operands apart from + // an option argument with the same value. + let mut opts = args.iter().cloned().enumerate().peekable(); + let mut operand_positions = Vec::new(); + let Some((_, executable)) = opts.next() else { return Err("Usage: ".to_string()); }; let mut params = Params { @@ -96,12 +105,13 @@ pub fn parse_params>(mut opts: Peekable) -> Resu let mut show_c_function = false; let tabsize_re = Regex::new(r"^--tabsize=(?\d+)$").unwrap(); let width_re = Regex::new(r"^--width=(?P\d+)$").unwrap(); - while let Some(param) = opts.next() { - let next_param = opts.peek(); + while let Some((position, param)) = opts.next() { + let next_param = opts.peek().map(|(_, next_param)| next_param); if param == "--" { break; } if param == "-" { + operand_positions.push(position); if from.is_none() { from = Some(param); } else if to.is_none() { @@ -146,7 +156,7 @@ pub fn parse_params>(mut opts: Peekable) -> Resu continue; } if param == "--palette" { - let Some(palette) = opts.next() else { + let Some((_, palette)) = opts.next() else { return Err("option '--palette' requires an argument".to_string()); }; params.palette = Some(palette.to_string_lossy().into_owned()); @@ -184,6 +194,10 @@ pub fn parse_params>(mut opts: Peekable) -> Resu push_function_line_pattern(&mut params, &pattern)?; continue; } + if param == "-r" || param == "--recursive" { + params.recursive = true; + continue; + } if param == "-a" || param == "--text" { params.text = true; continue; @@ -298,6 +312,7 @@ pub fn parse_params>(mut opts: Peekable) -> Resu if param.to_string_lossy().starts_with('-') { return Err(format!("unrecognized option '{}'", param.to_string_lossy())); } + operand_positions.push(position); if from.is_none() { from = Some(param); } else if to.is_none() { @@ -311,7 +326,8 @@ pub fn parse_params>(mut opts: Peekable) -> Resu } params.from = if let Some(from) = from { from - } else if let Some(param) = opts.next() { + } else if let Some((position, param)) = opts.next() { + operand_positions.push(position); param } else { return Err(format!( @@ -321,7 +337,8 @@ pub fn parse_params>(mut opts: Peekable) -> Resu }; params.to = if let Some(to) = to { to - } else if let Some(param) = opts.next() { + } else if let Some((position, param)) = opts.next() { + operand_positions.push(position); param } else { return Err(format!( @@ -351,7 +368,14 @@ pub fn parse_params>(mut opts: Peekable) -> Resu if let Some(context_count) = context { params.context_count = context_count; } - Ok(params) + let options = args + .into_iter() + .enumerate() + .skip(1) + .filter(|(position, _)| !operand_positions.contains(position)) + .map(|(_, arg)| arg) + .collect(); + Ok((params, options)) } /// Validates and records a `-F` pattern. @@ -458,6 +482,10 @@ fn match_unified_diff_params( #[cfg(test)] mod tests { use super::*; + + fn parse_params>(opts: Peekable) -> Result { + super::parse_params(opts).map(|(params, _)| params) + } fn os(s: &str) -> OsString { OsString::from(s) } @@ -1157,4 +1185,27 @@ mod tests { Err("'oops\\': Trailing backslash".to_string()) ); } + + #[test] + fn option_arguments() { + let options = |args: &[&str]| { + let (_, options) = + super::parse_params(args.iter().copied().map(os).peekable()).unwrap(); + options + }; + assert_eq!( + options(&["diff", "-u", "old", "-r", "new"]), + [os("-u"), os("-r")] + ); + // An option argument equal to an operand stays with its option. + assert_eq!( + options(&["diff", "-F", "new", "old", "new"]), + [os("-F"), os("new")] + ); + assert_eq!( + options(&["diff", "-q", "--", "-old", "new"]), + [os("-q"), os("--")] + ); + assert_eq!(options(&["diff", "-", "new", "-t"]), [os("-t")]); + } } diff --git a/tests/by-util/test_diff.rs b/tests/by-util/test_diff.rs index ae5fbf8..e142903 100644 --- a/tests/by-util/test_diff.rs +++ b/tests/by-util/test_diff.rs @@ -475,6 +475,149 @@ fn context_timestamp_follows_time_locale() -> Result<(), Box Result<(), Box> { + let tmp_dir = tempdir()?; + let left = tmp_dir.path().join("left"); + let right = tmp_dir.path().join("right"); + for dir in [&left, &right] { + std::fs::create_dir_all(dir.join("nested/inner"))?; + std::fs::write(dir.join("equal"), "same\n")?; + } + std::fs::write(left.join("changed"), "old\n")?; + std::fs::write(right.join("changed"), "new\n")?; + std::fs::write(left.join("nested/inner/deep"), "one\n")?; + std::fs::write(right.join("nested/inner/deep"), "two\n")?; + std::fs::write(left.join("gone"), "bye\n")?; + std::fs::write(right.join("added"), "hi\n")?; + std::fs::write(left.join("kind"), "file\n")?; + std::fs::create_dir(right.join("kind"))?; + let left = left.to_str().unwrap(); + let right = right.to_str().unwrap(); + // Entry names are joined to the directories with the native separator. + let s = std::path::MAIN_SEPARATOR; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg(left).arg(right); + cmd.assert().code(predicate::eq(1)).stdout(format!( + "Only in {right}: added\n\ + diff {left}{s}changed {right}{s}changed\n1c1\n< old\n---\n> new\n\ + Only in {left}: gone\n\ + File {left}{s}kind is a regular file while file {right}{s}kind is a directory\n\ + Common subdirectories: {left}{s}nested and {right}{s}nested\n" + )); + + // Options are repeated as given in the headers, and trailing slashes + // do not end up in the compared paths. + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("-r") + .arg("-q") + .arg(format!("{left}/")) + .arg(right); + cmd.assert().code(predicate::eq(1)).stdout(format!( + "Only in {right}: added\n\ + Files {left}{s}changed and {right}{s}changed differ\n\ + Only in {left}/: gone\n\ + File {left}{s}kind is a regular file while file {right}{s}kind is a directory\n\ + Files {left}{s}nested{s}inner{s}deep and {right}{s}nested{s}inner{s}deep differ\n" + )); + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("-N") + .arg("-r") + .arg("-e") + .arg(left) + .arg(right); + cmd.assert().code(predicate::eq(1)).stdout(format!( + "diff -N -r -e {left}{s}added {right}{s}added\n0a\nhi\n.\n\ + diff -N -r -e {left}{s}changed {right}{s}changed\n1c\nnew\n.\n\ + diff -N -r -e {left}{s}gone {right}{s}gone\n1d\n\ + File {left}{s}kind is a regular file while file {right}{s}kind is a directory\n\ + diff -N -r -e {left}{s}nested{s}inner{s}deep {right}{s}nested{s}inner{s}deep\n1c\ntwo\n.\n" + )); + + // -s reports the identical files, but a directory is not compared with + // itself. + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg("-r").arg("-s").arg(left).arg(left); + cmd.assert().code(predicate::eq(0)).stdout(""); + + let copy = tmp_dir.path().join("copy"); + std::fs::create_dir_all(copy.join("nested/inner"))?; + std::fs::write(copy.join("changed"), "old\n")?; + std::fs::write(copy.join("equal"), "same\n")?; + std::fs::write(copy.join("nested/inner/deep"), "one\n")?; + let copy = copy.to_str().unwrap(); + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg("-r").arg("-s").arg(left).arg(copy); + cmd.assert().code(predicate::eq(1)).stdout(format!( + "Files {left}{s}changed and {copy}{s}changed are identical\n\ + Files {left}{s}equal and {copy}{s}equal are identical\n\ + Only in {left}: gone\n\ + Only in {left}: kind\n\ + Files {left}{s}nested{s}inner{s}deep and {copy}{s}nested{s}inner{s}deep are identical\n" + )); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn compare_directories_special_entries() -> Result<(), Box> { + let tmp_dir = tempdir()?; + for dir in ["one", "two"] { + std::fs::create_dir_all(tmp_dir.path().join(dir).join("tools/bin"))?; + // Links to the same directory are not compared. + std::os::unix::fs::symlink("..", tmp_dir.path().join(dir).join("parent"))?; + let status = std::process::Command::new("mkfifo") + .arg(tmp_dir.path().join(dir).join("queue")) + .status()?; + assert!(status.success()); + } + std::fs::write(tmp_dir.path().join("one/tools/bin/run"), "go\n")?; + std::fs::remove_dir_all(tmp_dir.path().join("two/tools"))?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.current_dir(tmp_dir.path()) + .arg("diff") + .arg("-N") + .arg("one") + .arg("two"); + cmd.assert().code(predicate::eq(1)).stdout( + "File one/queue is a fifo while file two/queue is a fifo\n\ + Common subdirectories: one/tools and two/tools\n", + ); + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.current_dir(tmp_dir.path()) + .arg("diff") + .arg("-r") + .arg("-N") + .arg("one") + .arg("two"); + cmd.assert().code(predicate::eq(1)).stdout( + "File one/queue is a fifo while file two/queue is a fifo\n\ + diff -r -N one/tools/bin/run two/tools/bin/run\n1d0\n< go\n", + ); + + // A link back to a directory being compared is reported. + std::os::unix::fs::symlink("..", tmp_dir.path().join("one/tools/again"))?; + std::fs::create_dir(tmp_dir.path().join("two/tools"))?; + std::os::unix::fs::symlink("..", tmp_dir.path().join("two/tools/again"))?; + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.current_dir(tmp_dir.path()) + .arg("diff") + .arg("-r") + .arg("one") + .arg("two"); + cmd.assert() + .code(predicate::eq(2)) + .stdout(predicate::str::contains("Only in one/tools: bin\n")) + .stderr("diff: one/tools/again: recursive directory loop\n"); + Ok(()) +} + #[test] fn compare_file_to_directory() -> Result<(), Box> { let tmp_dir = tempdir()?;