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 = 8192;
fn utf16_without_bom(input: &[u8]) -> Option<&'static str> {
const NOT_ASCII: u8 = 0xFF;
fn read_as_ascii(line: &[u8]) -> &[u8] {
let end = line
.iter()
.position(|&b| b == NOT_ASCII)
.unwrap_or(line.len());
&line[..end]
}
fn counts_as_a_diff_line(line: &[u8]) -> bool {
parser::reads_as_a_diff_line(read_as_ascii(line))
&& parser::the_header_a_path_follows_in(line)
.is_none_or(|header| parser::carries_something_past(line, header))
}
let len = input.len().min(UTF16_SNIFF_BYTES) & !1;
let head = &input[..len];
for (encoding, text_at) in [("UTF-16LE", 0usize), ("UTF-16BE", 1usize)] {
let nul_at = 1 - text_at;
let text: Vec<u8> = head
.chunks_exact(2)
.map(|u| {
if u[nul_at] == 0 && matches!(u[text_at], b'\t' | b'\n' | b'\r' | 0x20..=0x7E) {
u[text_at]
} else {
NOT_ASCII
}
})
.collect();
if text
.split(|&b| b == b'\n')
.filter(|line| counts_as_a_diff_line(line))
.nth(1)
.is_some()
{
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 | validate::GitCheckError::ReaderPanicked => {
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 std::collections::BTreeSet;
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 a_utf16_mail_is_named_by_its_encoding_and_not_called_binary() {
const MARKER: &str = "diff --git ";
const WINDOW_THE_DEFECT_RETURNS_AT: usize = 256;
let mut mail = String::from("From: Someone <someone@example.invalid>\n");
mail.push_str("Subject: [PATCH] a change across a good part of the tree\n");
mail.push_str("Date: Mon, 1 Sep 2026 00:00:00 +0300\n\n");
mail.push_str("The commit message, and then the diffstat git writes before the diff:\n\n");
for i in 0..40 {
mail.push_str(&format!(
" src/a/rather/long/path/file{i:02}.rs | 12 ++++++------\n"
));
}
mail.push_str(" 40 files changed, 240 insertions(+), 240 deletions(-)\n\n");
mail.push_str(MARKER);
mail.push_str("a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-a\n+b\n");
let marker_at = mail.find(MARKER).expect("the mail carries a diff") * 2;
assert!(
marker_at > WINDOW_THE_DEFECT_RETURNS_AT,
"the marker has to sit past the window this test is about: {marker_at} bytes"
);
assert!(
marker_at < UTF16_SNIFF_BYTES,
"the window is narrower than a format-patch mail needs: the marker is {marker_at} \
bytes in, the window is {UTF16_SNIFF_BYTES}"
);
let utf16: Vec<u8> = mail.bytes().flat_map(|b| [b, 0]).collect();
assert_eq!(utf16_without_bom(&utf16), Some("UTF-16LE"));
}
#[test]
fn a_hunk_marker_without_a_header_after_it_is_not_called_utf16() {
assert_eq!(utf16_without_bom(b"@\x00@\x00 \x00\xff\xff"), None);
}
#[test]
fn a_utf16_mail_with_non_ascii_headers_is_named_by_its_encoding() {
let mut mail = String::from("From: Someone <someone@example.invalid>\n");
mail.push_str("Subject: [PATCH] Исправление разбора хвоста\n");
mail.push_str("Date: Mon, 1 Sep 2026 00:00:00 +0300\n\n");
mail.push_str("Запись без ханков читается как доходящая до своей последней строки.\n\n");
mail.push_str("diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-a\n+b\n");
let utf16 = as_utf16le(&mail);
assert_eq!(utf16_without_bom(&utf16), Some("UTF-16LE"));
}
#[test]
fn a_utf16_rename_of_a_path_in_another_script_is_named_by_its_encoding() {
let rename = "diff --git a/файл.md b/новый.md\n\
similarity index 100%\n\
rename from файл.md\n\
rename to новый.md\n";
let utf16 = as_utf16le(rename);
assert_eq!(utf16_without_bom(&utf16), Some("UTF-16LE"));
}
#[test]
fn a_marker_line_inside_binary_data_is_not_called_utf16() {
let png_then_a_marker_then_png =
b"\x89\x50\x0a\x00@\x00@\x00 \x00-\x001\x00\x0a\x00\x89\x50";
assert_eq!(utf16_without_bom(png_then_a_marker_then_png), None);
}
#[test]
fn a_path_marker_inside_binary_data_is_not_called_utf16() {
let png_then_a_marker_then_png =
b"\x89\x50\x0a\x00-\x00-\x00-\x00 \x00x\x00\x0a\x00\x89\x50";
assert_eq!(utf16_without_bom(png_then_a_marker_then_png), None);
}
#[test]
fn a_utf16_mail_whose_first_header_is_not_ascii_is_named_by_its_encoding() {
for ending in LINE_ENDINGS {
let mail = format!(
"Subject: [PATCH] Исправление разбора хвоста{ending}\
From: Someone <someone@example.invalid>{ending}{ending}\
diff --git a/f b/f{ending}--- a/f{ending}+++ b/f{ending}\
@@ -1 +1 @@{ending}-a{ending}+b{ending}"
);
let utf16 = as_utf16le(&mail);
assert_eq!(utf16_without_bom(&utf16), Some("UTF-16LE"), "{ending:?}");
}
}
#[test]
fn a_utf16_diff_that_opens_with_a_blank_line_is_named_by_its_encoding() {
for ending in LINE_ENDINGS {
let diff = format!(
"{ending}diff --git a/f b/f{ending}--- a/f{ending}+++ b/f{ending}\
@@ -1 +1 @@{ending}-a{ending}+b{ending}"
);
let utf16 = as_utf16le(&diff);
assert_eq!(utf16_without_bom(&utf16), Some("UTF-16LE"), "{ending:?}");
}
}
const MARKER_LINES: [(&str, &str); 8] = [
("diff --git ", "diff --git a/f b/f"),
("--- ", "--- a/f"),
("+++ ", "+++ b/f"),
("@@ ", "@@ -1 +1 @@"),
("Binary files ", "Binary files a/f and b/f differ"),
("diff --cc ", "diff --cc f"),
("diff --combined ", "diff --combined f"),
("@@@", "@@@ -1,1 -1,1 +1,1 @@@"),
];
#[test]
fn every_marker_the_parser_knows_has_a_pair() {
let paired: BTreeSet<&[u8]> = MARKER_LINES
.iter()
.map(|(bare, _)| bare.as_bytes())
.collect();
assert_eq!(paired, parser::markers().collect::<BTreeSet<_>>());
}
const HEADERS_ASCII_FOLLOWS_LEN: usize = 7;
const HEADERS_A_PATH_FOLLOWS_LEN: usize = 4;
const LINE_ENDINGS: [&str; 2] = ["\n", "\r\n"];
fn as_utf16(text: &str, to_bytes: fn(u16) -> [u8; 2]) -> Vec<u8> {
text.encode_utf16().flat_map(to_bytes).collect()
}
fn as_utf16le(text: &str) -> Vec<u8> {
as_utf16(text, u16::to_le_bytes)
}
fn between_binary_noise(lines: &str, to_bytes: fn(u16) -> [u8; 2]) -> Vec<u8> {
const NOISE: [u8; 2] = [0x89, 0x50];
let mut window = NOISE.to_vec();
window.extend(as_utf16("\n", to_bytes));
window.extend(as_utf16(lines, to_bytes));
window.extend(NOISE);
window
}
fn two_lines_le(line: &str, ending: &str) -> Vec<u8> {
between_binary_noise(&format!("{line}{ending}{line}{ending}"), u16::to_le_bytes)
}
fn weighed_list(lines: impl Iterator<Item = &'static [u8]>, how_many: usize) -> Vec<String> {
assert!(how_many > 0, "the length handed in");
let lines: Vec<_> = lines
.map(|line| String::from_utf8_lossy(line).into_owned())
.collect();
assert_eq!(lines.len(), how_many, "the list handed in");
lines
}
fn two_bare_lines_say_nothing(lines: impl Iterator<Item = &'static [u8]>, how_many: usize) {
let lines = weighed_list(lines, how_many);
for ending in LINE_ENDINGS {
for line in &lines {
let window = two_lines_le(line, ending);
assert_eq!(
utf16_without_bom(&window),
None,
"two `{line}` and {ending:?}"
);
}
}
}
fn two_lines_carrying_a_tail_name_the_encoding(
lines: impl Iterator<Item = &'static [u8]>,
how_many: usize,
tails: &[&str],
) {
let lines = weighed_list(lines, how_many);
assert!(!tails.is_empty(), "the tails handed in");
for ending in LINE_ENDINGS {
for line in &lines {
for tail in tails {
let window = two_lines_le(&format!("{line}{tail}"), ending);
assert_eq!(
utf16_without_bom(&window),
Some("UTF-16LE"),
"two `{line}{tail}` and {ending:?}"
);
}
}
}
}
#[test]
fn a_single_diff_line_inside_binary_data_is_not_called_utf16() {
for ending in LINE_ENDINGS {
for (_, line) in MARKER_LINES {
let window = between_binary_noise(&format!("{line}{ending}"), u16::to_le_bytes);
assert_eq!(
utf16_without_bom(&window),
None,
"a lone `{line}` and {ending:?}"
);
}
}
}
#[test]
fn bare_marker_lines_are_not_called_utf16() {
two_bare_lines_say_nothing(parser::markers(), MARKER_LINES.len());
}
#[test]
fn a_utf16_patch_with_one_line_left_to_read_is_not_named() {
for ending in LINE_ENDINGS {
let diff = format!(
"--- \u{444}{ending}+++ \u{444}{ending}\
@@ -1 +1 @@{ending}-a{ending}+b{ending}"
);
let utf16 = as_utf16le(&diff);
assert_eq!(utf16_without_bom(&utf16), None, "{ending:?}");
}
}
#[test]
fn bare_extended_headers_are_not_called_utf16() {
two_bare_lines_say_nothing(parser::headers_ascii_follows(), HEADERS_ASCII_FOLLOWS_LEN);
}
#[test]
fn extended_headers_carrying_something_are_named_by_their_encoding() {
two_lines_carrying_a_tail_name_the_encoding(
parser::headers_ascii_follows(),
HEADERS_ASCII_FOLLOWS_LEN,
&["1"],
);
}
#[test]
fn bare_headers_a_path_follows_are_not_called_utf16() {
two_bare_lines_say_nothing(parser::headers_a_path_follows(), HEADERS_A_PATH_FOLLOWS_LEN);
}
#[test]
fn headers_a_path_follows_are_named_by_their_encoding_where_the_path_stands_in_the_line() {
two_lines_carrying_a_tail_name_the_encoding(
parser::headers_a_path_follows(),
HEADERS_A_PATH_FOLLOWS_LEN,
&["\u{444}", "f"],
);
}
#[test]
fn a_diff_line_in_company_is_named_by_its_encoding() {
for ending in LINE_ENDINGS {
for (_, line) in MARKER_LINES {
let lines = format!("{line}{ending}index 111..222 100644{ending}");
for (to_bytes, encoding) in [
(u16::to_le_bytes as fn(u16) -> [u8; 2], "UTF-16LE"),
(u16::to_be_bytes as fn(u16) -> [u8; 2], "UTF-16BE"),
] {
let window = between_binary_noise(&lines, to_bytes);
assert_eq!(
utf16_without_bom(&window),
Some(encoding),
"`{line}` with a header, {encoding} and {ending:?}"
);
}
}
}
}
#[test]
fn read_limited_rejects_oversized_input() {
let data = b"0123456789";
let err = read_limited(&data[..], 4).unwrap_err();
assert!(matches!(err, AppError::Usage(_)));
}
}