use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use owo_colors::OwoColorize;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process;
use std::time::Instant;
use sysinfo::System;
#[derive(Parser)]
#[command(name = "xpatch")]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Encode {
base: PathBuf,
new: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(short, long, default_value = "0")]
tag: usize,
#[arg(short, long)]
zstd: bool,
#[arg(short, long)]
verify: bool,
#[arg(short = 'y', long)]
yes: bool,
#[arg(short, long)]
force: bool,
#[arg(short, long)]
quiet: bool,
},
Decode {
base: PathBuf,
delta: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(short = 'y', long)]
yes: bool,
#[arg(short, long)]
force: bool,
#[arg(short, long)]
quiet: bool,
},
Info {
delta: PathBuf,
},
}
const EXIT_SUCCESS: i32 = 0;
const EXIT_ERROR: i32 = 1;
const EXIT_ENCODE_DECODE_FAILED: i32 = 2;
const EXIT_OUT_OF_MEMORY: i32 = 4;
const EXIT_USER_CANCELLED: i32 = 5;
fn main() {
let cli = Cli::parse();
let result = match cli.command {
Commands::Encode {
base,
new,
output,
tag,
zstd,
verify,
yes,
force,
quiet,
} => handle_encode(&base, &new, &output, tag, zstd, verify, yes, force, quiet),
Commands::Decode {
base,
delta,
output,
yes,
force,
quiet,
} => handle_decode(&base, &delta, &output, yes, force, quiet),
Commands::Info { delta } => handle_info(&delta),
};
match result {
Ok(()) => process::exit(EXIT_SUCCESS),
Err(e) => {
eprintln!("{} {}", "Error:".bright_red().bold(), e);
let exit_code = if e.to_string().contains("out of memory")
|| e.to_string().contains("Out of memory")
|| e.to_string().contains("Insufficient memory")
{
EXIT_OUT_OF_MEMORY
} else if e.to_string().contains("cancelled") || e.to_string().contains("Cancelled") {
EXIT_USER_CANCELLED
} else if e.to_string().contains("encode") || e.to_string().contains("decode") {
EXIT_ENCODE_DECODE_FAILED
} else {
EXIT_ERROR
};
process::exit(exit_code);
}
}
}
#[allow(clippy::too_many_arguments)]
fn handle_encode(
base_path: &Path,
new_path: &Path,
output_path: &Path,
tag: usize,
zstd: bool,
verify: bool,
yes: bool,
force: bool,
quiet: bool,
) -> Result<()> {
if !base_path.exists() {
bail!("File not found: {}", base_path.display());
}
if !new_path.exists() {
bail!("File not found: {}", new_path.display());
}
if output_path.exists() && !force {
bail!(
"Output file already exists: {}\n Use --force to overwrite",
output_path.display()
);
}
let base_size = fs::metadata(base_path)
.context("Failed to read base file metadata")?
.len();
let new_size = fs::metadata(new_path)
.context("Failed to read new file metadata")?
.len();
if !quiet {
println!(
"{} Base: {}, New: {}",
"File sizes:".bright_cyan(),
format_bytes(base_size),
format_bytes(new_size)
);
}
let required = estimate_encode_memory(base_size, new_size);
check_memory(required, yes, quiet)?;
if !quiet {
let total_steps = if verify { 4 } else { 3 };
println!(
"{} Reading files...",
format!("Step 1/{}:", total_steps).bright_cyan()
);
}
let base_data = fs::read(base_path)
.with_context(|| format!("Failed to read base file: {}", base_path.display()))?;
let new_data = fs::read(new_path)
.with_context(|| format!("Failed to read new file: {}", new_path.display()))?;
if !quiet {
let total_steps = if verify { 4 } else { 3 };
println!(
"{} Encoding delta...",
format!("Step 2/{}:", total_steps).bright_cyan()
);
}
let start = Instant::now();
let delta = xpatch::delta::encode(tag, &base_data, &new_data, zstd);
let encode_time = start.elapsed();
if !quiet {
let total_steps = if verify { 4 } else { 3 };
println!(
"{} Writing output...",
format!("Step 3/{}:", total_steps).bright_cyan()
);
}
fs::write(output_path, &delta)
.with_context(|| format!("Failed to write output file: {}", output_path.display()))?;
let verify_result = if verify {
if !quiet {
println!("{} Verifying delta...", "Step 4/4:".bright_cyan());
}
let verify_start = Instant::now();
let reconstructed = xpatch::delta::decode(&base_data, &delta)
.map_err(|e| anyhow::anyhow!("Verification decode failed: {}", e))?;
let verify_time = verify_start.elapsed();
if reconstructed != new_data {
bail!(
"Verification failed: reconstructed output does not match original new file\n \
Expected {} bytes, got {} bytes",
new_data.len(),
reconstructed.len()
);
}
Some(verify_time)
} else {
None
};
if !quiet {
println!();
println!(
"{} Created {} ({}, {:.1}% of new file)",
"Success:".bright_green().bold(),
output_path.display(),
format_bytes(delta.len() as u64),
(delta.len() as f64 / new_size as f64) * 100.0
);
print!(" Encoding took {}", format_duration(encode_time));
if let Some(verify_time) = verify_result {
print!(", verification took {}", format_duration(verify_time));
}
println!();
}
Ok(())
}
fn handle_decode(
base_path: &Path,
delta_path: &Path,
output_path: &Path,
yes: bool,
force: bool,
quiet: bool,
) -> Result<()> {
if !base_path.exists() {
bail!("File not found: {}", base_path.display());
}
if !delta_path.exists() {
bail!("File not found: {}", delta_path.display());
}
if output_path.exists() && !force {
bail!(
"Output file already exists: {}\n Use --force to overwrite",
output_path.display()
);
}
let base_size = fs::metadata(base_path)
.context("Failed to read base file metadata")?
.len();
let delta_size = fs::metadata(delta_path)
.context("Failed to read delta file metadata")?
.len();
if !quiet {
println!(
"{} Base: {}, Delta: {}",
"File sizes:".bright_cyan(),
format_bytes(base_size),
format_bytes(delta_size)
);
}
let required = estimate_decode_memory(base_size, delta_size);
check_memory(required, yes, quiet)?;
if !quiet {
println!("{} Reading files...", "Step 1/3:".bright_cyan());
}
let base_data = fs::read(base_path)
.with_context(|| format!("Failed to read base file: {}", base_path.display()))?;
let delta_data = fs::read(delta_path)
.with_context(|| format!("Failed to read delta file: {}", delta_path.display()))?;
if !quiet {
println!("{} Decoding delta...", "Step 2/3:".bright_cyan());
}
let start = Instant::now();
let output_data = xpatch::delta::decode(&base_data, &delta_data)
.map_err(|e| anyhow::anyhow!("Decode failed: {}", e))?;
let decode_time = start.elapsed();
if !quiet {
println!("{} Writing output...", "Step 3/3:".bright_cyan());
}
fs::write(output_path, &output_data)
.with_context(|| format!("Failed to write output file: {}", output_path.display()))?;
if !quiet {
println!();
println!(
"{} Created {} ({})",
"Success:".bright_green().bold(),
output_path.display(),
format_bytes(output_data.len() as u64)
);
println!(" Decoding took {}", format_duration(decode_time));
}
Ok(())
}
fn handle_info(delta_path: &Path) -> Result<()> {
if !delta_path.exists() {
bail!("File not found: {}", delta_path.display());
}
let delta_data = fs::read(delta_path)
.with_context(|| format!("Failed to read delta file: {}", delta_path.display()))?;
let tag = xpatch::delta::get_tag(&delta_data)
.map_err(|e| anyhow::anyhow!("Failed to read delta tag: {}", e))?;
println!("Tag: {}", tag);
println!("Size: {} bytes", delta_data.len());
match xpatch::delta::decode_header(&delta_data) {
Ok((algo, _, header_bytes)) => {
println!("Algorithm: {:?}", algo);
println!("Header size: {} bytes", header_bytes);
}
Err(_) => {
}
}
Ok(())
}
fn estimate_encode_memory(base_size: u64, new_size: u64) -> u64 {
base_size + new_size + new_size + (base_size / 5)
}
fn estimate_decode_memory(base_size: u64, delta_size: u64) -> u64 {
base_size + delta_size + base_size + (base_size / 5)
}
fn check_memory(required: u64, skip_prompt: bool, quiet: bool) -> Result<()> {
let mut sys = System::new_all();
sys.refresh_memory();
let available = sys.available_memory();
let total = sys.total_memory();
if required > total {
bail!(
"Insufficient memory\n Required: ~{}\n Total RAM: {}\n\n \
These files cannot be processed on this system.",
format_bytes(required),
format_bytes(total)
);
}
let usage_pct = (required as f64 / available as f64) * 100.0;
if !quiet && usage_pct < 80.0 {
println!(
"{} ~{} required, {} available {}",
"Memory:".bright_cyan(),
format_bytes(required),
format_bytes(available),
"✓".bright_green()
);
}
if usage_pct >= 80.0 {
eprintln!();
eprintln!(
"{} This operation requires ~{}",
"Memory warning:".bright_yellow().bold(),
format_bytes(required)
);
eprintln!(
" Available: {} free ({} total)",
format_bytes(available),
format_bytes(total)
);
eprintln!();
if usage_pct >= 100.0 {
eprintln!(
" Loading these files will use {:.0}% of available memory.",
usage_pct
);
eprintln!(
" {}",
"Your system may freeze or crash.".bright_red().bold()
);
} else {
eprintln!(
" Loading these files will use {:.0}% of available memory.",
usage_pct
);
eprintln!(" System may slow down temporarily.");
}
eprintln!();
if skip_prompt {
eprintln!(" {} Continuing anyway (--yes flag)", "⚠".bright_yellow());
eprintln!();
} else {
eprint!(" Continue? [y/N]: ");
io::stderr().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
bail!("Cancelled by user");
}
eprintln!();
}
}
Ok(())
}
fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
fn format_duration(duration: std::time::Duration) -> String {
let nanos = duration.as_nanos();
if nanos < 1_000 {
format!("{}ns", nanos)
} else if nanos < 1_000_000 {
format!("{:.1}μs", nanos as f64 / 1_000.0)
} else if nanos < 1_000_000_000 {
format!("{:.2}ms", nanos as f64 / 1_000_000.0)
} else {
format!("{:.3}s", duration.as_secs_f64())
}
}