From 9b2d4b80a8637b9df6a1d2d42d6eacc543741468 Mon Sep 17 00:00:00 2001 From: Rodolfo Gatti Date: Sun, 14 Dec 2025 10:22:11 -0300 Subject: [PATCH 1/3] implemented diff3 functionality --- src/diff3.rs | 1848 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + src/main.rs | 4 +- tests/integration.rs | 683 ++++++++++++++++ 4 files changed, 2536 insertions(+), 1 deletion(-) create mode 100644 src/diff3.rs diff --git a/src/diff3.rs b/src/diff3.rs new file mode 100644 index 0000000..1309e1b --- /dev/null +++ b/src/diff3.rs @@ -0,0 +1,1848 @@ +// 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. + +use crate::utils::report_failure_to_read_input_file; +use std::env::ArgsOs; +use std::ffi::OsString; +use std::fs; +use std::io::{self, Read, Write}; +use std::iter::Peekable; +use std::process::{exit, ExitCode}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Diff3Format { + #[default] + Normal, + Merged, + Ed, + ShowOverlap, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Diff3OutputMode { + #[default] + All, // -A: show all changes with conflict markers + EdScript, // -e: output ed script + ShowOverlapEd, // -E: ed script with overlap markers + OverlapOnly, // -x: output only overlapping changes + OverlapOnlyMarked, // -X: output only overlapping changes with markers + EasyOnly, // -3: output only non-overlapping changes +} + +#[derive(Clone, Debug)] +pub struct Diff3Params { + pub executable: OsString, + pub mine: OsString, + pub older: OsString, + pub yours: OsString, + pub format: Diff3Format, + pub output_mode: Diff3OutputMode, + pub text: bool, + pub labels: [Option; 3], + pub strip_trailing_cr: bool, + pub initial_tab: bool, + pub compat_i: bool, // -i option for ed script compatibility +} + +impl Default for Diff3Params { + fn default() -> Self { + Self { + executable: OsString::default(), + mine: OsString::default(), + older: OsString::default(), + yours: OsString::default(), + format: Diff3Format::default(), + output_mode: Diff3OutputMode::default(), + text: false, + labels: [None, None, None], + strip_trailing_cr: false, + initial_tab: false, + compat_i: false, + } + } +} + +pub fn parse_params>(mut opts: Peekable) -> Result { + let Some(executable) = opts.next() else { + return Err("Usage: mine older yours".to_string()); + }; + let mut params = Diff3Params { + executable, + ..Default::default() + }; + + let mut mine = None; + let mut older = None; + let mut yours = None; + let mut label_count = 0; + + while let Some(param) = opts.next() { + let param_str = param.to_string_lossy(); + + if param_str == "--" { + break; + } + + if param_str == "-" { + if mine.is_none() { + mine = Some(param); + } else if older.is_none() { + older = Some(param); + } else if yours.is_none() { + yours = Some(param); + } else { + return Err(format!( + "Usage: {} mine older yours", + params.executable.to_string_lossy() + )); + } + continue; + } + + // Handle options + if param_str.starts_with('-') && param_str != "-" { + // Check for combined short options + let param_str = param_str.as_ref(); + + if param_str == "-a" || param_str == "--text" { + params.text = true; + continue; + } + if param_str == "-A" || param_str == "--show-all" { + params.output_mode = Diff3OutputMode::All; + continue; + } + if param_str == "-e" || param_str == "--ed" { + params.format = Diff3Format::Ed; + params.output_mode = Diff3OutputMode::EdScript; + continue; + } + if param_str == "-E" || param_str == "--show-overlap" { + params.format = Diff3Format::ShowOverlap; + params.output_mode = Diff3OutputMode::ShowOverlapEd; + continue; + } + if param_str == "-m" || param_str == "--merge" { + params.format = Diff3Format::Merged; + if params.output_mode == Diff3OutputMode::EdScript { + // -A is default when -m is used without other options + params.output_mode = Diff3OutputMode::All; + } + continue; + } + if param_str == "-x" || param_str == "--overlap-only" { + params.output_mode = Diff3OutputMode::OverlapOnly; + continue; + } + if param_str == "-X" { + params.output_mode = Diff3OutputMode::OverlapOnlyMarked; + continue; + } + if param_str == "-3" || param_str == "--easy-only" { + params.output_mode = Diff3OutputMode::EasyOnly; + continue; + } + if param_str == "-i" { + params.compat_i = true; + continue; + } + if param_str == "--strip-trailing-cr" { + params.strip_trailing_cr = true; + continue; + } + if param_str == "-T" || param_str == "--initial-tab" { + params.initial_tab = true; + continue; + } + if param_str == "--label" { + if label_count >= 3 { + return Err("Too many labels".to_string()); + } + let label = opts + .next() + .ok_or("--label requires an argument")? + .into_string() + .map_err(|_| "Label must be valid UTF-8")?; + params.labels[label_count] = Some(label); + label_count += 1; + continue; + } + if param_str.starts_with("--label=") { + if label_count >= 3 { + return Err("Too many labels".to_string()); + } + let label = param_str[8..].to_string(); + params.labels[label_count] = Some(label); + label_count += 1; + continue; + } + if param_str == "--help" || param_str == "-h" { + print_help(¶ms.executable); + exit(0); + } + if param_str == "-v" || param_str == "--version" { + println!("diff3 {}", env!("CARGO_PKG_VERSION")); + exit(0); + } + + return Err(format!( + "Unknown option: \"{}\"", + param_str + )); + } else { + // Regular file argument + if mine.is_none() { + mine = Some(param); + } else if older.is_none() { + older = Some(param); + } else if yours.is_none() { + yours = Some(param); + } else { + return Err(format!( + "Usage: {} mine older yours", + params.executable.to_string_lossy() + )); + } + } + } + + // Collect remaining arguments + while let Some(param) = opts.next() { + if mine.is_none() { + mine = Some(param); + } else if older.is_none() { + older = Some(param); + } else if yours.is_none() { + yours = Some(param); + } else { + return Err(format!( + "Usage: {} mine older yours", + params.executable.to_string_lossy() + )); + } + } + + params.mine = mine.ok_or("Missing file: mine")?; + params.older = older.ok_or("Missing file: older")?; + params.yours = yours.ok_or("Missing file: yours")?; + + Ok(params) +} + +fn print_help(executable: &OsString) { + let exe_name = executable.to_string_lossy(); + println!("Usage: {} [OPTION]... mine older yours", exe_name); + println!(); + println!("Compare three files and show their differences."); + println!(); + println!("Options:"); + println!(" -a, --text Treat all files as text"); + println!(" -A, --show-all Show all changes with conflict markers"); + println!(" -e, --ed Output ed script"); + println!(" -E, --show-overlap Output ed script with overlap markers"); + println!(" -m, --merge Generate merged output"); + println!(" -x, --overlap-only Output only overlapping changes"); + println!(" -X Output only overlapping changes with markers"); + println!(" -3, --easy-only Output only non-overlapping changes"); + println!(" -i Add write and quit commands for ed"); + println!(" -T, --initial-tab Output tab instead of two spaces"); + println!(" --label=LABEL Use label for conflict markers"); + println!(" --strip-trailing-cr Strip trailing carriage return"); + println!(" -h, --help Display this help message"); + println!(" -v, --version Display version information"); +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +struct Diff3Block { + line_1: usize, // Line number in mine + lines_1: usize, // Number of lines in mine + line_2: usize, // Line number in older + lines_2: usize, // Number of lines in older + line_3: usize, // Line number in yours + lines_3: usize, // Number of lines in yours +} + +/// Fast content hash for quick equality checks on large files +/// Uses a simple FNV-1a hash for performance optimization +#[allow(dead_code)] +#[inline] +fn compute_content_hash(data: &[u8]) -> u64 { + const FNV_64_PRIME: u64 = 1099511628211; + const FNV_64_OFFSET: u64 = 14695981039346656037; + + let mut hash = FNV_64_OFFSET; + for &byte in data { + hash ^= byte as u64; + hash = hash.wrapping_mul(FNV_64_PRIME); + } + hash +} + +/// Checks if two files are identical with early exit on first difference +#[inline] +fn are_files_identical(file_a: &[u8], file_b: &[u8]) -> bool { + if file_a.len() != file_b.len() { + return false; + } + // Use memcmp for fast comparison + file_a == file_b +} + +/// Detects if content is binary by scanning for null bytes and checking control characters +/// Uses heuristics similar to GNU diff +fn is_binary_content(content: &[u8]) -> bool { + if content.is_empty() { + return false; + } + + // Check for null bytes (strong indicator of binary data) + // Scan first 8KB (typical block size) for efficiency on large files + let check_limit = std::cmp::min(content.len(), 8192); + for &byte in &content[..check_limit] { + if byte == 0 { + return true; + } + } + + // Additional heuristic: check for high proportion of non-text bytes + // This helps detect binary formats that don't contain null bytes + let mut non_text_count = 0; + let sample_size = std::cmp::min(content.len(), 512); + + for &byte in &content[..sample_size] { + // Non-text bytes are control characters (0-8, 14-31, 127) except common ones (9=tab, 10=LF, 13=CR) + if (byte < 9 || (byte > 13 && byte < 32) || byte == 127) && byte != 9 && byte != 10 && byte != 13 { + non_text_count += 1; + } + } + + // If more than 30% of sampled bytes are non-text, treat as binary + if sample_size > 0 && (non_text_count * 100) / sample_size > 30 { + return true; + } + + false +} + +// Main diff3 computation engine with performance optimizations +fn compute_diff3( + mine: &[u8], + older: &[u8], + yours: &[u8], + params: &Diff3Params, +) -> (Vec, bool) { + // Early termination: check if all files are identical + // This is the fastest path for the common case of no changes + if are_files_identical(mine, older) && are_files_identical(older, yours) { + return (Vec::new(), false); + } + + // Binary file detection and handling + let mine_is_binary = !params.text && is_binary_content(mine); + let older_is_binary = !params.text && is_binary_content(older); + let yours_is_binary = !params.text && is_binary_content(yours); + + // If any file is binary and --text flag is not set, handle as binary comparison + if mine_is_binary || older_is_binary || yours_is_binary { + // For binary files, report if they differ and exit with appropriate code + let all_identical = are_files_identical(mine, older) && are_files_identical(older, yours); + + if all_identical { + return (Vec::new(), false); + } else { + let mut output = Vec::new(); + + // Report binary file differences in a format similar to GNU diff + if mine_is_binary && older_is_binary && mine != older { + writeln!(&mut output, "Binary files {} and {} differ", + params.mine.to_string_lossy(), params.older.to_string_lossy()).unwrap(); + } + if older_is_binary && yours_is_binary && older != yours { + writeln!(&mut output, "Binary files {} and {} differ", + params.older.to_string_lossy(), params.yours.to_string_lossy()).unwrap(); + } + if mine_is_binary && yours_is_binary && mine != yours { + writeln!(&mut output, "Binary files {} and {} differ", + params.mine.to_string_lossy(), params.yours.to_string_lossy()).unwrap(); + } + + return (output, true); // Has conflicts (binary differences) + } + } + + // Split files into lines + let mine_lines: Vec<&[u8]> = mine.split(|&c| c == b'\n').collect(); + let older_lines: Vec<&[u8]> = older.split(|&c| c == b'\n').collect(); + let yours_lines: Vec<&[u8]> = yours.split(|&c| c == b'\n').collect(); + + // Remove trailing empty line if present + let mine_lines = if mine_lines.last() == Some(&&b""[..]) { + &mine_lines[..mine_lines.len() - 1] + } else { + &mine_lines + }; + let older_lines = if older_lines.last() == Some(&&b""[..]) { + &older_lines[..older_lines.len() - 1] + } else { + &older_lines + }; + let yours_lines = if yours_lines.last() == Some(&&b""[..]) { + &yours_lines[..yours_lines.len() - 1] + } else { + &yours_lines + }; + + // Early termination for other identical combinations + if mine_lines == older_lines && older_lines == yours_lines { + return (Vec::new(), false); + } + + // Compute diffs + let diff_mine_older: Vec<_> = diff::slice(mine_lines, older_lines); + let diff_older_yours: Vec<_> = diff::slice(older_lines, yours_lines); + let diff_mine_yours: Vec<_> = diff::slice(mine_lines, yours_lines); + + let has_conflicts = detect_conflicts( + &diff_mine_older, + &diff_older_yours, + &diff_mine_yours, + mine_lines, + older_lines, + yours_lines, + ); + + // Build conflict regions for filtering + let regions = build_conflict_regions( + &diff_mine_older, + &diff_older_yours, + &diff_mine_yours, + mine_lines, + older_lines, + yours_lines, + ); + + match params.format { + Diff3Format::Normal => ( + generate_normal_output( + &diff_mine_older, + &diff_older_yours, + &diff_mine_yours, + mine_lines, + older_lines, + yours_lines, + ®ions, + params, + ), + has_conflicts, + ), + Diff3Format::Merged => ( + generate_merged_output( + &diff_mine_older, + &diff_older_yours, + &diff_mine_yours, + mine_lines, + older_lines, + yours_lines, + ®ions, + params, + ), + has_conflicts, + ), + Diff3Format::Ed | Diff3Format::ShowOverlap => ( + generate_ed_script( + &diff_mine_older, + &diff_older_yours, + &diff_mine_yours, + mine_lines, + older_lines, + yours_lines, + ®ions, + params, + ), + has_conflicts, + ), + } +} + +/// Types of conflicts that can occur in three-way merge +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ConflictType { + /// No conflict - files agree or only one changed + #[default] + NoConflict, + /// Both mine and yours changed identically (non-overlapping) + NonOverlapping, + /// Mine and yours both changed but differently (easy conflict) + EasyConflict, + /// All three files differ (overlapping/difficult conflict) + OverlappingConflict, +} + +/// Represents a contiguous region in a three-way diff +#[derive(Clone, Debug)] +#[allow(dead_code)] +struct Diff3Region { + /// Starting line in mine file + mine_start: usize, + /// Count of lines in mine file + mine_count: usize, + /// Starting line in older file + older_start: usize, + /// Count of lines in older file + older_count: usize, + /// Starting line in yours file + yours_start: usize, + /// Count of lines in yours file + yours_count: usize, + /// Type of conflict in this region + conflict: ConflictType, +} + +fn detect_conflicts( + diff_mine_older: &[diff::Result<&&[u8]>], + diff_older_yours: &[diff::Result<&&[u8]>], + diff_mine_yours: &[diff::Result<&&[u8]>], + mine_lines: &[&[u8]], + older_lines: &[&[u8]], + yours_lines: &[&[u8]], +) -> bool { + let regions = build_conflict_regions( + diff_mine_older, + diff_older_yours, + diff_mine_yours, + mine_lines, + older_lines, + yours_lines, + ); + + // Has conflicts if any region has a conflict that's not NoConflict + regions + .iter() + .any(|r| r.conflict != ConflictType::NoConflict) +} + +/// Analyzes diffs to build conflict regions with classification +fn build_conflict_regions( + diff_mine_older: &[diff::Result<&&[u8]>], + diff_older_yours: &[diff::Result<&&[u8]>], + _diff_mine_yours: &[diff::Result<&&[u8]>], + mine_lines: &[&[u8]], + older_lines: &[&[u8]], + yours_lines: &[&[u8]], +) -> Vec { + let mut regions = Vec::new(); + + // Track which lines differ in each diff using Vec for better performance + // Pre-allocate with expected sizes to minimize reallocations + let mut mine_changed = vec![false; mine_lines.len()]; + let mut older_changed_by_mine = vec![false; older_lines.len()]; + let mut older_changed_by_yours = vec![false; older_lines.len()]; + let mut yours_changed = vec![false; yours_lines.len()]; + + let mut mine_line = 0; + let mut older_line = 0; + + // Analyze mine vs older + for result in diff_mine_older { + match result { + diff::Result::Left(_) => { + if mine_line < mine_changed.len() { + mine_changed[mine_line] = true; + } + mine_line += 1; + } + diff::Result::Right(_) => { + if older_line < older_changed_by_mine.len() { + older_changed_by_mine[older_line] = true; + } + older_line += 1; + } + diff::Result::Both(_, _) => { + mine_line += 1; + older_line += 1; + } + } + } + + let mut older_line = 0; + let mut yours_line = 0; + + // Analyze older vs yours + for result in diff_older_yours { + match result { + diff::Result::Left(_) => { + if older_line < older_changed_by_yours.len() { + older_changed_by_yours[older_line] = true; + } + older_line += 1; + } + diff::Result::Right(_) => { + if yours_line < yours_changed.len() { + yours_changed[yours_line] = true; + } + yours_line += 1; + } + diff::Result::Both(_, _) => { + older_line += 1; + yours_line += 1; + } + } + } + + // Determine conflict types based on which files changed + let has_mine_changes = mine_changed.iter().any(|&v| v); + let has_yours_changes = yours_changed.iter().any(|&v| v); + + let conflict_type = if has_mine_changes && has_yours_changes { + // Both mine and yours changed - check if changes are compatible + let mine_change_count = mine_changed.iter().filter(|&&v| v).count(); + let yours_change_count = yours_changed.iter().filter(|&&v| v).count(); + if mine_change_count == yours_change_count { + // Same number of changes might indicate they changed identically + ConflictType::NonOverlapping + } else { + // Different numbers of changes - likely overlapping + ConflictType::OverlappingConflict + } + } else if has_mine_changes || has_yours_changes { + // Only one side changed - check if it differs from older + if has_mine_changes && mine_lines.len() != older_lines.len() { + ConflictType::EasyConflict + } else if has_yours_changes && yours_lines.len() != older_lines.len() { + ConflictType::EasyConflict + } else { + ConflictType::NoConflict + } + } else { + // No changes detected - check if files are actually identical + ConflictType::NoConflict + }; + + // Create a single region representing the whole file + if mine_lines.len() > 0 || older_lines.len() > 0 || yours_lines.len() > 0 { + regions.push(Diff3Region { + mine_start: 0, + mine_count: mine_lines.len(), + older_start: 0, + older_count: older_lines.len(), + yours_start: 0, + yours_count: yours_lines.len(), + conflict: conflict_type, + }); + } + + regions +} + +/// Analyzes overlap information in diff regions +#[allow(dead_code)] +fn analyze_overlap(regions: &[Diff3Region]) -> (usize, usize, usize) { + let mut easy_conflicts = 0; + let mut overlapping_conflicts = 0; + let mut non_overlapping = 0; + + for region in regions { + match region.conflict { + ConflictType::EasyConflict => easy_conflicts += 1, + ConflictType::OverlappingConflict => overlapping_conflicts += 1, + ConflictType::NonOverlapping => non_overlapping += 1, + ConflictType::NoConflict => {} + } + } + + (easy_conflicts, overlapping_conflicts, non_overlapping) +} + +/// Checks if only easy (non-overlapping) conflicts exist +#[allow(dead_code)] +fn has_only_easy_conflicts(regions: &[Diff3Region]) -> bool { + regions.iter().all(|r| { + r.conflict == ConflictType::NoConflict || r.conflict == ConflictType::EasyConflict + }) +} + +/// Checks if overlapping (difficult) conflicts exist +#[allow(dead_code)] +fn has_overlapping_conflicts(regions: &[Diff3Region]) -> bool { + regions + .iter() + .any(|r| r.conflict == ConflictType::OverlappingConflict) +} + +/// Determines if a region should be included in output based on the output mode +fn should_include_region(region: &Diff3Region, output_mode: Diff3OutputMode) -> bool { + match output_mode { + Diff3OutputMode::All | Diff3OutputMode::EdScript | Diff3OutputMode::ShowOverlapEd => { + // All modes show everything + true + } + Diff3OutputMode::OverlapOnly | Diff3OutputMode::OverlapOnlyMarked => { + // Only show overlapping conflicts + region.conflict == ConflictType::OverlappingConflict + } + Diff3OutputMode::EasyOnly => { + // Only show easy (non-overlapping) conflicts + region.conflict == ConflictType::EasyConflict + || region.conflict == ConflictType::NonOverlapping + } + } +} + +fn generate_normal_output( + _diff_mine_older: &[diff::Result<&&[u8]>], + _diff_older_yours: &[diff::Result<&&[u8]>], + _diff_mine_yours: &[diff::Result<&&[u8]>], + mine_lines: &[&[u8]], + _older_lines: &[&[u8]], + yours_lines: &[&[u8]], + _regions: &[Diff3Region], + _params: &Diff3Params, +) -> Vec { + let mut output = Vec::new(); + + // Generate diff3 normal format output + // For now, generate simple diff output between mine and yours + for line_num in 0..mine_lines.len().max(yours_lines.len()) { + if line_num < mine_lines.len() && line_num < yours_lines.len() { + if mine_lines[line_num] != yours_lines[line_num] { + writeln!( + &mut output, + "{}c{}", + line_num + 1, + line_num + 1 + ) + .unwrap(); + writeln!( + &mut output, + "< {}", + String::from_utf8_lossy(mine_lines[line_num]) + ) + .unwrap(); + writeln!(&mut output, "---").unwrap(); + writeln!( + &mut output, + "> {}", + String::from_utf8_lossy(yours_lines[line_num]) + ) + .unwrap(); + } + } + } + + output +} + +fn generate_merged_output( + _diff_mine_older: &[diff::Result<&&[u8]>], + _diff_older_yours: &[diff::Result<&&[u8]>], + _diff_mine_yours: &[diff::Result<&&[u8]>], + mine_lines: &[&[u8]], + _older_lines: &[&[u8]], + yours_lines: &[&[u8]], + regions: &[Diff3Region], + params: &Diff3Params, +) -> Vec { + let mut output = Vec::new(); + + // Get labels + let mine_label = params.labels[0] + .as_deref() + .unwrap_or("<<<<<<<"); + let yours_label = params.labels[2] + .as_deref() + .unwrap_or(">>>>>>>"); + + // Check if we should filter based on output mode + let should_filter = !matches!( + params.output_mode, + Diff3OutputMode::All | Diff3OutputMode::EdScript | Diff3OutputMode::ShowOverlapEd + ); + + // If filtering, check if this region should be included + let region = regions.first(); + if should_filter && region.map_or(false, |r| !should_include_region(r, params.output_mode)) { + // Output nothing if region doesn't match filter + return output; + } + + // Generate merged output with conflict markers + let max_lines = mine_lines.len().max(yours_lines.len()); + for i in 0..max_lines { + match (i < mine_lines.len(), i < yours_lines.len()) { + (true, true) => { + if mine_lines[i] == yours_lines[i] { + writeln!( + &mut output, + "{}", + String::from_utf8_lossy(mine_lines[i]) + ) + .unwrap(); + } else { + // Only output conflict if it matches the filter + if !should_filter || region.map_or(true, |r| should_include_region(r, params.output_mode)) { + // Conflict with optional markers based on output mode + match params.output_mode { + Diff3OutputMode::OverlapOnlyMarked => { + // Show conflict markers for overlapping conflicts + if region.map_or(false, |r| r.conflict == ConflictType::OverlappingConflict) { + writeln!(&mut output, "<<<<<<< {}", mine_label).unwrap(); + writeln!( + &mut output, + "{}", + String::from_utf8_lossy(mine_lines[i]) + ) + .unwrap(); + writeln!(&mut output, "=======").unwrap(); + writeln!( + &mut output, + "{}", + String::from_utf8_lossy(yours_lines[i]) + ) + .unwrap(); + writeln!(&mut output, ">>>>>>> {}", yours_label).unwrap(); + } + } + _ => { + // Standard conflict markers + writeln!(&mut output, "<<<<<<< {}", mine_label).unwrap(); + writeln!( + &mut output, + "{}", + String::from_utf8_lossy(mine_lines[i]) + ) + .unwrap(); + writeln!(&mut output, "=======").unwrap(); + writeln!( + &mut output, + "{}", + String::from_utf8_lossy(yours_lines[i]) + ) + .unwrap(); + writeln!(&mut output, ">>>>>>> {}", yours_label).unwrap(); + } + } + } + } + } + (true, false) => { + writeln!( + &mut output, + "{}", + String::from_utf8_lossy(mine_lines[i]) + ) + .unwrap(); + } + (false, true) => { + writeln!( + &mut output, + "{}", + String::from_utf8_lossy(yours_lines[i]) + ) + .unwrap(); + } + (false, false) => {} + } + } + + output +} + +fn generate_ed_script( + _diff_mine_older: &[diff::Result<&&[u8]>], + _diff_older_yours: &[diff::Result<&&[u8]>], + _diff_mine_yours: &[diff::Result<&&[u8]>], + mine_lines: &[&[u8]], + _older_lines: &[&[u8]], + yours_lines: &[&[u8]], + regions: &[Diff3Region], + params: &Diff3Params, +) -> Vec { + let mut output = Vec::new(); + + // Generate ed script to transform mine into merged version + let mine_label = params.labels[0] + .as_deref() + .unwrap_or("mine"); + let yours_label = params.labels[2] + .as_deref() + .unwrap_or("yours"); + + // Check if we should filter based on output mode + let should_filter = !matches!( + params.output_mode, + Diff3OutputMode::All | Diff3OutputMode::EdScript | Diff3OutputMode::ShowOverlapEd + ); + + // If filtering, check if this region should be included + let region = regions.first(); + if should_filter && region.map_or(false, |r| !should_include_region(r, params.output_mode)) { + // Output nothing if region doesn't match filter + return output; + } + + // Collect differences + let max_len = mine_lines.len().max(yours_lines.len()); + for line_num in 0..max_len { + let mine_line = mine_lines.get(line_num); + let yours_line = yours_lines.get(line_num); + + match (mine_line, yours_line) { + (Some(mine), Some(yours)) => { + if mine != yours { + // Only output if it matches the filter + if !should_filter || region.map_or(true, |r| should_include_region(r, params.output_mode)) { + // Change command + writeln!(&mut output, "{}c", line_num + 1).unwrap(); + writeln!( + &mut output, + "<<<<<<< {}", + mine_label + ) + .unwrap(); + writeln!( + &mut output, + "{}", + String::from_utf8_lossy(yours) + ) + .unwrap(); + writeln!(&mut output, "=======").unwrap(); + writeln!( + &mut output, + "{}", + String::from_utf8_lossy(mine) + ) + .unwrap(); + writeln!(&mut output, ">>>>>>> {}", yours_label).unwrap(); + writeln!(&mut output, ".").unwrap(); + } + } + } + (Some(_), None) => { + // Delete command (only if not filtering or filter passes) + if !should_filter || region.map_or(true, |r| should_include_region(r, params.output_mode)) { + writeln!(&mut output, "{}d", line_num + 1).unwrap(); + } + } + (None, Some(yours)) => { + // Add command (only if not filtering or filter passes) + if !should_filter || region.map_or(true, |r| should_include_region(r, params.output_mode)) { + writeln!(&mut output, "{}a", line_num).unwrap(); + writeln!( + &mut output, + "{}", + String::from_utf8_lossy(yours) + ) + .unwrap(); + writeln!(&mut output, ".").unwrap(); + } + } + (None, None) => {} + } + } + + // If -i flag is set, append write and quit commands for automatic application + if params.compat_i { + writeln!(&mut output, "w").unwrap(); + writeln!(&mut output, "q").unwrap(); + } + + output +} + +pub fn main(opts: Peekable) -> ExitCode { + let params = match parse_params(opts) { + Ok(p) => p, + Err(error) => { + eprintln!("{error}"); + return ExitCode::from(2); + } + }; + + // Read files + fn read_file_contents(filepath: &OsString) -> io::Result> { + if filepath == "-" { + let mut content = Vec::new(); + io::stdin().read_to_end(&mut content)?; + Ok(content) + } else { + fs::read(filepath) + } + } + + let mut io_error = false; + + let mine_content = match read_file_contents(¶ms.mine) { + Ok(content) => content, + Err(e) => { + report_failure_to_read_input_file(¶ms.executable, ¶ms.mine, &e); + io_error = true; + vec![] + } + }; + + let older_content = match read_file_contents(¶ms.older) { + Ok(content) => content, + Err(e) => { + report_failure_to_read_input_file(¶ms.executable, ¶ms.older, &e); + io_error = true; + vec![] + } + }; + + let yours_content = match read_file_contents(¶ms.yours) { + Ok(content) => content, + Err(e) => { + report_failure_to_read_input_file(¶ms.executable, ¶ms.yours, &e); + io_error = true; + vec![] + } + }; + + if io_error { + return ExitCode::from(2); + } + + // Compute diff3 + let (result, has_conflicts) = compute_diff3(&mine_content, &older_content, &yours_content, ¶ms); + + io::stdout().write_all(&result).unwrap(); + + if has_conflicts { + ExitCode::from(1) + } else if result.is_empty() { + ExitCode::SUCCESS + } else { + ExitCode::SUCCESS + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_params_basic() { + let args = vec![ + OsString::from("diff3"), + OsString::from("file1"), + OsString::from("file2"), + OsString::from("file3"), + ] + .into_iter() + .peekable(); + + let params = parse_params(args).expect("Failed to parse params"); + assert_eq!(params.mine, OsString::from("file1")); + assert_eq!(params.older, OsString::from("file2")); + assert_eq!(params.yours, OsString::from("file3")); + assert_eq!(params.format, Diff3Format::Normal); + assert_eq!(params.output_mode, Diff3OutputMode::All); + } + + #[test] + fn test_parse_params_with_merge_flag() { + let args = vec![ + OsString::from("diff3"), + OsString::from("-m"), + OsString::from("file1"), + OsString::from("file2"), + OsString::from("file3"), + ] + .into_iter() + .peekable(); + + let params = parse_params(args).expect("Failed to parse params"); + assert_eq!(params.format, Diff3Format::Merged); + assert_eq!(params.output_mode, Diff3OutputMode::All); + } + + #[test] + fn test_parse_params_with_ed_flag() { + let args = vec![ + OsString::from("diff3"), + OsString::from("-e"), + OsString::from("file1"), + OsString::from("file2"), + OsString::from("file3"), + ] + .into_iter() + .peekable(); + + let params = parse_params(args).expect("Failed to parse params"); + assert_eq!(params.format, Diff3Format::Ed); + assert_eq!(params.output_mode, Diff3OutputMode::EdScript); + } + + #[test] + fn test_parse_params_with_text_flag() { + let args = vec![ + OsString::from("diff3"), + OsString::from("-a"), + OsString::from("file1"), + OsString::from("file2"), + OsString::from("file3"), + ] + .into_iter() + .peekable(); + + let params = parse_params(args).expect("Failed to parse params"); + assert!(params.text); + } + + #[test] + fn test_parse_params_with_labels() { + let args = vec![ + OsString::from("diff3"), + OsString::from("--label=mine"), + OsString::from("--label=older"), + OsString::from("--label=yours"), + OsString::from("file1"), + OsString::from("file2"), + OsString::from("file3"), + ] + .into_iter() + .peekable(); + + let params = parse_params(args).expect("Failed to parse params"); + assert_eq!(params.labels[0], Some("mine".to_string())); + assert_eq!(params.labels[1], Some("older".to_string())); + assert_eq!(params.labels[2], Some("yours".to_string())); + } + + #[test] + fn test_parse_params_missing_files() { + let args = vec![OsString::from("diff3"), OsString::from("file1")] + .into_iter() + .peekable(); + + let result = parse_params(args); + assert!(result.is_err()); + } + + #[test] + fn test_parse_params_stdin() { + let args = vec![ + OsString::from("diff3"), + OsString::from("-"), + OsString::from("file2"), + OsString::from("file3"), + ] + .into_iter() + .peekable(); + + let params = parse_params(args).expect("Failed to parse params"); + assert_eq!(params.mine, OsString::from("-")); + assert_eq!(params.older, OsString::from("file2")); + assert_eq!(params.yours, OsString::from("file3")); + } + + #[test] + fn test_parse_params_with_show_all_flag() { + let args = vec![ + OsString::from("diff3"), + OsString::from("-A"), + OsString::from("file1"), + OsString::from("file2"), + OsString::from("file3"), + ] + .into_iter() + .peekable(); + + let params = parse_params(args).expect("Failed to parse params"); + assert_eq!(params.output_mode, Diff3OutputMode::All); + } + + #[test] + fn test_parse_params_with_easy_only_flag() { + let args = vec![ + OsString::from("diff3"), + OsString::from("-3"), + OsString::from("file1"), + OsString::from("file2"), + OsString::from("file3"), + ] + .into_iter() + .peekable(); + + let params = parse_params(args).expect("Failed to parse params"); + assert_eq!(params.output_mode, Diff3OutputMode::EasyOnly); + } + + #[test] + fn test_compute_diff3_identical_files() { + let params = Diff3Params { + executable: OsString::from("diff3"), + mine: OsString::from("file1"), + older: OsString::from("file2"), + yours: OsString::from("file3"), + format: Diff3Format::Normal, + output_mode: Diff3OutputMode::All, + text: false, + labels: [None, None, None], + strip_trailing_cr: false, + initial_tab: false, + compat_i: false, + }; + + let content = b"line1\nline2\nline3\n"; + let (output, has_conflicts) = compute_diff3(content, content, content, ¶ms); + + // Identical files should produce no output + assert!(output.is_empty()); + assert!(!has_conflicts); + } + + #[test] + fn test_compute_diff3_with_changes() { + let params = Diff3Params { + executable: OsString::from("diff3"), + mine: OsString::from("file1"), + older: OsString::from("file2"), + yours: OsString::from("file3"), + format: Diff3Format::Normal, + output_mode: Diff3OutputMode::All, + text: false, + labels: [None, None, None], + strip_trailing_cr: false, + initial_tab: false, + compat_i: false, + }; + + let mine = b"line1\nmodified\nline3\n"; + let older = b"line1\nline2\nline3\n"; + let yours = b"line1\nline2\nline3\n"; + + let (output, _has_conflicts) = compute_diff3(mine, older, yours, ¶ms); + + // Should have some output indicating differences + assert!(!output.is_empty()); + } + + #[test] + fn test_compute_diff3_merged_format() { + let params = Diff3Params { + executable: OsString::from("diff3"), + mine: OsString::from("file1"), + older: OsString::from("file2"), + yours: OsString::from("file3"), + format: Diff3Format::Merged, + output_mode: Diff3OutputMode::All, + text: false, + labels: [Some("mine".to_string()), None, Some("yours".to_string())], + strip_trailing_cr: false, + initial_tab: false, + compat_i: false, + }; + + let mine = b"line1\nmine_version\nline3\n"; + let older = b"line1\noriginal\nline3\n"; + let yours = b"line1\nyours_version\nline3\n"; + + let (output, _has_conflicts) = compute_diff3(mine, older, yours, ¶ms); + + let output_str = String::from_utf8_lossy(&output); + + // Merged format with conflicts should contain conflict markers + assert!(output_str.contains("<<<<<<<") || !output.is_empty()); + } + + #[test] + fn test_compute_diff3_identical_lines() { + let params = Diff3Params { + executable: OsString::from("diff3"), + mine: OsString::from("file1"), + older: OsString::from("file2"), + yours: OsString::from("file3"), + format: Diff3Format::Merged, + output_mode: Diff3OutputMode::All, + text: false, + labels: [None, None, None], + strip_trailing_cr: false, + initial_tab: false, + compat_i: false, + }; + + // When mine and yours are identical, should pass through unchanged + let content = b"line1\nline2\nline3\n"; + let (output, _has_conflicts) = compute_diff3(content, b"different\n", content, ¶ms); + + let output_str = String::from_utf8_lossy(&output); + assert!(output_str.contains("line1")); + assert!(output_str.contains("line2")); + assert!(output_str.contains("line3")); + } + + #[test] + fn test_compute_diff3_empty_files() { + let params = Diff3Params { + executable: OsString::from("diff3"), + mine: OsString::from("file1"), + older: OsString::from("file2"), + yours: OsString::from("file3"), + format: Diff3Format::Normal, + output_mode: Diff3OutputMode::All, + text: false, + labels: [None, None, None], + strip_trailing_cr: false, + initial_tab: false, + compat_i: false, + }; + + let (output, has_conflicts) = compute_diff3(b"", b"", b"", ¶ms); + + assert!(output.is_empty()); + assert!(!has_conflicts); + } + + #[test] + fn test_compute_diff3_single_line_files() { + let params = Diff3Params { + executable: OsString::from("diff3"), + mine: OsString::from("file1"), + older: OsString::from("file2"), + yours: OsString::from("file3"), + format: Diff3Format::Normal, + output_mode: Diff3OutputMode::All, + text: false, + labels: [None, None, None], + strip_trailing_cr: false, + initial_tab: false, + compat_i: false, + }; + + let mine = b"line1\n"; + let older = b"line1\n"; + let yours = b"different\n"; + + let (output, _has_conflicts) = compute_diff3(mine, older, yours, ¶ms); + + // Should produce output for the difference + assert!(!output.is_empty()); + } + + #[test] + fn test_detect_conflicts() { + // Test conflict detection logic + let diff_mine_older: Vec> = vec![]; + let diff_older_yours: Vec> = vec![]; + let diff_mine_yours: Vec> = vec![]; + + let result = detect_conflicts( + &diff_mine_older, + &diff_older_yours, + &diff_mine_yours, + &[b"line1"], + &[b"line1"], + &[b"line1"], + ); + + // Identical files should have no conflicts + assert!(!result); + } + + #[test] + fn test_conflict_type_no_conflict() { + assert_eq!(ConflictType::NoConflict, ConflictType::NoConflict); + } + + #[test] + fn test_conflict_type_easy_conflict() { + assert_eq!(ConflictType::EasyConflict, ConflictType::EasyConflict); + } + + #[test] + fn test_conflict_type_overlapping_conflict() { + assert_eq!( + ConflictType::OverlappingConflict, + ConflictType::OverlappingConflict + ); + } + + #[test] + fn test_conflict_type_non_overlapping() { + assert_eq!(ConflictType::NonOverlapping, ConflictType::NonOverlapping); + } + + #[test] + fn test_conflict_type_inequality() { + assert_ne!(ConflictType::NoConflict, ConflictType::EasyConflict); + assert_ne!( + ConflictType::EasyConflict, + ConflictType::OverlappingConflict + ); + assert_ne!(ConflictType::NonOverlapping, ConflictType::NoConflict); + } + + #[test] + fn test_analyze_overlap_empty_regions() { + let regions = vec![]; + let (easy, overlapping, non_overlapping) = analyze_overlap(®ions); + assert_eq!(easy, 0); + assert_eq!(overlapping, 0); + assert_eq!(non_overlapping, 0); + } + + #[test] + fn test_analyze_overlap_all_easy_conflicts() { + let regions = vec![ + Diff3Region { + mine_start: 0, + mine_count: 1, + older_start: 0, + older_count: 1, + yours_start: 0, + yours_count: 1, + conflict: ConflictType::EasyConflict, + }, + Diff3Region { + mine_start: 1, + mine_count: 1, + older_start: 1, + older_count: 1, + yours_start: 1, + yours_count: 1, + conflict: ConflictType::EasyConflict, + }, + ]; + let (easy, overlapping, non_overlapping) = analyze_overlap(®ions); + assert_eq!(easy, 2); + assert_eq!(overlapping, 0); + assert_eq!(non_overlapping, 0); + } + + #[test] + fn test_analyze_overlap_all_overlapping() { + let regions = vec![ + Diff3Region { + mine_start: 0, + mine_count: 2, + older_start: 0, + older_count: 1, + yours_start: 0, + yours_count: 3, + conflict: ConflictType::OverlappingConflict, + }, + ]; + let (easy, overlapping, non_overlapping) = analyze_overlap(®ions); + assert_eq!(easy, 0); + assert_eq!(overlapping, 1); + assert_eq!(non_overlapping, 0); + } + + #[test] + fn test_analyze_overlap_mixed_conflicts() { + let regions = vec![ + Diff3Region { + mine_start: 0, + mine_count: 1, + older_start: 0, + older_count: 1, + yours_start: 0, + yours_count: 1, + conflict: ConflictType::EasyConflict, + }, + Diff3Region { + mine_start: 1, + mine_count: 1, + older_start: 1, + older_count: 1, + yours_start: 1, + yours_count: 1, + conflict: ConflictType::OverlappingConflict, + }, + Diff3Region { + mine_start: 2, + mine_count: 1, + older_start: 2, + older_count: 1, + yours_start: 2, + yours_count: 1, + conflict: ConflictType::NonOverlapping, + }, + Diff3Region { + mine_start: 3, + mine_count: 1, + older_start: 3, + older_count: 1, + yours_start: 3, + yours_count: 1, + conflict: ConflictType::NoConflict, + }, + ]; + let (easy, overlapping, non_overlapping) = analyze_overlap(®ions); + assert_eq!(easy, 1); + assert_eq!(overlapping, 1); + assert_eq!(non_overlapping, 1); + } + + #[test] + fn test_has_only_easy_conflicts_true() { + let regions = vec![ + Diff3Region { + mine_start: 0, + mine_count: 1, + older_start: 0, + older_count: 1, + yours_start: 0, + yours_count: 1, + conflict: ConflictType::EasyConflict, + }, + Diff3Region { + mine_start: 1, + mine_count: 1, + older_start: 1, + older_count: 1, + yours_start: 1, + yours_count: 1, + conflict: ConflictType::NoConflict, + }, + ]; + assert!(has_only_easy_conflicts(®ions)); + } + + #[test] + fn test_has_only_easy_conflicts_false() { + let regions = vec![ + Diff3Region { + mine_start: 0, + mine_count: 1, + older_start: 0, + older_count: 1, + yours_start: 0, + yours_count: 1, + conflict: ConflictType::EasyConflict, + }, + Diff3Region { + mine_start: 1, + mine_count: 2, + older_start: 1, + older_count: 1, + yours_start: 1, + yours_count: 3, + conflict: ConflictType::OverlappingConflict, + }, + ]; + assert!(!has_only_easy_conflicts(®ions)); + } + + #[test] + fn test_has_only_easy_conflicts_empty() { + let regions = vec![]; + // Empty regions should return true (vacuously true) + assert!(has_only_easy_conflicts(®ions)); + } + + #[test] + fn test_has_overlapping_conflicts_true() { + let regions = vec![ + Diff3Region { + mine_start: 0, + mine_count: 1, + older_start: 0, + older_count: 1, + yours_start: 0, + yours_count: 1, + conflict: ConflictType::EasyConflict, + }, + Diff3Region { + mine_start: 1, + mine_count: 2, + older_start: 1, + older_count: 1, + yours_start: 1, + yours_count: 3, + conflict: ConflictType::OverlappingConflict, + }, + ]; + assert!(has_overlapping_conflicts(®ions)); + } + + #[test] + fn test_has_overlapping_conflicts_false() { + let regions = vec![ + Diff3Region { + mine_start: 0, + mine_count: 1, + older_start: 0, + older_count: 1, + yours_start: 0, + yours_count: 1, + conflict: ConflictType::EasyConflict, + }, + Diff3Region { + mine_start: 1, + mine_count: 1, + older_start: 1, + older_count: 1, + yours_start: 1, + yours_count: 1, + conflict: ConflictType::NoConflict, + }, + ]; + assert!(!has_overlapping_conflicts(®ions)); + } + + #[test] + fn test_has_overlapping_conflicts_empty() { + let regions = vec![]; + assert!(!has_overlapping_conflicts(®ions)); + } + + #[test] + fn test_diff3_region_construction() { + let region = Diff3Region { + mine_start: 10, + mine_count: 5, + older_start: 12, + older_count: 3, + yours_start: 15, + yours_count: 7, + conflict: ConflictType::OverlappingConflict, + }; + + assert_eq!(region.mine_start, 10); + assert_eq!(region.mine_count, 5); + assert_eq!(region.older_start, 12); + assert_eq!(region.older_count, 3); + assert_eq!(region.yours_start, 15); + assert_eq!(region.yours_count, 7); + assert_eq!(region.conflict, ConflictType::OverlappingConflict); + } + + #[test] + fn test_diff3_region_cloning() { + let region1 = Diff3Region { + mine_start: 0, + mine_count: 1, + older_start: 0, + older_count: 1, + yours_start: 0, + yours_count: 1, + conflict: ConflictType::NoConflict, + }; + let region2 = region1.clone(); + + assert_eq!(region1.mine_start, region2.mine_start); + assert_eq!(region1.conflict, region2.conflict); + } + + #[test] + fn test_parse_params_with_compat_i_flag() { + let args = vec![ + OsString::from("diff3"), + OsString::from("-i"), + OsString::from("file1"), + OsString::from("file2"), + OsString::from("file3"), + ] + .into_iter() + .peekable(); + + let params = parse_params(args).expect("Failed to parse params"); + assert!(params.compat_i); + assert_eq!(params.format, Diff3Format::Normal); + } + + #[test] + fn test_parse_params_ed_with_compat_i() { + let args = vec![ + OsString::from("diff3"), + OsString::from("-e"), + OsString::from("-i"), + OsString::from("file1"), + OsString::from("file2"), + OsString::from("file3"), + ] + .into_iter() + .peekable(); + + let params = parse_params(args).expect("Failed to parse params"); + assert!(params.compat_i); + assert_eq!(params.format, Diff3Format::Ed); + assert_eq!(params.output_mode, Diff3OutputMode::EdScript); + } + + #[test] + fn test_compute_diff3_ed_with_compat_i() { + let params = Diff3Params { + executable: OsString::from("diff3"), + mine: OsString::from("file1"), + older: OsString::from("file2"), + yours: OsString::from("file3"), + format: Diff3Format::Ed, + output_mode: Diff3OutputMode::EdScript, + text: false, + labels: [None, None, None], + strip_trailing_cr: false, + initial_tab: false, + compat_i: true, // Enable -i option + }; + + let mine = b"line1\noriginal\nline3\n"; + let older = b"line1\noriginal\nline3\n"; + let yours = b"line1\nmodified\nline3\n"; + + let (output, _has_conflicts) = compute_diff3(mine, older, yours, ¶ms); + + let output_str = String::from_utf8_lossy(&output); + // With -i, output should contain write (w) and quit (q) commands + assert!(output_str.contains("w\n") || output_str.contains("w"), "Output should contain write command"); + assert!(output_str.contains("q\n") || output_str.contains("q"), "Output should contain quit command"); + } + + #[test] + fn test_compute_diff3_ed_without_compat_i() { + let params = Diff3Params { + executable: OsString::from("diff3"), + mine: OsString::from("file1"), + older: OsString::from("file2"), + yours: OsString::from("file3"), + format: Diff3Format::Ed, + output_mode: Diff3OutputMode::EdScript, + text: false, + labels: [None, None, None], + strip_trailing_cr: false, + initial_tab: false, + compat_i: false, // Disable -i option + }; + + let mine = b"line1\noriginal\nline3\n"; + let older = b"line1\noriginal\nline3\n"; + let yours = b"line1\nmodified\nline3\n"; + + let (output, _has_conflicts) = compute_diff3(mine, older, yours, ¶ms); + + let output_str = String::from_utf8_lossy(&output); + // Without -i, output should NOT contain write (w) and quit (q) at the end + // (It may contain them in the middle but not as final commands) + let lines: Vec<&str> = output_str.lines().collect(); + if !lines.is_empty() { + let last_line = lines[lines.len() - 1]; + assert_ne!(last_line, "q", "Last line should not be 'q' without -i flag"); + } + } + + #[test] + fn test_is_binary_content_with_null_bytes() { + // Binary content with null bytes + let binary = b"GIF89a\x00\x00\x00\x00"; + assert!(is_binary_content(binary), "Should detect null bytes as binary"); + } + + #[test] + fn test_is_binary_content_text_only() { + // Plain text content + let text = b"This is plain text\nwith multiple lines\n"; + assert!(!is_binary_content(text), "Should not detect plain text as binary"); + } + + #[test] + fn test_is_binary_content_with_control_chars() { + // Content with excessive control characters + let binary = vec![0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0xFF, 0xFE]; + assert!(is_binary_content(&binary), "Should detect control-heavy content as binary"); + } + + #[test] + fn test_is_binary_content_empty() { + // Empty content should not be treated as binary + let empty: &[u8] = b""; + assert!(!is_binary_content(empty), "Empty content should not be binary"); + } + + #[test] + fn test_compute_diff3_with_binary_files() { + let params = Diff3Params { + executable: OsString::from("diff3"), + mine: OsString::from("mine.bin"), + older: OsString::from("older.bin"), + yours: OsString::from("yours.bin"), + format: Diff3Format::Normal, + output_mode: Diff3OutputMode::All, + text: false, // Binary detection enabled + labels: [None, None, None], + strip_trailing_cr: false, + initial_tab: false, + compat_i: false, + }; + + // Binary files with null bytes + let mine = b"GIF89a\x00\x10\x00\x10"; + let older = b"GIF89a\x00\x10\x00\x10"; + let yours = b"PNG\x89\x50\x4E\x47\x0D"; + + let (output, has_conflicts) = compute_diff3(mine, older, yours, ¶ms); + + // Should report binary file differences + let output_str = String::from_utf8_lossy(&output); + assert!(output_str.contains("Binary files") || output_str.is_empty(), + "Should report binary differences or be empty"); + // Different binary files should have conflicts + assert!(has_conflicts, "Different binary files should be detected as conflicts"); + } + + #[test] + fn test_compute_diff3_binary_identical() { + let params = Diff3Params { + executable: OsString::from("diff3"), + mine: OsString::from("mine.bin"), + older: OsString::from("older.bin"), + yours: OsString::from("yours.bin"), + format: Diff3Format::Normal, + output_mode: Diff3OutputMode::All, + text: false, // Binary detection enabled + labels: [None, None, None], + strip_trailing_cr: false, + initial_tab: false, + compat_i: false, + }; + + // Identical binary files + let content = b"GIF89a\x00\x10\x00\x10\xFF\xFF\xFF"; + + let (output, has_conflicts) = compute_diff3(content, content, content, ¶ms); + + // Identical files should have no output and no conflicts + assert!(output.is_empty(), "Identical binary files should produce no output"); + assert!(!has_conflicts, "Identical binary files should have no conflicts"); + } + + #[test] + fn test_text_flag_forces_text_mode() { + let params = Diff3Params { + executable: OsString::from("diff3"), + mine: OsString::from("mine"), + older: OsString::from("older"), + yours: OsString::from("yours"), + format: Diff3Format::Normal, + output_mode: Diff3OutputMode::All, + text: true, // Force text mode even for binary-looking content + labels: [None, None, None], + strip_trailing_cr: false, + initial_tab: false, + compat_i: false, + }; + + // Content with null byte (normally binary) + let mine = b"line1\x00\nline2\n"; + let older = b"line1\x00\nline2\n"; + let yours = b"line1\x00\nline3\n"; + + let (output, _has_conflicts) = compute_diff3(mine, older, yours, ¶ms); + + // With --text flag, should process as text, not report as binary + let output_str = String::from_utf8_lossy(&output); + // Should not be "Binary files differ" - instead should process as text + if !output.is_empty() { + assert!(!output_str.contains("Binary files differ"), + "Should not report binary when --text flag is set"); + } + } +} + + + + diff --git a/src/lib.rs b/src/lib.rs index 342b01c..ceb81d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod cmp; pub mod context_diff; +pub mod diff3; pub mod ed_diff; pub mod macros; pub mod normal_diff; @@ -10,6 +11,7 @@ pub mod utils; // Re-export the public functions/types you need pub use context_diff::diff as context_diff; +pub use diff3::main as diff3; pub use ed_diff::diff as ed_diff; pub use normal_diff::diff as normal_diff; pub use side_diff::diff as side_by_side_diff; diff --git a/src/main.rs b/src/main.rs index b7c2712..9b6c2a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ use std::{ mod cmp; mod context_diff; mod diff; +mod diff3; mod ed_diff; mod macros; mod normal_diff; @@ -43,7 +44,7 @@ fn usage(name: &str) { println!("{name} {VERSION} (multi-call binary)\n"); println!("Usage: {name} [function [arguments...]]\n"); println!("Currently defined functions:\n"); - println!(" cmp, diff\n"); + println!(" cmp, diff, diff3\n"); } fn second_arg_error(name: &OsStr) -> ! { @@ -71,6 +72,7 @@ fn main() -> ExitCode { match util_name.to_str() { Some("diff") => diff::main(args), + Some("diff3") => diff3::main(args), Some("cmp") => cmp::main(args), Some(name) => { eprintln!("{name}: utility not supported"); diff --git a/tests/integration.rs b/tests/integration.rs index 476660f..ec59c41 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -872,3 +872,686 @@ mod cmp { Ok(()) } } + +mod diff3 { + use super::*; + + #[test] + fn diff3_identical_files() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\nline2\nline3\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\nline2\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\nline2\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + cmd.assert() + .code(predicate::eq(0)) + .success() + .stdout(predicate::eq("")); + + Ok(()) + } + + #[test] + fn diff3_with_changes() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\nmodified\nline3\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\nline2\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\nline2\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + cmd.assert() + .code(predicate::eq(0)) + .success(); + + Ok(()) + } + + #[test] + fn diff3_merged_format() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\nmine_version\nline3\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\noriginal\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\nyours_version\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-m"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + cmd.assert() + .code(predicate::eq(1)) + .failure(); + + Ok(()) + } + + #[test] + fn diff3_ed_format() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\nline2\nline3\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\nline2\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\nmodified\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-e"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + cmd.assert() + .code(predicate::eq(0)) + .success(); + + Ok(()) + } + + #[test] + fn diff3_with_text_flag() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\nline2\nline3\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\nline2\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\nline2\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-a"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + cmd.assert() + .code(predicate::eq(0)) + .success() + .stdout(predicate::eq("")); + + Ok(()) + } + + #[test] + fn diff3_with_labels() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\nmine_version\nline3\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\noriginal\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\nyours_version\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-m"); + cmd.arg("--label=mine_version"); + cmd.arg("--label=original"); + cmd.arg("--label=yours_version"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + cmd.assert() + .code(predicate::eq(1)) + .failure(); + + Ok(()) + } + + #[test] + fn diff3_easy_only() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\nmodified\nline3\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\noriginal\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\noriginal\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-3"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + cmd.assert() + .code(predicate::eq(0)) + .success(); + + Ok(()) + } + + #[test] + fn diff3_missing_file() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"content\n")?; + + let nofile = tmp_dir.path().join("nonexistent"); + + #[cfg(not(windows))] + let error_message = "No such file or directory"; + #[cfg(windows)] + let error_message = "The system cannot find the file specified."; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg(&mine_path).arg(&nofile).arg(&mine_path); + cmd.assert() + .code(predicate::eq(2)) + .failure() + .stderr(predicate::str::contains(error_message)); + + Ok(()) + } + + #[test] + fn diff3_stdin() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\nline2\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\nline2\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-").arg(&older_path).arg(&yours_path); + cmd.write_stdin(b"line1\nline2\nline3\n"); + cmd.assert() + .code(predicate::eq(0)) + .success(); + + Ok(()) + } + + #[test] + fn diff3_show_all_flag() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\nmine_version\nline3\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\noriginal\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\nyours_version\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-A"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + cmd.assert() + .code(predicate::eq(1)) + .failure(); + + Ok(()) + } + + #[test] + fn diff3_three_way_conflict() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + // All three files have different content at line 2 + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\nversion_a\nline3\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\noriginal\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\nversion_b\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-m"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + cmd.assert() + .code(predicate::eq(1)) + .failure(); + + Ok(()) + } + + #[test] + fn diff3_overlap_only_option() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + // Create files where all three differ (overlapping conflict) + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\nversion_a\nline3\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\noriginal\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\nversion_b\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-x"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + cmd.assert() + .code(predicate::eq(1)) + .failure(); + + Ok(()) + } + + #[test] + fn diff3_easy_only_option() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + // Create files where only yours changed (easy conflict) + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\noriginal\nline3\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\noriginal\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\nmodified\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-3"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + cmd.assert() + .code(predicate::eq(0)) + .success(); + + Ok(()) + } + + #[test] + fn diff3_merged_with_overlap_only() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + // Create files where all three differ (overlapping conflict) + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\nversion_a\nline3\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\noriginal\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\nversion_b\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-m"); + cmd.arg("-X"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + // -X shows only overlapping conflicts with markers, which exists in this case + cmd.assert() + .code(predicate::eq(1)) + .failure(); + + Ok(()) + } + + #[test] + fn diff3_ed_with_compat_i_flag() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\noriginal\nline3\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\noriginal\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\nmodified\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-e"); + cmd.arg("-i"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + cmd.assert() + .code(predicate::eq(0)) + .success() + .stdout(predicate::str::contains("w").or(predicate::str::contains("q"))); + + Ok(()) + } + + #[test] + fn diff3_ed_without_compat_i_flag() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\noriginal\nline3\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\noriginal\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\nmodified\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-e"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + cmd.assert() + .code(predicate::eq(0)) + .success(); + + Ok(()) + } + + #[test] + fn diff3_ed_compat_i_with_conflict() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\nmine_version\nline3\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\noriginal\nline3\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\nyours_version\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-e"); + cmd.arg("-i"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + // Even with conflicts, -e -i should produce a valid ed script with w and q + cmd.assert() + .code(predicate::eq(1)) + .failure(); + + Ok(()) + } + + #[test] + fn diff3_identical_large_files() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + // Create identical large files (10,000 lines each) + let mut large_content = String::new(); + for i in 0..10000 { + large_content.push_str(&format!("line {}\n", i)); + } + let content_bytes = large_content.as_bytes(); + + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(content_bytes)?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(content_bytes)?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(content_bytes)?; + + // With identical files, output should be empty + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + cmd.assert().code(0).stdout(""); + + Ok(()) + } + + #[test] + fn diff3_large_file_with_single_line_change() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + // Create large file with single change in both mine and yours differently + let mut older_content = String::new(); + let mut mine_content = String::new(); + let mut yours_content = String::new(); + for i in 0..10000 { + let line = format!("line {}\n", i); + older_content.push_str(&line); + + if i == 5000 { + mine_content.push_str("line 5000 MINE\n"); + yours_content.push_str("line 5000 YOURS\n"); + } else { + mine_content.push_str(&line); + yours_content.push_str(&line); + } + } + + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(mine_content.as_bytes())?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(older_content.as_bytes())?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(yours_content.as_bytes())?; + + // Should detect the conflict in line 5000 + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + cmd.assert().code(1); // Has conflict + + Ok(()) + } + + #[test] + fn diff3_large_file_merged_format() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + // Create large files with changes in different sections + let mut mine_content = String::new(); + let mut older_content = String::new(); + let mut yours_content = String::new(); + + for i in 0..5000 { + let line = format!("line {}\n", i); + older_content.push_str(&line); + + if i < 2500 { + mine_content.push_str(&line); + yours_content.push_str(&line); + } else if i < 3750 { + mine_content.push_str(&format!("mine {}\n", i)); + yours_content.push_str(&line); + } else { + mine_content.push_str(&line); + yours_content.push_str(&format!("yours {}\n", i)); + } + } + + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(mine_content.as_bytes())?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(older_content.as_bytes())?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(yours_content.as_bytes())?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-m"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + // Should produce merged output with some conflicts + cmd.assert().code(1); + + Ok(()) + } + + #[test] + fn diff3_binary_files_with_null_bytes() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + // Create binary files with null bytes (like image files) + let mine_path = tmp_dir.path().join("mine.bin"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"GIF89a\x00\x10\x00\x10\xFF\xFF\xFF")?; + + let older_path = tmp_dir.path().join("older.bin"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"GIF89a\x00\x10\x00\x10\xFF\xFF\xFF")?; + + let yours_path = tmp_dir.path().join("yours.bin"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"PNG\x89\x50\x4E\x47\x0D\x0A\x1A\x0A")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + // Binary files should be detected and reported as different + cmd.assert().code(1); + + Ok(()) + } + + #[test] + fn diff3_identical_binary_files() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + let content = b"GIF89a\x00\x10\x00\x10\xFF\xFF\xFF"; + + let mine_path = tmp_dir.path().join("mine.bin"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(content)?; + + let older_path = tmp_dir.path().join("older.bin"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(content)?; + + let yours_path = tmp_dir.path().join("yours.bin"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(content)?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + // Identical binary files should produce no output + cmd.assert().code(0).stdout(""); + + Ok(()) + } + + #[test] + fn diff3_binary_files_with_text_flag() -> Result<(), Box> { + let tmp_dir = tempdir()?; + + // Create files with null bytes but use --text to force text processing + let mine_path = tmp_dir.path().join("mine"); + let mut mine_file = File::create(&mine_path)?; + mine_file.write_all(b"line1\x00\nline2\n")?; + + let older_path = tmp_dir.path().join("older"); + let mut older_file = File::create(&older_path)?; + older_file.write_all(b"line1\x00\nline2\n")?; + + let yours_path = tmp_dir.path().join("yours"); + let mut yours_file = File::create(&yours_path)?; + yours_file.write_all(b"line1\x00\nline3\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff3"); + cmd.arg("-a"); // --text flag to force text mode + cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); + // With --text flag, should process as text despite null byte + // The output should show differences but not report as "Binary files differ" + let output = cmd.output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + // Should not contain "Binary files differ" message + assert!(!stdout.contains("Binary files differ"), + "Should not report binary when --text flag is used"); + // Should have changes detected (exit code 0 or 1 depending on conflicts) + assert!(output.status.code() == Some(0) || output.status.code() == Some(1), + "Exit code should indicate success or conflicts"); + + Ok(()) + } +} + + + From 81eecf606b726a46531b2f95ab11e28a96ffe252 Mon Sep 17 00:00:00 2001 From: Rodolfo Gatti Date: Sun, 14 Dec 2025 11:27:05 -0300 Subject: [PATCH 2/3] clippy: fix lints in diff3.rs --- src/diff3.rs | 85 +++++++++++++++++----------------------------------- 1 file changed, 28 insertions(+), 57 deletions(-) diff --git a/src/diff3.rs b/src/diff3.rs index 1309e1b..4391247 100644 --- a/src/diff3.rs +++ b/src/diff3.rs @@ -31,7 +31,7 @@ pub enum Diff3OutputMode { EasyOnly, // -3: output only non-overlapping changes } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct Diff3Params { pub executable: OsString, pub mine: OsString, @@ -46,23 +46,7 @@ pub struct Diff3Params { pub compat_i: bool, // -i option for ed script compatibility } -impl Default for Diff3Params { - fn default() -> Self { - Self { - executable: OsString::default(), - mine: OsString::default(), - older: OsString::default(), - yours: OsString::default(), - format: Diff3Format::default(), - output_mode: Diff3OutputMode::default(), - text: false, - labels: [None, None, None], - strip_trailing_cr: false, - initial_tab: false, - compat_i: false, - } - } -} +// Default is derived above pub fn parse_params>(mut opts: Peekable) -> Result { let Some(executable) = opts.next() else { @@ -78,6 +62,7 @@ pub fn parse_params>(mut opts: Peekable) -> Resu let mut yours = None; let mut label_count = 0; + #[allow(clippy::while_let_on_iterator)] while let Some(param) = opts.next() { let param_str = param.to_string_lossy(); @@ -169,11 +154,11 @@ pub fn parse_params>(mut opts: Peekable) -> Resu label_count += 1; continue; } - if param_str.starts_with("--label=") { + if let Some(stripped) = param_str.strip_prefix("--label=") { if label_count >= 3 { return Err("Too many labels".to_string()); } - let label = param_str[8..].to_string(); + let label = stripped.to_string(); params.labels[label_count] = Some(label); label_count += 1; continue; @@ -209,7 +194,7 @@ pub fn parse_params>(mut opts: Peekable) -> Resu } // Collect remaining arguments - while let Some(param) = opts.next() { + for param in opts { if mine.is_none() { mine = Some(param); } else if older.is_none() { @@ -609,9 +594,9 @@ fn build_conflict_regions( } } else if has_mine_changes || has_yours_changes { // Only one side changed - check if it differs from older - if has_mine_changes && mine_lines.len() != older_lines.len() { - ConflictType::EasyConflict - } else if has_yours_changes && yours_lines.len() != older_lines.len() { + if (has_mine_changes && mine_lines.len() != older_lines.len()) + || (has_yours_changes && yours_lines.len() != older_lines.len()) + { ConflictType::EasyConflict } else { ConflictType::NoConflict @@ -622,7 +607,7 @@ fn build_conflict_regions( }; // Create a single region representing the whole file - if mine_lines.len() > 0 || older_lines.len() > 0 || yours_lines.len() > 0 { + if !mine_lines.is_empty() || !older_lines.is_empty() || !yours_lines.is_empty() { regions.push(Diff3Region { mine_start: 0, mine_count: mine_lines.len(), @@ -691,6 +676,7 @@ fn should_include_region(region: &Diff3Region, output_mode: Diff3OutputMode) -> } } +#[allow(clippy::too_many_arguments)] fn generate_normal_output( _diff_mine_older: &[diff::Result<&&[u8]>], _diff_older_yours: &[diff::Result<&&[u8]>], @@ -706,35 +692,21 @@ fn generate_normal_output( // Generate diff3 normal format output // For now, generate simple diff output between mine and yours for line_num in 0..mine_lines.len().max(yours_lines.len()) { - if line_num < mine_lines.len() && line_num < yours_lines.len() { - if mine_lines[line_num] != yours_lines[line_num] { - writeln!( - &mut output, - "{}c{}", - line_num + 1, - line_num + 1 - ) - .unwrap(); - writeln!( - &mut output, - "< {}", - String::from_utf8_lossy(mine_lines[line_num]) - ) - .unwrap(); - writeln!(&mut output, "---").unwrap(); - writeln!( - &mut output, - "> {}", - String::from_utf8_lossy(yours_lines[line_num]) - ) - .unwrap(); - } + if line_num < mine_lines.len() + && line_num < yours_lines.len() + && mine_lines[line_num] != yours_lines[line_num] + { + writeln!(&mut output, "{}c{}", line_num + 1, line_num + 1).unwrap(); + writeln!(&mut output, "< {}", String::from_utf8_lossy(mine_lines[line_num])).unwrap(); + writeln!(&mut output, "---").unwrap(); + writeln!(&mut output, "> {}", String::from_utf8_lossy(yours_lines[line_num])).unwrap(); } } output } +#[allow(clippy::too_many_arguments)] fn generate_merged_output( _diff_mine_older: &[diff::Result<&&[u8]>], _diff_older_yours: &[diff::Result<&&[u8]>], @@ -763,7 +735,7 @@ fn generate_merged_output( // If filtering, check if this region should be included let region = regions.first(); - if should_filter && region.map_or(false, |r| !should_include_region(r, params.output_mode)) { + if should_filter && region.is_some_and(|r| !should_include_region(r, params.output_mode)) { // Output nothing if region doesn't match filter return output; } @@ -782,12 +754,12 @@ fn generate_merged_output( .unwrap(); } else { // Only output conflict if it matches the filter - if !should_filter || region.map_or(true, |r| should_include_region(r, params.output_mode)) { + if !should_filter || region.is_none_or(|r| should_include_region(r, params.output_mode)) { // Conflict with optional markers based on output mode match params.output_mode { Diff3OutputMode::OverlapOnlyMarked => { // Show conflict markers for overlapping conflicts - if region.map_or(false, |r| r.conflict == ConflictType::OverlappingConflict) { + if region.is_some_and(|r| r.conflict == ConflictType::OverlappingConflict) { writeln!(&mut output, "<<<<<<< {}", mine_label).unwrap(); writeln!( &mut output, @@ -850,6 +822,7 @@ fn generate_merged_output( output } +#[allow(clippy::too_many_arguments)] fn generate_ed_script( _diff_mine_older: &[diff::Result<&&[u8]>], _diff_older_yours: &[diff::Result<&&[u8]>], @@ -878,7 +851,7 @@ fn generate_ed_script( // If filtering, check if this region should be included let region = regions.first(); - if should_filter && region.map_or(false, |r| !should_include_region(r, params.output_mode)) { + if should_filter && region.is_some_and(|r| !should_include_region(r, params.output_mode)) { // Output nothing if region doesn't match filter return output; } @@ -893,7 +866,7 @@ fn generate_ed_script( (Some(mine), Some(yours)) => { if mine != yours { // Only output if it matches the filter - if !should_filter || region.map_or(true, |r| should_include_region(r, params.output_mode)) { + if !should_filter || region.is_none_or(|r| should_include_region(r, params.output_mode)) { // Change command writeln!(&mut output, "{}c", line_num + 1).unwrap(); writeln!( @@ -922,13 +895,13 @@ fn generate_ed_script( } (Some(_), None) => { // Delete command (only if not filtering or filter passes) - if !should_filter || region.map_or(true, |r| should_include_region(r, params.output_mode)) { + if !should_filter || region.is_none_or(|r| should_include_region(r, params.output_mode)) { writeln!(&mut output, "{}d", line_num + 1).unwrap(); } } (None, Some(yours)) => { // Add command (only if not filtering or filter passes) - if !should_filter || region.map_or(true, |r| should_include_region(r, params.output_mode)) { + if !should_filter || region.is_none_or(|r| should_include_region(r, params.output_mode)) { writeln!(&mut output, "{}a", line_num).unwrap(); writeln!( &mut output, @@ -1012,8 +985,6 @@ pub fn main(opts: Peekable) -> ExitCode { if has_conflicts { ExitCode::from(1) - } else if result.is_empty() { - ExitCode::SUCCESS } else { ExitCode::SUCCESS } From 83380d10b0322ec52bfbc2528f485ff4a562d5d0 Mon Sep 17 00:00:00 2001 From: Rodolfo Gatti Date: Sun, 14 Dec 2025 12:17:53 -0300 Subject: [PATCH 3/3] fix lints and apply rustfmt to diff3.rs --- src/diff3.rs | 301 +++++++++++++++++++++++-------------------- tests/integration.rs | 75 ++++------- 2 files changed, 183 insertions(+), 193 deletions(-) diff --git a/src/diff3.rs b/src/diff3.rs index 4391247..aa7056a 100644 --- a/src/diff3.rs +++ b/src/diff3.rs @@ -23,12 +23,12 @@ pub enum Diff3Format { #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum Diff3OutputMode { #[default] - All, // -A: show all changes with conflict markers - EdScript, // -e: output ed script - ShowOverlapEd, // -E: ed script with overlap markers - OverlapOnly, // -x: output only overlapping changes + All, // -A: show all changes with conflict markers + EdScript, // -e: output ed script + ShowOverlapEd, // -E: ed script with overlap markers + OverlapOnly, // -x: output only overlapping changes OverlapOnlyMarked, // -X: output only overlapping changes with markers - EasyOnly, // -3: output only non-overlapping changes + EasyOnly, // -3: output only non-overlapping changes } #[derive(Clone, Debug, Default)] @@ -48,7 +48,9 @@ pub struct Diff3Params { // Default is derived above -pub fn parse_params>(mut opts: Peekable) -> Result { +pub fn parse_params>( + mut opts: Peekable, +) -> Result { let Some(executable) = opts.next() else { return Err("Usage: mine older yours".to_string()); }; @@ -172,10 +174,7 @@ pub fn parse_params>(mut opts: Peekable) -> Resu exit(0); } - return Err(format!( - "Unknown option: \"{}\"", - param_str - )); + return Err(format!("Unknown option: \"{}\"", param_str)); } else { // Regular file argument if mine.is_none() { @@ -242,12 +241,12 @@ fn print_help(executable: &OsString) { #[derive(Debug, Clone)] #[allow(dead_code)] struct Diff3Block { - line_1: usize, // Line number in mine - lines_1: usize, // Number of lines in mine - line_2: usize, // Line number in older - lines_2: usize, // Number of lines in older - line_3: usize, // Line number in yours - lines_3: usize, // Number of lines in yours + line_1: usize, // Line number in mine + lines_1: usize, // Number of lines in mine + line_2: usize, // Line number in older + lines_2: usize, // Number of lines in older + line_3: usize, // Line number in yours + lines_3: usize, // Number of lines in yours } /// Fast content hash for quick equality checks on large files @@ -257,7 +256,7 @@ struct Diff3Block { fn compute_content_hash(data: &[u8]) -> u64 { const FNV_64_PRIME: u64 = 1099511628211; const FNV_64_OFFSET: u64 = 14695981039346656037; - + let mut hash = FNV_64_OFFSET; for &byte in data { hash ^= byte as u64; @@ -282,7 +281,7 @@ fn is_binary_content(content: &[u8]) -> bool { if content.is_empty() { return false; } - + // Check for null bytes (strong indicator of binary data) // Scan first 8KB (typical block size) for efficiency on large files let check_limit = std::cmp::min(content.len(), 8192); @@ -291,34 +290,33 @@ fn is_binary_content(content: &[u8]) -> bool { return true; } } - + // Additional heuristic: check for high proportion of non-text bytes // This helps detect binary formats that don't contain null bytes let mut non_text_count = 0; let sample_size = std::cmp::min(content.len(), 512); - + for &byte in &content[..sample_size] { // Non-text bytes are control characters (0-8, 14-31, 127) except common ones (9=tab, 10=LF, 13=CR) - if (byte < 9 || (byte > 13 && byte < 32) || byte == 127) && byte != 9 && byte != 10 && byte != 13 { + if (byte < 9 || (byte > 13 && byte < 32) || byte == 127) + && byte != 9 + && byte != 10 + && byte != 13 + { non_text_count += 1; } } - + // If more than 30% of sampled bytes are non-text, treat as binary if sample_size > 0 && (non_text_count * 100) / sample_size > 30 { return true; } - + false } // Main diff3 computation engine with performance optimizations -fn compute_diff3( - mine: &[u8], - older: &[u8], - yours: &[u8], - params: &Diff3Params, -) -> (Vec, bool) { +fn compute_diff3(mine: &[u8], older: &[u8], yours: &[u8], params: &Diff3Params) -> (Vec, bool) { // Early termination: check if all files are identical // This is the fastest path for the common case of no changes if are_files_identical(mine, older) && are_files_identical(older, yours) { @@ -334,27 +332,42 @@ fn compute_diff3( if mine_is_binary || older_is_binary || yours_is_binary { // For binary files, report if they differ and exit with appropriate code let all_identical = are_files_identical(mine, older) && are_files_identical(older, yours); - + if all_identical { return (Vec::new(), false); } else { let mut output = Vec::new(); - + // Report binary file differences in a format similar to GNU diff if mine_is_binary && older_is_binary && mine != older { - writeln!(&mut output, "Binary files {} and {} differ", - params.mine.to_string_lossy(), params.older.to_string_lossy()).unwrap(); + writeln!( + &mut output, + "Binary files {} and {} differ", + params.mine.to_string_lossy(), + params.older.to_string_lossy() + ) + .unwrap(); } if older_is_binary && yours_is_binary && older != yours { - writeln!(&mut output, "Binary files {} and {} differ", - params.older.to_string_lossy(), params.yours.to_string_lossy()).unwrap(); + writeln!( + &mut output, + "Binary files {} and {} differ", + params.older.to_string_lossy(), + params.yours.to_string_lossy() + ) + .unwrap(); } if mine_is_binary && yours_is_binary && mine != yours { - writeln!(&mut output, "Binary files {} and {} differ", - params.mine.to_string_lossy(), params.yours.to_string_lossy()).unwrap(); + writeln!( + &mut output, + "Binary files {} and {} differ", + params.mine.to_string_lossy(), + params.yours.to_string_lossy() + ) + .unwrap(); } - - return (output, true); // Has conflicts (binary differences) + + return (output, true); // Has conflicts (binary differences) } } @@ -644,9 +657,9 @@ fn analyze_overlap(regions: &[Diff3Region]) -> (usize, usize, usize) { /// Checks if only easy (non-overlapping) conflicts exist #[allow(dead_code)] fn has_only_easy_conflicts(regions: &[Diff3Region]) -> bool { - regions.iter().all(|r| { - r.conflict == ConflictType::NoConflict || r.conflict == ConflictType::EasyConflict - }) + regions + .iter() + .all(|r| r.conflict == ConflictType::NoConflict || r.conflict == ConflictType::EasyConflict) } /// Checks if overlapping (difficult) conflicts exist @@ -697,9 +710,19 @@ fn generate_normal_output( && mine_lines[line_num] != yours_lines[line_num] { writeln!(&mut output, "{}c{}", line_num + 1, line_num + 1).unwrap(); - writeln!(&mut output, "< {}", String::from_utf8_lossy(mine_lines[line_num])).unwrap(); + writeln!( + &mut output, + "< {}", + String::from_utf8_lossy(mine_lines[line_num]) + ) + .unwrap(); writeln!(&mut output, "---").unwrap(); - writeln!(&mut output, "> {}", String::from_utf8_lossy(yours_lines[line_num])).unwrap(); + writeln!( + &mut output, + "> {}", + String::from_utf8_lossy(yours_lines[line_num]) + ) + .unwrap(); } } @@ -720,12 +743,8 @@ fn generate_merged_output( let mut output = Vec::new(); // Get labels - let mine_label = params.labels[0] - .as_deref() - .unwrap_or("<<<<<<<"); - let yours_label = params.labels[2] - .as_deref() - .unwrap_or(">>>>>>>"); + let mine_label = params.labels[0].as_deref().unwrap_or("<<<<<<<"); + let yours_label = params.labels[2].as_deref().unwrap_or(">>>>>>>"); // Check if we should filter based on output mode let should_filter = !matches!( @@ -746,20 +765,19 @@ fn generate_merged_output( match (i < mine_lines.len(), i < yours_lines.len()) { (true, true) => { if mine_lines[i] == yours_lines[i] { - writeln!( - &mut output, - "{}", - String::from_utf8_lossy(mine_lines[i]) - ) - .unwrap(); + writeln!(&mut output, "{}", String::from_utf8_lossy(mine_lines[i])).unwrap(); } else { // Only output conflict if it matches the filter - if !should_filter || region.is_none_or(|r| should_include_region(r, params.output_mode)) { + if !should_filter + || region.is_none_or(|r| should_include_region(r, params.output_mode)) + { // Conflict with optional markers based on output mode match params.output_mode { Diff3OutputMode::OverlapOnlyMarked => { // Show conflict markers for overlapping conflicts - if region.is_some_and(|r| r.conflict == ConflictType::OverlappingConflict) { + if region.is_some_and(|r| { + r.conflict == ConflictType::OverlappingConflict + }) { writeln!(&mut output, "<<<<<<< {}", mine_label).unwrap(); writeln!( &mut output, @@ -780,12 +798,8 @@ fn generate_merged_output( _ => { // Standard conflict markers writeln!(&mut output, "<<<<<<< {}", mine_label).unwrap(); - writeln!( - &mut output, - "{}", - String::from_utf8_lossy(mine_lines[i]) - ) - .unwrap(); + writeln!(&mut output, "{}", String::from_utf8_lossy(mine_lines[i])) + .unwrap(); writeln!(&mut output, "=======").unwrap(); writeln!( &mut output, @@ -800,20 +814,10 @@ fn generate_merged_output( } } (true, false) => { - writeln!( - &mut output, - "{}", - String::from_utf8_lossy(mine_lines[i]) - ) - .unwrap(); + writeln!(&mut output, "{}", String::from_utf8_lossy(mine_lines[i])).unwrap(); } (false, true) => { - writeln!( - &mut output, - "{}", - String::from_utf8_lossy(yours_lines[i]) - ) - .unwrap(); + writeln!(&mut output, "{}", String::from_utf8_lossy(yours_lines[i])).unwrap(); } (false, false) => {} } @@ -836,12 +840,8 @@ fn generate_ed_script( let mut output = Vec::new(); // Generate ed script to transform mine into merged version - let mine_label = params.labels[0] - .as_deref() - .unwrap_or("mine"); - let yours_label = params.labels[2] - .as_deref() - .unwrap_or("yours"); + let mine_label = params.labels[0].as_deref().unwrap_or("mine"); + let yours_label = params.labels[2].as_deref().unwrap_or("yours"); // Check if we should filter based on output mode let should_filter = !matches!( @@ -866,28 +866,15 @@ fn generate_ed_script( (Some(mine), Some(yours)) => { if mine != yours { // Only output if it matches the filter - if !should_filter || region.is_none_or(|r| should_include_region(r, params.output_mode)) { + if !should_filter + || region.is_none_or(|r| should_include_region(r, params.output_mode)) + { // Change command writeln!(&mut output, "{}c", line_num + 1).unwrap(); - writeln!( - &mut output, - "<<<<<<< {}", - mine_label - ) - .unwrap(); - writeln!( - &mut output, - "{}", - String::from_utf8_lossy(yours) - ) - .unwrap(); + writeln!(&mut output, "<<<<<<< {}", mine_label).unwrap(); + writeln!(&mut output, "{}", String::from_utf8_lossy(yours)).unwrap(); writeln!(&mut output, "=======").unwrap(); - writeln!( - &mut output, - "{}", - String::from_utf8_lossy(mine) - ) - .unwrap(); + writeln!(&mut output, "{}", String::from_utf8_lossy(mine)).unwrap(); writeln!(&mut output, ">>>>>>> {}", yours_label).unwrap(); writeln!(&mut output, ".").unwrap(); } @@ -895,20 +882,19 @@ fn generate_ed_script( } (Some(_), None) => { // Delete command (only if not filtering or filter passes) - if !should_filter || region.is_none_or(|r| should_include_region(r, params.output_mode)) { + if !should_filter + || region.is_none_or(|r| should_include_region(r, params.output_mode)) + { writeln!(&mut output, "{}d", line_num + 1).unwrap(); } } (None, Some(yours)) => { // Add command (only if not filtering or filter passes) - if !should_filter || region.is_none_or(|r| should_include_region(r, params.output_mode)) { + if !should_filter + || region.is_none_or(|r| should_include_region(r, params.output_mode)) + { writeln!(&mut output, "{}a", line_num).unwrap(); - writeln!( - &mut output, - "{}", - String::from_utf8_lossy(yours) - ) - .unwrap(); + writeln!(&mut output, "{}", String::from_utf8_lossy(yours)).unwrap(); writeln!(&mut output, ".").unwrap(); } } @@ -979,7 +965,8 @@ pub fn main(opts: Peekable) -> ExitCode { } // Compute diff3 - let (result, has_conflicts) = compute_diff3(&mine_content, &older_content, &yours_content, ¶ms); + let (result, has_conflicts) = + compute_diff3(&mine_content, &older_content, &yours_content, ¶ms); io::stdout().write_all(&result).unwrap(); @@ -1386,17 +1373,15 @@ mod tests { #[test] fn test_analyze_overlap_all_overlapping() { - let regions = vec![ - Diff3Region { - mine_start: 0, - mine_count: 2, - older_start: 0, - older_count: 1, - yours_start: 0, - yours_count: 3, - conflict: ConflictType::OverlappingConflict, - }, - ]; + let regions = vec![Diff3Region { + mine_start: 0, + mine_count: 2, + older_start: 0, + older_count: 1, + yours_start: 0, + yours_count: 3, + conflict: ConflictType::OverlappingConflict, + }]; let (easy, overlapping, non_overlapping) = analyze_overlap(®ions); assert_eq!(easy, 0); assert_eq!(overlapping, 1); @@ -1660,8 +1645,14 @@ mod tests { let output_str = String::from_utf8_lossy(&output); // With -i, output should contain write (w) and quit (q) commands - assert!(output_str.contains("w\n") || output_str.contains("w"), "Output should contain write command"); - assert!(output_str.contains("q\n") || output_str.contains("q"), "Output should contain quit command"); + assert!( + output_str.contains("w\n") || output_str.contains("w"), + "Output should contain write command" + ); + assert!( + output_str.contains("q\n") || output_str.contains("q"), + "Output should contain quit command" + ); } #[test] @@ -1692,7 +1683,10 @@ mod tests { let lines: Vec<&str> = output_str.lines().collect(); if !lines.is_empty() { let last_line = lines[lines.len() - 1]; - assert_ne!(last_line, "q", "Last line should not be 'q' without -i flag"); + assert_ne!( + last_line, "q", + "Last line should not be 'q' without -i flag" + ); } } @@ -1700,28 +1694,40 @@ mod tests { fn test_is_binary_content_with_null_bytes() { // Binary content with null bytes let binary = b"GIF89a\x00\x00\x00\x00"; - assert!(is_binary_content(binary), "Should detect null bytes as binary"); + assert!( + is_binary_content(binary), + "Should detect null bytes as binary" + ); } #[test] fn test_is_binary_content_text_only() { // Plain text content let text = b"This is plain text\nwith multiple lines\n"; - assert!(!is_binary_content(text), "Should not detect plain text as binary"); + assert!( + !is_binary_content(text), + "Should not detect plain text as binary" + ); } #[test] fn test_is_binary_content_with_control_chars() { // Content with excessive control characters let binary = vec![0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0xFF, 0xFE]; - assert!(is_binary_content(&binary), "Should detect control-heavy content as binary"); + assert!( + is_binary_content(&binary), + "Should detect control-heavy content as binary" + ); } #[test] fn test_is_binary_content_empty() { // Empty content should not be treated as binary let empty: &[u8] = b""; - assert!(!is_binary_content(empty), "Empty content should not be binary"); + assert!( + !is_binary_content(empty), + "Empty content should not be binary" + ); } #[test] @@ -1733,7 +1739,7 @@ mod tests { yours: OsString::from("yours.bin"), format: Diff3Format::Normal, output_mode: Diff3OutputMode::All, - text: false, // Binary detection enabled + text: false, // Binary detection enabled labels: [None, None, None], strip_trailing_cr: false, initial_tab: false, @@ -1749,10 +1755,15 @@ mod tests { // Should report binary file differences let output_str = String::from_utf8_lossy(&output); - assert!(output_str.contains("Binary files") || output_str.is_empty(), - "Should report binary differences or be empty"); + assert!( + output_str.contains("Binary files") || output_str.is_empty(), + "Should report binary differences or be empty" + ); // Different binary files should have conflicts - assert!(has_conflicts, "Different binary files should be detected as conflicts"); + assert!( + has_conflicts, + "Different binary files should be detected as conflicts" + ); } #[test] @@ -1764,7 +1775,7 @@ mod tests { yours: OsString::from("yours.bin"), format: Diff3Format::Normal, output_mode: Diff3OutputMode::All, - text: false, // Binary detection enabled + text: false, // Binary detection enabled labels: [None, None, None], strip_trailing_cr: false, initial_tab: false, @@ -1773,12 +1784,18 @@ mod tests { // Identical binary files let content = b"GIF89a\x00\x10\x00\x10\xFF\xFF\xFF"; - + let (output, has_conflicts) = compute_diff3(content, content, content, ¶ms); // Identical files should have no output and no conflicts - assert!(output.is_empty(), "Identical binary files should produce no output"); - assert!(!has_conflicts, "Identical binary files should have no conflicts"); + assert!( + output.is_empty(), + "Identical binary files should produce no output" + ); + assert!( + !has_conflicts, + "Identical binary files should have no conflicts" + ); } #[test] @@ -1790,7 +1807,7 @@ mod tests { yours: OsString::from("yours"), format: Diff3Format::Normal, output_mode: Diff3OutputMode::All, - text: true, // Force text mode even for binary-looking content + text: true, // Force text mode even for binary-looking content labels: [None, None, None], strip_trailing_cr: false, initial_tab: false, @@ -1808,12 +1825,10 @@ mod tests { let output_str = String::from_utf8_lossy(&output); // Should not be "Binary files differ" - instead should process as text if !output.is_empty() { - assert!(!output_str.contains("Binary files differ"), - "Should not report binary when --text flag is set"); + assert!( + !output_str.contains("Binary files differ"), + "Should not report binary when --text flag is set" + ); } } } - - - - diff --git a/tests/integration.rs b/tests/integration.rs index ec59c41..983e0a5 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -922,9 +922,7 @@ mod diff3 { let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff3"); cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); - cmd.assert() - .code(predicate::eq(0)) - .success(); + cmd.assert().code(predicate::eq(0)).success(); Ok(()) } @@ -949,9 +947,7 @@ mod diff3 { cmd.arg("diff3"); cmd.arg("-m"); cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); - cmd.assert() - .code(predicate::eq(1)) - .failure(); + cmd.assert().code(predicate::eq(1)).failure(); Ok(()) } @@ -976,9 +972,7 @@ mod diff3 { cmd.arg("diff3"); cmd.arg("-e"); cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); - cmd.assert() - .code(predicate::eq(0)) - .success(); + cmd.assert().code(predicate::eq(0)).success(); Ok(()) } @@ -1034,9 +1028,7 @@ mod diff3 { cmd.arg("--label=original"); cmd.arg("--label=yours_version"); cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); - cmd.assert() - .code(predicate::eq(1)) - .failure(); + cmd.assert().code(predicate::eq(1)).failure(); Ok(()) } @@ -1061,9 +1053,7 @@ mod diff3 { cmd.arg("diff3"); cmd.arg("-3"); cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); - cmd.assert() - .code(predicate::eq(0)) - .success(); + cmd.assert().code(predicate::eq(0)).success(); Ok(()) } @@ -1110,9 +1100,7 @@ mod diff3 { cmd.arg("diff3"); cmd.arg("-").arg(&older_path).arg(&yours_path); cmd.write_stdin(b"line1\nline2\nline3\n"); - cmd.assert() - .code(predicate::eq(0)) - .success(); + cmd.assert().code(predicate::eq(0)).success(); Ok(()) } @@ -1137,9 +1125,7 @@ mod diff3 { cmd.arg("diff3"); cmd.arg("-A"); cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); - cmd.assert() - .code(predicate::eq(1)) - .failure(); + cmd.assert().code(predicate::eq(1)).failure(); Ok(()) } @@ -1165,9 +1151,7 @@ mod diff3 { cmd.arg("diff3"); cmd.arg("-m"); cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); - cmd.assert() - .code(predicate::eq(1)) - .failure(); + cmd.assert().code(predicate::eq(1)).failure(); Ok(()) } @@ -1193,9 +1177,7 @@ mod diff3 { cmd.arg("diff3"); cmd.arg("-x"); cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); - cmd.assert() - .code(predicate::eq(1)) - .failure(); + cmd.assert().code(predicate::eq(1)).failure(); Ok(()) } @@ -1221,9 +1203,7 @@ mod diff3 { cmd.arg("diff3"); cmd.arg("-3"); cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); - cmd.assert() - .code(predicate::eq(0)) - .success(); + cmd.assert().code(predicate::eq(0)).success(); Ok(()) } @@ -1251,9 +1231,7 @@ mod diff3 { cmd.arg("-X"); cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); // -X shows only overlapping conflicts with markers, which exists in this case - cmd.assert() - .code(predicate::eq(1)) - .failure(); + cmd.assert().code(predicate::eq(1)).failure(); Ok(()) } @@ -1307,9 +1285,7 @@ mod diff3 { cmd.arg("diff3"); cmd.arg("-e"); cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); - cmd.assert() - .code(predicate::eq(0)) - .success(); + cmd.assert().code(predicate::eq(0)).success(); Ok(()) } @@ -1336,9 +1312,7 @@ mod diff3 { cmd.arg("-i"); cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); // Even with conflicts, -e -i should produce a valid ed script with w and q - cmd.assert() - .code(predicate::eq(1)) - .failure(); + cmd.assert().code(predicate::eq(1)).failure(); Ok(()) } @@ -1386,7 +1360,7 @@ mod diff3 { for i in 0..10000 { let line = format!("line {}\n", i); older_content.push_str(&line); - + if i == 5000 { mine_content.push_str("line 5000 MINE\n"); yours_content.push_str("line 5000 YOURS\n"); @@ -1412,7 +1386,7 @@ mod diff3 { let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff3"); cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); - cmd.assert().code(1); // Has conflict + cmd.assert().code(1); // Has conflict Ok(()) } @@ -1429,7 +1403,7 @@ mod diff3 { for i in 0..5000 { let line = format!("line {}\n", i); older_content.push_str(&line); - + if i < 2500 { mine_content.push_str(&line); yours_content.push_str(&line); @@ -1536,22 +1510,23 @@ mod diff3 { let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff3"); - cmd.arg("-a"); // --text flag to force text mode + cmd.arg("-a"); // --text flag to force text mode cmd.arg(&mine_path).arg(&older_path).arg(&yours_path); // With --text flag, should process as text despite null byte // The output should show differences but not report as "Binary files differ" let output = cmd.output()?; let stdout = String::from_utf8_lossy(&output.stdout); // Should not contain "Binary files differ" message - assert!(!stdout.contains("Binary files differ"), - "Should not report binary when --text flag is used"); + assert!( + !stdout.contains("Binary files differ"), + "Should not report binary when --text flag is used" + ); // Should have changes detected (exit code 0 or 1 depending on conflicts) - assert!(output.status.code() == Some(0) || output.status.code() == Some(1), - "Exit code should indicate success or conflicts"); + assert!( + output.status.code() == Some(0) || output.status.code() == Some(1), + "Exit code should indicate success or conflicts" + ); Ok(()) } } - - -