use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io::{IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::Parser;
use hunkpick::cli::{Cli, ColorMode, Command, InputOpts, VerifyOpts};
use hunkpick::error::AppError;
use hunkpick::{emit, list, model, parser, select, split, validate};
fn main() -> ExitCode {
match run().and_then(|()| flush_out()) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("hunkpick: {e}");
ExitCode::from(e.exit_code())
}
}
}
fn run() -> Result<(), AppError> {
let cli = Cli::parse();
match cli.command {
Command::List { json, color, input } => run_list(json, color, &input),
Command::Select {
selectors,
input,
verify,
} => run_select(&selectors, &input, &verify),
Command::Split {
hunk,
at,
input,
verify,
} => run_split(&hunk, &at, &input, &verify),
}
}
fn run_list(json: bool, color: ColorMode, input: &InputOpts) -> Result<(), AppError> {
let Some(patch) = load_and_parse(input)? else {
return Ok(());
};
let use_color = hunkpick::cli::resolve_color(color);
let text = if json {
list::list_json(&patch)
} else {
list::list_human(&patch, use_color)
};
write_out(text.as_bytes())?;
if !text.ends_with('\n') {
write_out(b"\n")?;
}
Ok(())
}
fn run_select(
selectors: &[OsString],
input: &InputOpts,
verify: &VerifyOpts,
) -> Result<(), AppError> {
let Some(patch) = load_and_parse(input)? else {
return Ok(());
};
let sels = select::parse_selectors(selectors).map_err(usage)?;
let out = select::select(&patch, &sels).map_err(usage)?;
emit_verified(&out, verify)
}
fn run_split(
hunk: &OsStr,
at: &[u32],
input: &InputOpts,
verify: &VerifyOpts,
) -> Result<(), AppError> {
let Some(mut patch) = load_and_parse(input)? else {
return Ok(());
};
let (fi, hi) = select::resolve_hunk(&patch, hunk).map_err(usage)?;
split::split_patch_hunk(&mut patch, fi, hi, at).map_err(usage)?;
hunkpick::renumber::renumber_new_side(&mut patch);
emit_verified(&patch, verify)
}
fn load_and_parse(opts: &InputOpts) -> Result<Option<model::Patch>, AppError> {
let input = read_source(opts)?;
if input.iter().all(u8::is_ascii_whitespace) {
return Ok(None);
}
reject_non_diff(&input)?;
let patch = parser::parse(&input).map_err(|e| AppError::Usage(format!("parse error: {e}")))?;
validate::validate_input(&patch).map_err(|e| AppError::Usage(format!("input diff: {e}")))?;
Ok(Some(patch))
}
fn read_source(opts: &InputOpts) -> Result<Vec<u8>, AppError> {
match opts.input.as_deref() {
Some(path) if path != Path::new("-") => {
let file =
File::open(path).map_err(|e| AppError::Io(format!("{}: {e}", path.display())))?;
read_limited(file, opts.max_input_bytes)
}
_ => {
let stdin = std::io::stdin();
if stdin.is_terminal() {
eprintln!(
"hunkpick: reading a diff from the terminal; pipe one in or use -i FILE \
(Ctrl-D ends the input)"
);
}
read_limited(stdin.lock(), opts.max_input_bytes)
}
}
}
fn read_limited<R: Read>(r: R, limit: u64) -> Result<Vec<u8>, AppError> {
let mut buf = Vec::new();
if limit == 0 {
let mut r = r;
r.read_to_end(&mut buf)
.map_err(|e| AppError::Io(e.to_string()))?;
return Ok(buf);
}
r.take(limit.saturating_add(1))
.read_to_end(&mut buf)
.map_err(|e| AppError::Io(e.to_string()))?;
if buf.len() as u64 > limit {
return Err(AppError::Usage(format!(
"input exceeds limit of {limit} bytes (override with --max-input-bytes)"
)));
}
Ok(buf)
}
fn utf16_or_32_bom(input: &[u8]) -> Option<&'static str> {
match input {
[0xFF, 0xFE, 0x00, 0x00, ..] => Some("UTF-32LE"),
[0x00, 0x00, 0xFE, 0xFF, ..] => Some("UTF-32BE"),
[0xFF, 0xFE, ..] => Some("UTF-16LE"),
[0xFE, 0xFF, ..] => Some("UTF-16BE"),
_ => None,
}
}
const UTF16_SNIFF_BYTES: usize = 256;
fn utf16_without_bom(input: &[u8]) -> Option<&'static str> {
let len = input.len().min(UTF16_SNIFF_BYTES) & !1;
if len < 8 {
return None;
}
let head = &input[..len];
for (encoding, text_at) in [("UTF-16LE", 0usize), ("UTF-16BE", 1usize)] {
let nul_at = 1 - text_at;
if !head.iter().skip(nul_at).step_by(2).all(|&b| b == 0) {
continue;
}
let text: Vec<u8> = head.iter().skip(text_at).step_by(2).copied().collect();
let is_ascii_text = text
.iter()
.all(|&b| matches!(b, b'\t' | b'\n' | b'\r' | 0x20..=0x7E));
if is_ascii_text && parser::looks_like_a_diff(&text) {
return Some(encoding);
}
}
None
}
fn reject_non_diff(input: &[u8]) -> Result<(), AppError> {
if let Some(encoding) = utf16_or_32_bom(input) {
return Err(AppError::Usage(format!(
"input starts with a {encoding} byte-order mark; hunkpick reads a UTF-8 (or any \
ASCII-compatible) byte stream -- re-encode the diff, e.g. `iconv -f {encoding} -t \
UTF-8`"
)));
}
if let Some(encoding) = utf16_without_bom(input) {
return Err(AppError::Usage(format!(
"input looks like {encoding} without a byte-order mark; hunkpick reads a UTF-8 (or \
any ASCII-compatible) byte stream -- re-encode the diff, e.g. `iconv -f {encoding} \
-t UTF-8`"
)));
}
if input.contains(&0) {
return Err(AppError::Usage(
"binary input: NUL byte found, expected a unified diff".into(),
));
}
if !parser::looks_like_a_diff(input) {
return Err(AppError::Usage(
"input does not look like a unified diff (no diff markers found)".into(),
));
}
Ok(())
}
fn write_out(bytes: &[u8]) -> Result<(), AppError> {
match std::io::stdout().write_all(bytes) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
Err(e) => Err(AppError::Io(e.to_string())),
}
}
fn flush_out() -> Result<(), AppError> {
match std::io::stdout().flush() {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
Err(e) => Err(AppError::Io(e.to_string())),
}
}
fn usage<E: std::fmt::Display>(e: E) -> AppError {
AppError::Usage(format!("{e}"))
}
fn check_dir(dir: Option<&Path>) -> Result<PathBuf, AppError> {
let Some(dir) = dir else {
return Ok(PathBuf::from("."));
};
match std::fs::metadata(dir) {
Ok(m) if m.is_dir() => Ok(dir.to_path_buf()),
Ok(_) => Err(AppError::Usage(format!(
"-C {}: not a directory",
dir.display()
))),
Err(e) => Err(AppError::Usage(format!("-C {}: {e}", dir.display()))),
}
}
fn emit_verified(out: &model::Patch, verify: &VerifyOpts) -> Result<(), AppError> {
if !verify.no_verify_result_diff_internal {
validate::validate_internal(out)
.map_err(|e| AppError::Verify(format!("internal consistency check failed: {e}")))?;
}
let bytes = emit::emit(out);
if verify.verify_result_diff_git {
let dir = check_dir(verify.dir.as_deref())?;
validate::validate_with_git(&bytes, &dir).map_err(|e| match e {
validate::GitCheckError::Rejected(_) => AppError::Verify(e.to_string()),
validate::GitCheckError::WriterPanicked => AppError::Internal(e.to_string()),
validate::GitCheckError::Spawn { .. }
| validate::GitCheckError::Io(_)
| validate::GitCheckError::Failed { .. } => AppError::Io(e.to_string()),
})?;
}
write_out(&bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_limited_accepts_input_at_max_limit() {
let data = b"diff --git a/f b/f\n";
let got = read_limited(&data[..], u64::MAX).unwrap();
assert_eq!(got, data);
}
#[test]
fn every_byte_order_mark_is_named_by_its_own_encoding() {
let cases: [(&[u8], Option<&str>); 6] = [
(&[0xFF, 0xFE, 0x00, 0x00, b'-'], Some("UTF-32LE")),
(&[0x00, 0x00, 0xFE, 0xFF, b'-'], Some("UTF-32BE")),
(&[0xFF, 0xFE, b'-', 0x00], Some("UTF-16LE")),
(&[0xFE, 0xFF, 0x00, b'-'], Some("UTF-16BE")),
(b"diff --git a/f b/f", None),
(&[0xEF, 0xBB, 0xBF, b'd'], None),
];
for (input, expected) in cases {
assert_eq!(
utf16_or_32_bom(input),
expected,
"byte-order mark {:02X?}",
&input[..input.len().min(4)]
);
}
}
#[test]
fn read_limited_rejects_oversized_input() {
let data = b"0123456789";
let err = read_limited(&data[..], 4).unwrap_err();
assert!(matches!(err, AppError::Usage(_)));
}
}