use clap::Parser;
use paircomp_core::{
fingerprint_line_prefix, fingerprint_through_line, inspect_file, inspect_line,
utf8_character_position, ByteSearch, ByteSearchStep, Fingerprint, LineSearch, LineSearchStep,
};
use std::fmt;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
#[derive(Parser)]
#[command(
name = "paircomp",
version,
about = "Compare isolated file copies using fingerprints"
)]
struct Cli {
file: PathBuf,
}
#[derive(Debug)]
enum WorkflowError {
Core(paircomp_core::Error),
Io(io::Error),
Aborted,
}
impl fmt::Display for WorkflowError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Core(error) => error.fmt(f),
Self::Io(error) => write!(f, "terminal I/O failed: {error}"),
Self::Aborted => f.write_str("input ended before the comparison was complete"),
}
}
}
impl From<paircomp_core::Error> for WorkflowError {
fn from(error: paircomp_core::Error) -> Self {
Self::Core(error)
}
}
impl From<io::Error> for WorkflowError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
let mut diagnostics = io::stderr().lock();
match run(
&cli.file,
&mut io::stdin().lock(),
&mut io::stdout().lock(),
&mut diagnostics,
) {
Ok(status) => status,
Err(error) => {
let _ = writeln!(diagnostics, "paircomp: {error}");
ExitCode::from(2)
}
}
}
fn run(
path: &Path,
input: &mut impl BufRead,
output: &mut impl Write,
diagnostics: &mut impl Write,
) -> Result<ExitCode, WorkflowError> {
let info = inspect_file(path)?;
writeln!(output, "File: {}", path.display())?;
writeln!(output, "Lines: {}", info.line_count)?;
writeln!(output, "Size: {} bytes", info.byte_len)?;
writeln!(
output,
"Fingerprint: {}",
format_fingerprint(info.fingerprint)
)?;
writeln!(output)?;
writeln!(
output,
"Keep both files unchanged during this session. Restart after editing either file."
)?;
writeln!(output)?;
if read_match(
input,
output,
diagnostics,
"Does this fingerprint match the other copy? [y/n] ",
)? {
writeln!(output, "Files match.")?;
return Ok(ExitCode::SUCCESS);
}
let other_line_count = read_count(
input,
output,
diagnostics,
"Line count displayed by the other copy: ",
"line",
)?;
let mut search = LineSearch::new(info.line_count, other_line_count)?;
loop {
match search.current_step() {
LineSearchStep::CompareThroughLine { line } => {
let fingerprint = fingerprint_through_line(path, line)?;
writeln!(output)?;
writeln!(output, "Compare through line {line}:")?;
writeln!(output, "Fingerprint: {}", format_fingerprint(fingerprint))?;
let matched = read_match(
input,
output,
diagnostics,
"Does this fingerprint match? [y/n] ",
)?;
search.record_result(matched)?;
}
LineSearchStep::DifferenceAtLine { line } => {
writeln!(output)?;
writeln!(output, "First divergence: line {line}")?;
if line > info.line_count {
writeln!(output, "The local file ends before line {line}.")?;
}
writeln!(
output,
"Choose the same continuation answer on both copies."
)?;
if read_yes_no(
input,
output,
diagnostics,
"Continue within this line? [Y/n] ",
Some(true),
)? {
localize_byte(path, line, input, output, diagnostics)?;
}
writeln!(output)?;
writeln!(
output,
"Inspect/correct the corresponding files, then run paircomp again."
)?;
return Ok(ExitCode::from(1));
}
}
}
}
fn localize_byte(
path: &Path,
line: u64,
input: &mut impl BufRead,
output: &mut impl Write,
diagnostics: &mut impl Write,
) -> Result<(), WorkflowError> {
let local = inspect_line(path, line)?;
let local_byte_len = local.map_or(0, |info| info.byte_len);
writeln!(output)?;
writeln!(
output,
"Line {line} size: {local_byte_len} bytes (including any CR/LF)"
)?;
let other_byte_len = read_count(
input,
output,
diagnostics,
"Byte count displayed for this line by the other copy: ",
"byte",
)?;
let mut search = ByteSearch::new(local_byte_len, other_byte_len)?;
loop {
match search.current_step() {
ByteSearchStep::CompareThroughByte { byte } => {
let fingerprint = fingerprint_line_prefix(path, line, byte)?;
writeln!(output)?;
writeln!(output, "Compare line {line} through byte {byte}:")?;
writeln!(output, "Fingerprint: {}", format_fingerprint(fingerprint))?;
let matched = read_match(
input,
output,
diagnostics,
"Does this fingerprint match? [y/n] ",
)?;
search.record_result(matched)?;
}
ByteSearchStep::DifferenceAtByte { byte } => {
let character = utf8_character_position(path, line, byte)?;
writeln!(output)?;
write!(output, "First divergence: line {line}, byte {byte}")?;
if let Some(character) = character {
write!(output, " (UTF-8 character {character})")?;
}
writeln!(output)?;
if local.is_none() {
writeln!(
output,
"The local file ends before line {line}; this byte is absent."
)?;
} else if byte > local_byte_len {
writeln!(
output,
"The local line ends before byte {byte}; this byte is absent."
)?;
}
return Ok(());
}
}
}
}
fn format_fingerprint(fingerprint: Fingerprint) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut formatted = String::with_capacity(64);
for &byte in fingerprint.as_bytes() {
formatted.push(char::from(HEX[usize::from(byte >> 4)]));
formatted.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
formatted
}
fn read_prompt(
input: &mut impl BufRead,
output: &mut impl Write,
prompt: &str,
) -> Result<String, WorkflowError> {
write!(output, "{prompt}")?;
output.flush()?;
let mut answer = String::new();
input.read_line(&mut answer)?;
if !answer.ends_with('\n') {
return Err(WorkflowError::Aborted);
}
Ok(answer)
}
fn read_match(
input: &mut impl BufRead,
output: &mut impl Write,
diagnostics: &mut impl Write,
prompt: &str,
) -> Result<bool, WorkflowError> {
read_yes_no(input, output, diagnostics, prompt, None)
}
fn read_yes_no(
input: &mut impl BufRead,
output: &mut impl Write,
diagnostics: &mut impl Write,
prompt: &str,
default: Option<bool>,
) -> Result<bool, WorkflowError> {
loop {
let answer = read_prompt(input, output, prompt)?;
let answer = answer.trim();
if answer.is_empty() {
if let Some(default) = default {
return Ok(default);
}
} else if answer.eq_ignore_ascii_case("n") || answer.eq_ignore_ascii_case("no") {
return Ok(false);
} else if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
return Ok(true);
}
writeln!(
diagnostics,
"paircomp: invalid answer; enter y, yes, n, or no"
)?;
}
}
fn read_count(
input: &mut impl BufRead,
output: &mut impl Write,
diagnostics: &mut impl Write,
prompt: &str,
kind: &str,
) -> Result<u64, WorkflowError> {
loop {
let answer = read_prompt(input, output, prompt)?;
let answer = answer.trim();
if !answer.is_empty() && answer.bytes().all(|byte| byte.is_ascii_digit()) {
if let Ok(count) = answer.parse() {
return Ok(count);
}
}
writeln!(
diagnostics,
"paircomp: invalid {kind} count; enter a nonnegative decimal u64"
)?;
}
}