use crate::cli::global::GlobalFlags;
use crate::diff::{DiffResult, format_diff_result_colored};
use crate::exit;
use crate::ops::patch::{
ApplyHunksOptions, ApplyHunksResult, ApplyHunksStatus, OnStale, apply_hunks,
apply_hunks_with_options, parse_patch,
};
use crate::plan::Operation;
use crate::tx::engine::WriteSource;
use clap::Args;
use serde::Serialize;
#[derive(Debug, Args)]
#[command(after_help = "\
EXAMPLES:
patchloom patch apply changes.patch
patchloom patch apply changes.patch --apply
patchloom patch check changes.patch
patchloom patch merge changes.patch --check
patchloom patch merge changes.patch --apply --allow-conflicts")]
pub struct PatchArgs {
#[command(subcommand)]
pub action: PatchAction,
#[command(flatten)]
pub write: crate::cli::global::WriteFlags,
}
#[derive(Debug, clap::Subcommand)]
pub enum PatchAction {
Check {
file: Option<String>,
#[arg(long)]
stdin: bool,
},
Apply {
file: Option<String>,
#[arg(long)]
stdin: bool,
#[arg(long, value_enum, default_value_t = OnStaleCli::Fail)]
on_stale: OnStaleCli,
},
Merge {
file: Option<String>,
#[arg(long)]
stdin: bool,
#[arg(long)]
allow_conflicts: bool,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum OnStaleCli {
#[default]
Fail,
Merge,
}
impl From<OnStaleCli> for OnStale {
fn from(value: OnStaleCli) -> Self {
match value {
OnStaleCli::Fail => OnStale::Fail,
OnStaleCli::Merge => OnStale::Merge,
}
}
}
enum DiffReadError {
NoSource,
IoError(String, std::io::Error),
StdinError(std::io::Error),
}
fn read_diff_input(
file: &Option<String>,
stdin_flag: bool,
global: &GlobalFlags,
) -> Result<String, DiffReadError> {
if let Some(path) = file {
if path == "-" {
std::io::read_to_string(std::io::stdin()).map_err(DiffReadError::StdinError)
} else {
let full = global
.resolve_user_path(path)
.map_err(|e| DiffReadError::IoError(path.clone(), std::io::Error::other(e)))?;
std::fs::read_to_string(&full)
.map_err(|e| DiffReadError::IoError(full.display().to_string(), e))
}
} else if stdin_flag {
std::io::read_to_string(std::io::stdin()).map_err(DiffReadError::StdinError)
} else {
Err(DiffReadError::NoSource)
}
}
#[derive(Debug, Clone, Serialize)]
struct PatchFileResult {
path: String,
status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
conflicts: Option<usize>,
}
#[derive(Debug, Serialize)]
struct PatchFilesOutput {
ok: bool,
files: Vec<PatchFileResult>,
}
fn patch_file_result(path: &str, applied: &ApplyHunksResult) -> PatchFileResult {
PatchFileResult {
path: path.to_string(),
status: applied.status.as_str(),
error: None,
conflicts: if applied.conflicts.is_empty() {
None
} else {
Some(applied.conflicts.len())
},
}
}
fn build_file_results(
diffs: &[crate::diff::FileDiff],
status: &'static str,
) -> Vec<PatchFileResult> {
diffs
.iter()
.filter(|d| d.has_changes)
.map(|d| PatchFileResult {
path: d.path.clone(),
status,
error: None,
conflicts: None,
})
.collect()
}
fn apply_patch_file(
original: &str,
hunks: &[crate::ops::patch::Hunk],
options: ApplyHunksOptions,
) -> Result<ApplyHunksResult, String> {
apply_hunks_with_options(original, hunks, options)
}
fn inject_stale_label(msg: &str, label: &str) -> String {
if let Some(idx) = msg.find(" -- ") {
let (prefix, rest) = msg.split_at(idx + 4);
format!("{prefix}{label}: {rest}")
} else {
format!("{msg} ({label})")
}
}
fn emit_error(global: &GlobalFlags, error: &str) -> anyhow::Result<()> {
global.emit_error_json(error)
}
fn emit_patch_files_output(
global: &GlobalFlags,
ok: bool,
results: &[PatchFileResult],
) -> anyhow::Result<()> {
if global.json {
let output = PatchFilesOutput {
ok,
files: results.to_vec(),
};
global.emit_json(&output)?;
} else if global.jsonl {
global.emit_json_items(results)?;
} else if !global.quiet {
for r in results {
let label = match r.status {
"clean" => "clean",
"stale" => "STALE",
"missing" => "MISSING",
"error" => "ERROR",
"conflict" => "CONFLICT",
"applied" => "applied",
other => other,
};
if let Some(err) = &r.error {
eprintln!("patch check: {} -- {}: {}", r.path, label, err);
} else if let Some(n) = r.conflicts {
eprintln!("patch check: {} -- {} ({} conflicts)", r.path, label, n);
} else if r.status != "clean" && r.status != "applied" {
eprintln!("patch check: {} -- {}", r.path, label);
}
}
}
Ok(())
}
pub fn run(args: PatchArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
crate::verbose!(
"patch: action={:?}, apply={}, check={}",
std::mem::discriminant(&args.action),
global.apply,
global.check
);
let (file, stdin_flag, merge_mode, apply_options) = match &args.action {
PatchAction::Check { file, stdin } => {
(file.clone(), *stdin, false, ApplyHunksOptions::default())
}
PatchAction::Apply {
file,
stdin,
on_stale,
} => (
file.clone(),
*stdin,
false,
ApplyHunksOptions {
on_stale: (*on_stale).into(),
allow_conflicts: false,
},
),
PatchAction::Merge {
file,
stdin,
allow_conflicts,
} => (
file.clone(),
*stdin,
true,
ApplyHunksOptions {
on_stale: OnStale::Merge,
allow_conflicts: *allow_conflicts,
},
),
};
let cwd = global.resolve_cwd()?;
let diff_text = match read_diff_input(&file, stdin_flag, global) {
Ok(text) => text,
Err(DiffReadError::NoSource) => {
emit_error(global, "patch: must specify --file <path> or --stdin")?;
return Ok(exit::PARSE_ERROR);
}
Err(DiffReadError::IoError(path, e)) => {
emit_error(global, &format!("patch: failed to read '{path}': {e}"))?;
return Ok(exit::PARSE_ERROR);
}
Err(DiffReadError::StdinError(e)) => {
emit_error(global, &format!("patch: failed to read stdin: {e}"))?;
return Ok(exit::PARSE_ERROR);
}
};
crate::verbose!("patch: diff text length={}", diff_text.len());
let patch_files = match parse_patch(&diff_text) {
Ok(pf) => pf,
Err(msg) => {
emit_error(global, &format!("patch: parse error: {msg}"))?;
return Ok(exit::PARSE_ERROR);
}
};
crate::verbose!(
"patch: parsed {} file(s), merge_mode={}",
patch_files.len(),
merge_mode
);
if matches!(args.action, PatchAction::Check { .. }) {
let mut all_clean = true;
let mut results = Vec::new();
for pf in &patch_files {
let file_path = cwd.join(&pf.path);
let original = match std::fs::read_to_string(&file_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if pf.is_creation {
String::new()
} else {
let msg = format!("file not found: {}", file_path.display());
results.push(PatchFileResult {
path: pf.path.clone(),
status: "missing",
error: Some(msg.clone()),
conflicts: None,
});
all_clean = false;
continue;
}
}
Err(e) => {
let msg = format!("failed to read {}: {}", file_path.display(), e);
results.push(PatchFileResult {
path: pf.path.clone(),
status: "error",
error: Some(msg.clone()),
conflicts: None,
});
if !global.json && !global.jsonl && !global.quiet {
eprintln!("patch check: {} -- READ ERROR: {}", pf.path, msg);
}
all_clean = false;
continue;
}
};
if apply_hunks(&original, &pf.hunks).is_ok() {
results.push(PatchFileResult {
path: pf.path.clone(),
status: "clean",
error: None,
conflicts: None,
});
} else {
all_clean = false;
results.push(PatchFileResult {
path: pf.path.clone(),
status: "stale",
error: None,
conflicts: None,
});
}
}
emit_patch_files_output(global, all_clean, &results)?;
return Ok(if all_clean {
exit::SUCCESS
} else {
exit::AMBIGUOUS
});
}
if merge_mode && (global.check || (!global.apply && !global.confirm)) {
let check_options = ApplyHunksOptions {
on_stale: OnStale::Merge,
allow_conflicts: true,
};
let mut results = Vec::new();
let mut all_ok = true;
for pf in &patch_files {
let file_path = cwd.join(&pf.path);
let original = match std::fs::read_to_string(&file_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
anyhow::bail!("patch check: cannot read {}: {e}", pf.path);
}
};
match apply_patch_file(&original, &pf.hunks, check_options) {
Ok(applied) => {
if applied.status == ApplyHunksStatus::Conflict {
all_ok = false;
}
results.push(patch_file_result(&pf.path, &applied));
}
Err(msg) => {
all_ok = false;
results.push(PatchFileResult {
path: pf.path.clone(),
status: "error",
error: Some(msg),
conflicts: None,
});
}
}
}
emit_patch_files_output(global, all_ok, &results)?;
let has_errors = results.iter().any(|r| r.status == "error");
let has_conflicts = results.iter().any(|r| r.status == "conflict");
return Ok(if has_errors {
exit::AMBIGUOUS
} else if has_conflicts && !apply_options.allow_conflicts {
exit::CONFLICTS
} else {
exit::CHANGES_DETECTED
});
}
let op = Operation::PatchApply {
diff: diff_text,
on_stale: apply_options.on_stale,
allow_conflicts: apply_options.allow_conflicts,
};
let (cwd, result) =
match crate::cmd::output::stage_for_write(WriteSource::Operations(vec![op]), global) {
Ok(v) => v,
Err(e) => {
let msg = e.to_string();
let exit_code = if msg.contains("conflict(s)") {
exit::CONFLICTS
} else {
exit::AMBIGUOUS
};
let label = if merge_mode { "MERGE FAILED" } else { "STALE" };
let err = inject_stale_label(&msg, label);
emit_error(global, &err)?;
return Ok(exit_code);
}
};
use crate::cmd::write_mode::{FinalizeCallbacks, finalize_report};
finalize_report(
global,
&cwd,
result,
true,
FinalizeCallbacks {
on_check: |g: &GlobalFlags, _has: bool, diffs: &[crate::diff::FileDiff]| {
let files = build_file_results(diffs, "changed");
let changed = files.len();
if changed > 0 {
emit_patch_files_output(g, true, &files)?;
if !(g.json || g.jsonl || g.quiet) {
println!("{changed} file(s) would change");
}
}
Ok(())
},
on_apply: |g: &GlobalFlags,
_has: bool,
diffs: &[crate::diff::FileDiff],
_plain: Option<String>| {
let files = build_file_results(diffs, "applied");
emit_patch_files_output(g, true, &files)?;
Ok(())
},
on_preview: |g: &GlobalFlags,
_has: bool,
diffs: &[crate::diff::FileDiff],
_plain: Option<String>| {
if g.json || g.jsonl {
let files = build_file_results(diffs, "changed");
emit_patch_files_output(g, true, &files)?;
} else {
print!(
"{}",
format_diff_result_colored(
&DiffResult {
diffs: diffs.to_vec()
},
g.should_color()
)
);
}
Ok(())
},
after_preview_emit: |_: &GlobalFlags| {},
after_preview_apply: |_: &GlobalFlags| {},
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::global::GlobalFlags;
use tempfile::TempDir;
#[test]
fn merge_check_reports_conflict_without_writing() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("hello.txt");
std::fs::write(&file, "line1\ncompletely different\nline3\n").unwrap();
let diff_path = tmp.path().join("stale.patch");
std::fs::write(
&diff_path,
"--- a/hello.txt\n+++ b/hello.txt\n@@ -1,3 +1,3 @@\n line1\n-old line\n+new line\n line3\n",
)
.unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.check = true;
let code = run(
PatchArgs {
action: PatchAction::Merge {
file: Some(diff_path.to_string_lossy().into_owned()),
stdin: false,
allow_conflicts: false,
},
write: Default::default(),
},
&global,
)
.unwrap();
assert_eq!(code, exit::CONFLICTS);
}
#[cfg(unix)]
#[test]
fn merge_check_surfaces_io_error_for_unreadable_file() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("secret.txt");
std::fs::write(&file, "line1\nline2\nline3\n").unwrap();
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read_to_string(&file).is_ok() {
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
return;
}
let diff_path = tmp.path().join("fix.patch");
std::fs::write(
&diff_path,
"--- a/secret.txt\n+++ b/secret.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+patched\n line3\n",
)
.unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.check = true;
let result = run(
PatchArgs {
action: PatchAction::Merge {
file: Some(diff_path.to_string_lossy().into_owned()),
stdin: false,
allow_conflicts: false,
},
write: Default::default(),
},
&global,
);
assert!(result.is_err(), "expected I/O error for unreadable file");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("cannot read") || err_msg.contains("Permission denied"),
"expected permission error, got: {err_msg}"
);
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
}
#[test]
fn merge_check_treats_not_found_as_empty() {
let tmp = TempDir::new().unwrap();
let diff_path = tmp.path().join("new.patch");
std::fs::write(
&diff_path,
"--- /dev/null\n+++ b/new_file.txt\n@@ -0,0 +1 @@\n+hello\n",
)
.unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.check = true;
let code = run(
PatchArgs {
action: PatchAction::Merge {
file: Some(diff_path.to_string_lossy().into_owned()),
stdin: false,
allow_conflicts: false,
},
write: Default::default(),
},
&global,
)
.unwrap();
assert_eq!(code, exit::CHANGES_DETECTED);
}
#[test]
fn inject_stale_label_inserts_after_separator() {
let msg = "patch apply: test.txt -- hunk 1 failed: stale context";
let result = inject_stale_label(msg, "STALE");
assert_eq!(
result,
"patch apply: test.txt -- STALE: hunk 1 failed: stale context"
);
}
#[test]
fn conflict_matching_uses_precise_marker() {
let msg_with_conflicts = "patch apply: f.txt -- 2 conflict(s) found";
let exit_code = if msg_with_conflicts.contains("conflict(s)") {
exit::CONFLICTS
} else {
exit::AMBIGUOUS
};
assert_eq!(exit_code, exit::CONFLICTS);
let msg_generic = "patch apply: f.txt -- conflicting base version";
let exit_code2 = if msg_generic.contains("conflict(s)") {
exit::CONFLICTS
} else {
exit::AMBIGUOUS
};
assert_eq!(exit_code2, exit::AMBIGUOUS);
}
#[test]
fn inject_stale_label_fallback_without_separator() {
let msg = "some other error";
let result = inject_stale_label(msg, "STALE");
assert_eq!(result, "some other error (STALE)");
}
#[test]
fn patch_apply_json_output_on_success() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("test.txt");
std::fs::write(&file, "line one\nline two\nline three\n").unwrap();
let diff_path = tmp.path().join("fix.patch");
std::fs::write(
&diff_path,
"--- a/test.txt\n+++ b/test.txt\n@@ -1,3 +1,3 @@\n line one\n-line two\n+line TWO\n line three\n",
)
.unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.apply = true;
global.json = true;
let code = run(
PatchArgs {
action: PatchAction::Apply {
file: Some(diff_path.to_string_lossy().into_owned()),
stdin: false,
on_stale: OnStaleCli::Fail,
},
write: Default::default(),
},
&global,
)
.unwrap();
assert_eq!(code, exit::SUCCESS);
let content = std::fs::read_to_string(&file).unwrap();
assert!(content.contains("line TWO"), "patch should be applied");
}
}