use std::path::Path;
#[derive(Debug)]
struct FilePatch {
path: String,
hunks: Vec<Hunk>,
}
#[derive(Debug)]
struct Hunk {
old_start: usize,
lines: Vec<DiffLine>,
}
#[derive(Debug, Clone)]
enum DiffLine {
Context(String),
Add(String),
Remove(String),
}
pub trait PatchApplier: Send + Sync {
fn apply(&self, root: &Path, diff: &str) -> anyhow::Result<()>;
fn name(&self) -> &'static str;
}
pub struct FuzzyApplier;
impl PatchApplier for FuzzyApplier {
fn apply(&self, root: &Path, diff: &str) -> anyhow::Result<()> {
fuzzy_apply_patch(root, diff)
}
fn name(&self) -> &'static str {
"fuzzy"
}
}
#[cfg(feature = "applier-diffy")]
pub struct DiffyApplier;
#[cfg(feature = "applier-diffy")]
impl PatchApplier for DiffyApplier {
fn apply(&self, root: &Path, diff: &str) -> anyhow::Result<()> {
diffy_apply_patch(root, diff)
}
fn name(&self) -> &'static str {
"diffy"
}
}
pub fn applier_from_env() -> Box<dyn PatchApplier> {
match std::env::var("NEWT_PATCH_APPLIER").ok().as_deref() {
Some("diffy") => diffy_applier(),
Some("fuzzy") | None => Box::new(FuzzyApplier),
Some(other) => {
tracing::warn!(applier = %other, "unknown NEWT_PATCH_APPLIER; using fuzzy");
Box::new(FuzzyApplier)
}
}
}
#[cfg(feature = "applier-diffy")]
fn diffy_applier() -> Box<dyn PatchApplier> {
Box::new(DiffyApplier)
}
#[cfg(not(feature = "applier-diffy"))]
fn diffy_applier() -> Box<dyn PatchApplier> {
tracing::warn!(
"NEWT_PATCH_APPLIER=diffy requested but the `applier-diffy` feature is \
not compiled in; falling back to the fuzzy applier"
);
Box::new(FuzzyApplier)
}
pub fn apply_patch(root: &Path, diff: &str) -> anyhow::Result<()> {
applier_from_env().apply(root, diff)
}
fn is_workspace_contained(rel: &Path) -> bool {
use std::path::Component;
rel.components()
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
}
fn escape_error(rel: &Path) -> anyhow::Error {
anyhow::anyhow!(
"refusing to write '{}': path escapes the workspace (absolute or `..` component)",
rel.display()
)
}
#[cfg(target_os = "linux")]
fn write_contained(workspace: &Path, rel: &str, content: &[u8]) -> anyhow::Result<()> {
use newt_core::fs_cap::WorkspaceDir;
use std::io::Write;
let rel_path = Path::new(rel);
let dir = WorkspaceDir::open_root(workspace)?;
if let Some(parent) = rel_path.parent() {
if !parent.as_os_str().is_empty() {
dir.create_dir_all(parent)?;
}
}
let tmp_rel = sibling_tmp(rel_path);
{
let mut f = dir.create(&tmp_rel)?;
f.write_all(content)?;
}
dir.rename(&tmp_rel, rel_path)?;
Ok(())
}
#[cfg(not(target_os = "linux"))]
fn write_contained(workspace: &Path, rel: &str, content: &[u8]) -> anyhow::Result<()> {
let abs = workspace.join(rel);
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = abs.with_extension("newt-tmp");
std::fs::write(&tmp, content)?;
std::fs::rename(&tmp, &abs)?;
Ok(())
}
#[cfg(target_os = "linux")]
fn read_contained_opt(workspace: &Path, rel: &str) -> anyhow::Result<Option<String>> {
use newt_core::fs_cap::WorkspaceDir;
use std::io::Read;
let dir = WorkspaceDir::open_root(workspace)?;
match dir.open(Path::new(rel)) {
Ok(mut f) => {
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(Some(s))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
#[cfg(not(target_os = "linux"))]
fn read_contained_opt(workspace: &Path, rel: &str) -> anyhow::Result<Option<String>> {
let abs = workspace.join(rel);
if abs.exists() {
Ok(Some(std::fs::read_to_string(&abs)?))
} else {
Ok(None)
}
}
#[cfg(target_os = "linux")]
fn sibling_tmp(rel: &Path) -> std::path::PathBuf {
let mut name = rel
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("newt"))
.to_os_string();
name.push(".newt-tmp");
match rel.parent() {
Some(p) if !p.as_os_str().is_empty() => p.join(name),
_ => std::path::PathBuf::from(name),
}
}
fn fuzzy_apply_patch(root: &Path, diff: &str) -> anyhow::Result<()> {
let patches = parse_unified_diff(diff)?;
if patches.is_empty() {
anyhow::bail!("no file patches found in diff");
}
let mut results: Vec<(String, String)> = Vec::new();
for patch in &patches {
if !is_workspace_contained(Path::new(&patch.path)) {
return Err(escape_error(Path::new(&patch.path)));
}
let original = read_contained_opt(root, &patch.path)?.unwrap_or_default();
let result = apply_hunks(&original, &patch.hunks)?;
results.push((patch.path.clone(), result));
}
for (rel, content) in &results {
write_contained(root, rel, content.as_bytes())?;
}
Ok(())
}
pub fn edit(path: &Path, patch: &str) -> anyhow::Result<()> {
let root = path.parent().unwrap_or(Path::new("."));
apply_patch(root, patch)
}
pub fn apply_whole_files<P, M, S, T>(workspace: P, files: M) -> anyhow::Result<Vec<String>>
where
P: AsRef<Path>,
M: IntoIterator<Item = (S, T)>,
S: Into<String>,
T: AsRef<str>,
{
let workspace = workspace.as_ref();
let mut written = Vec::new();
for (rel, contents) in files {
let rel = rel.into();
if !is_workspace_contained(Path::new(&rel)) {
return Err(escape_error(Path::new(&rel)));
}
write_contained(workspace, &rel, contents.as_ref().as_bytes())?;
written.push(rel);
}
Ok(written)
}
#[cfg(feature = "applier-diffy")]
fn diffy_apply_patch(root: &Path, diff: &str) -> anyhow::Result<()> {
let sections = split_file_sections(diff);
if sections.is_empty() {
anyhow::bail!("no file patches found in diff");
}
let mut results: Vec<(String, String)> = Vec::new();
for section in §ions {
let patch = diffy::Patch::from_str(section)
.map_err(|e| anyhow::anyhow!("diffy parse error: {e}"))?;
let plus = section
.lines()
.find(|l| l.starts_with("+++ "))
.ok_or_else(|| anyhow::anyhow!("diff section missing +++ header"))?;
let rel = extract_path(plus)?;
if !is_workspace_contained(Path::new(&rel)) {
return Err(escape_error(Path::new(&rel)));
}
let original = read_contained_opt(root, &rel)?.unwrap_or_default();
let patched = diffy::apply(&original, &patch)
.map_err(|e| anyhow::anyhow!("diffy rejected patch for {rel}: {e}"))?;
results.push((rel, patched));
}
for (rel, content) in &results {
write_contained(root, rel, content.as_bytes())?;
}
Ok(())
}
#[cfg(feature = "applier-diffy")]
fn split_file_sections(diff: &str) -> Vec<String> {
let mut sections: Vec<String> = Vec::new();
let mut cur: Option<String> = None;
for line in diff.lines() {
if line.starts_with("--- ") {
if let Some(prev) = cur.take() {
sections.push(prev);
}
cur = Some(String::new());
}
if let Some(buf) = cur.as_mut() {
buf.push_str(line);
buf.push('\n');
}
}
if let Some(last) = cur {
sections.push(last);
}
sections
}
fn parse_unified_diff(diff: &str) -> anyhow::Result<Vec<FilePatch>> {
let lines: Vec<&str> = diff.lines().collect();
let mut patches = Vec::new();
let mut i = 0;
while i < lines.len() {
if lines[i].starts_with("--- ") {
if i + 1 >= lines.len() || !lines[i + 1].starts_with("+++ ") {
anyhow::bail!("expected +++ header after --- at line {}", i + 1);
}
let path = extract_path(lines[i + 1])?;
i += 2;
let mut hunks = Vec::new();
while i < lines.len() && lines[i].starts_with("@@ ") {
let (hunk, consumed) = parse_hunk(&lines[i..])?;
hunks.push(hunk);
i += consumed;
}
if hunks.is_empty() {
anyhow::bail!("file patch for '{path}' has no hunks");
}
patches.push(FilePatch { path, hunks });
} else {
i += 1;
}
}
Ok(patches)
}
fn extract_path(line: &str) -> anyhow::Result<String> {
let rest = line
.strip_prefix("+++ ")
.ok_or_else(|| anyhow::anyhow!("invalid +++ line: {line}"))?;
let path = rest.strip_prefix("b/").unwrap_or(rest);
if path.is_empty() || path == "/dev/null" {
anyhow::bail!("cannot determine target path from: {line}");
}
Ok(path.to_string())
}
fn parse_hunk(lines: &[&str]) -> anyhow::Result<(Hunk, usize)> {
let header = lines[0];
let (old_start, _, _, _) = parse_hunk_header(header)?;
let mut diff_lines = Vec::new();
let mut i = 1;
while i < lines.len() {
let line = lines[i];
if line.starts_with("@@ ") || line.starts_with("--- ") {
break;
}
if let Some(rest) = line.strip_prefix(' ') {
diff_lines.push(DiffLine::Context(rest.to_string()));
} else if let Some(rest) = line.strip_prefix('+') {
diff_lines.push(DiffLine::Add(rest.to_string()));
} else if let Some(rest) = line.strip_prefix('-') {
diff_lines.push(DiffLine::Remove(rest.to_string()));
} else if line == "\\ No newline at end of file" {
} else {
diff_lines.push(DiffLine::Context(line.to_string()));
}
i += 1;
}
Ok((
Hunk {
old_start,
lines: diff_lines,
},
i,
))
}
fn parse_hunk_header(header: &str) -> anyhow::Result<(usize, usize, usize, usize)> {
let inner = header
.strip_prefix("@@ ")
.and_then(|s| s.split(" @@").next())
.ok_or_else(|| anyhow::anyhow!("malformed hunk header: {header}"))?;
let parts: Vec<&str> = inner.split_whitespace().collect();
if parts.len() != 2 {
anyhow::bail!("malformed hunk header: {header}");
}
let (old_start, old_count) = parse_range(parts[0].strip_prefix('-').unwrap_or(parts[0]))?;
let (new_start, new_count) = parse_range(parts[1].strip_prefix('+').unwrap_or(parts[1]))?;
Ok((old_start, old_count, new_start, new_count))
}
fn parse_range(s: &str) -> anyhow::Result<(usize, usize)> {
if let Some((start, count)) = s.split_once(',') {
Ok((start.parse()?, count.parse()?))
} else {
Ok((s.parse()?, 1))
}
}
const SEARCH_WINDOW: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FuzzLevel {
Exact,
TrailingWs,
Trim,
}
fn lines_match(expected: &str, actual: &str, level: FuzzLevel) -> bool {
match level {
FuzzLevel::Exact => expected == actual,
FuzzLevel::TrailingWs => expected.trim_end() == actual.trim_end(),
FuzzLevel::Trim => expected.trim() == actual.trim(),
}
}
fn hunk_old_side(hunk: &Hunk) -> Vec<&str> {
hunk.lines
.iter()
.filter_map(|l| match l {
DiffLine::Context(t) | DiffLine::Remove(t) => Some(t.as_str()),
DiffLine::Add(_) => None,
})
.collect()
}
fn old_side_matches_at(old_side: &[&str], old_lines: &[&str], at: usize, level: FuzzLevel) -> bool {
if at + old_side.len() > old_lines.len() {
return false;
}
old_side
.iter()
.zip(&old_lines[at..at + old_side.len()])
.all(|(exp, act)| lines_match(exp, act, level))
}
fn locate_hunk(hunk: &Hunk, old_lines: &[&str]) -> Option<(usize, FuzzLevel)> {
let old_side = hunk_old_side(hunk);
if old_side.is_empty() {
let hint = hunk.old_start.saturating_sub(1).min(old_lines.len());
return Some((hint, FuzzLevel::Exact));
}
let hint = if hunk.old_start == 0 {
0
} else {
(hunk.old_start - 1).min(old_lines.len())
};
let candidate_positions = || {
std::iter::once(hint).chain((1..=SEARCH_WINDOW).flat_map(move |d| {
let up = hint.checked_add(d);
let down = hint.checked_sub(d);
[up, down].into_iter().flatten()
}))
};
for at in candidate_positions() {
if old_side_matches_at(&old_side, old_lines, at, FuzzLevel::Exact) {
return Some((at, FuzzLevel::Exact));
}
}
for level in [FuzzLevel::TrailingWs, FuzzLevel::Trim] {
let mut found: Option<usize> = None;
let mut count = 0usize;
for at in 0..=old_lines.len().saturating_sub(old_side.len()) {
if old_side_matches_at(&old_side, old_lines, at, level) {
count += 1;
let within_window = at.abs_diff(hint) <= SEARCH_WINDOW;
if within_window && found.is_none() {
found = Some(at);
}
}
}
if count == 1 {
if let Some(at) = found {
return Some((at, level));
}
}
}
None
}
fn apply_hunks(original: &str, hunks: &[Hunk]) -> anyhow::Result<String> {
let old_lines: Vec<&str> = if original.is_empty() {
Vec::new()
} else {
original.lines().collect()
};
let mut result = Vec::new();
let mut old_idx: usize = 0;
for hunk in hunks {
let (hunk_start, _fuzz) = locate_hunk(hunk, &old_lines).ok_or_else(|| {
let first_anchor = hunk
.lines
.iter()
.find_map(|l| match l {
DiffLine::Context(t) | DiffLine::Remove(t) => Some(t.clone()),
DiffLine::Add(_) => None,
})
.unwrap_or_default();
anyhow::anyhow!(
"context mismatch: hunk near line {} does not match the file \
(no location found within {} lines even with whitespace \
tolerance); first expected context/remove line was '{}'",
hunk.old_start,
SEARCH_WINDOW,
first_anchor
)
})?;
if hunk_start > old_lines.len() {
anyhow::bail!(
"hunk starts at line {} but file only has {} lines",
hunk.old_start,
old_lines.len()
);
}
if hunk_start < old_idx {
anyhow::bail!(
"context mismatch: hunk near line {} resolves to line {} which \
overlaps an already-applied hunk",
hunk.old_start,
hunk_start + 1
);
}
while old_idx < hunk_start {
result.push(old_lines[old_idx].to_string());
old_idx += 1;
}
for diff_line in &hunk.lines {
match diff_line {
DiffLine::Context(_) => {
result.push(old_lines[old_idx].to_string());
old_idx += 1;
}
DiffLine::Remove(_) => {
old_idx += 1;
}
DiffLine::Add(text) => {
result.push(text.clone());
}
}
}
}
while old_idx < old_lines.len() {
result.push(old_lines[old_idx].to_string());
old_idx += 1;
}
let mut output = result.join("\n");
if original.is_empty() || original.ends_with('\n') {
output.push('\n');
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn single_file_add_line() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("hello.txt");
fs::write(&file, "line1\nline2\nline3\n").unwrap();
let diff = "\
--- a/hello.txt
+++ b/hello.txt
@@ -1,3 +1,4 @@
line1
+inserted
line2
line3
";
apply_patch(tmp.path(), diff).unwrap();
let result = fs::read_to_string(&file).unwrap();
assert_eq!(result, "line1\ninserted\nline2\nline3\n");
}
#[test]
fn apply_whole_files_rejects_path_escape() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
fs::create_dir(&ws).unwrap();
let outside = tmp.path().join("escape.txt");
let err = apply_whole_files(&ws, [("../escape.txt", "pwned")]).unwrap_err();
assert!(
err.to_string().contains("escapes the workspace"),
"got: {err}"
);
assert!(
!outside.exists(),
"`..` escape must not write outside the workspace"
);
let abs_target = tmp.path().join("abs-escape.txt");
let abs_key = abs_target.to_string_lossy().into_owned();
let err = apply_whole_files(&ws, [(abs_key.as_str(), "pwned")]).unwrap_err();
assert!(
err.to_string().contains("escapes the workspace"),
"got: {err}"
);
assert!(!abs_target.exists(), "absolute path must not be written");
let ok = apply_whole_files(&ws, [("sub/ok.rs", "fine")]).unwrap();
assert_eq!(ok, vec!["sub/ok.rs".to_string()]);
assert!(ws.join("sub/ok.rs").exists());
}
#[test]
#[cfg(target_os = "linux")]
fn apply_whole_files_denies_symlink_escape_object_bound() {
let ws = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
std::fs::write(outside.path().join("evil.rs"), "original\n").unwrap();
std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
let result = apply_whole_files(ws.path(), [("link/evil.rs".to_string(), "PWNED\n")]);
assert!(
result.is_err(),
"a symlink-escape write must be refused: {result:?}"
);
assert_eq!(
std::fs::read_to_string(outside.path().join("evil.rs")).unwrap(),
"original\n",
"the outside file must be UNCHANGED — the write never escaped"
);
}
#[test]
fn apply_patch_rejects_path_escape() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
fs::create_dir(&ws).unwrap();
let outside = tmp.path().join("escape.txt");
let diff = "\
--- a/../escape.txt
+++ b/../escape.txt
@@ -0,0 +1 @@
+pwned
";
let err = apply_patch(&ws, diff).unwrap_err();
assert!(
err.to_string().contains("escapes the workspace"),
"got: {err}"
);
assert!(
!outside.exists(),
"diff `..` escape must not write outside the workspace"
);
}
#[test]
fn is_workspace_contained_rejects_escapes() {
assert!(is_workspace_contained(Path::new("a/b.rs")));
assert!(is_workspace_contained(Path::new("./a.rs")));
assert!(is_workspace_contained(Path::new("sub/dir/file.txt")));
assert!(!is_workspace_contained(Path::new("../a.rs")));
assert!(!is_workspace_contained(Path::new("a/../../b")));
assert!(!is_workspace_contained(Path::new("/etc/passwd")));
}
#[test]
fn single_file_remove_line() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("hello.txt");
fs::write(&file, "line1\nline2\nline3\n").unwrap();
let diff = "\
--- a/hello.txt
+++ b/hello.txt
@@ -1,3 +1,2 @@
line1
-line2
line3
";
apply_patch(tmp.path(), diff).unwrap();
let result = fs::read_to_string(&file).unwrap();
assert_eq!(result, "line1\nline3\n");
}
#[test]
fn multi_hunk() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("code.rs");
fs::write(&file, "a\nb\nc\nd\ne\nf\ng\n").unwrap();
let diff = "\
--- a/code.rs
+++ b/code.rs
@@ -1,3 +1,3 @@
a
-b
+B
c
@@ -5,3 +5,3 @@
e
-f
+F
g
";
apply_patch(tmp.path(), diff).unwrap();
let result = fs::read_to_string(&file).unwrap();
assert_eq!(result, "a\nB\nc\nd\ne\nF\ng\n");
}
#[test]
fn new_file() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("new.txt");
assert!(!file.exists());
let diff = "\
--- /dev/null
+++ b/new.txt
@@ -0,0 +1,2 @@
+hello
+world
";
apply_patch(tmp.path(), diff).unwrap();
let result = fs::read_to_string(&file).unwrap();
assert_eq!(result, "hello\nworld\n");
}
#[test]
fn context_mismatch_rejected() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("hello.txt");
fs::write(&file, "actual\nline2\n").unwrap();
let diff = "\
--- a/hello.txt
+++ b/hello.txt
@@ -1,2 +1,2 @@
expected
-line2
+replaced
";
let err = apply_patch(tmp.path(), diff).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("context mismatch"),
"expected context mismatch error, got: {msg}"
);
}
#[test]
fn malformed_diff_rejected() {
let tmp = TempDir::new().unwrap();
let err = apply_patch(tmp.path(), "this is not a diff").unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("no file patches found"),
"unexpected error: {msg}"
);
}
#[test]
fn atomic_write_verified() {
let tmp = TempDir::new().unwrap();
let file1 = tmp.path().join("ok.txt");
let file2 = tmp.path().join("fail.txt");
fs::write(&file1, "aaa\nbbb\n").unwrap();
fs::write(&file2, "xxx\nyyy\n").unwrap();
let diff = "\
--- a/ok.txt
+++ b/ok.txt
@@ -1,2 +1,2 @@
aaa
-bbb
+ccc
--- a/fail.txt
+++ b/fail.txt
@@ -1,2 +1,2 @@
WRONG_CONTEXT
-yyy
+zzz
";
let err = apply_patch(tmp.path(), diff).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("mismatch"), "expected mismatch error: {msg}");
let content1 = fs::read_to_string(&file1).unwrap();
assert_eq!(content1, "aaa\nbbb\n", "first file should be unchanged");
let content2 = fs::read_to_string(&file2).unwrap();
assert_eq!(content2, "xxx\nyyy\n", "second file should be unchanged");
}
#[test]
fn edit_applies_patch() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("hello.txt");
fs::write(&file, "line1\nline2\n").unwrap();
let diff = "\
--- a/hello.txt
+++ b/hello.txt
@@ -1,2 +1,2 @@
line1
-line2
+edited
";
edit(&file, diff).unwrap();
let result = fs::read_to_string(&file).unwrap();
assert_eq!(result, "line1\nedited\n");
}
#[test]
fn apply_whole_files_writes_single_file() {
let tmp = TempDir::new().unwrap();
let files = vec![("src/lib.rs".to_string(), "pub fn hello() {}\n".to_string())];
let written = apply_whole_files(tmp.path(), files).unwrap();
assert_eq!(written, vec!["src/lib.rs".to_string()]);
let got = fs::read_to_string(tmp.path().join("src/lib.rs")).unwrap();
assert_eq!(got, "pub fn hello() {}\n");
}
#[test]
fn apply_whole_files_creates_parent_dirs() {
let tmp = TempDir::new().unwrap();
let files = vec![("a/b/c/d.rs".to_string(), "fn x() {}\n".to_string())];
apply_whole_files(tmp.path(), files).unwrap();
assert!(tmp.path().join("a/b/c/d.rs").exists());
}
#[test]
fn apply_whole_files_overwrites_existing() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("src/lib.rs");
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(&file, "OLD\n").unwrap();
let files = vec![("src/lib.rs".to_string(), "NEW\n".to_string())];
apply_whole_files(tmp.path(), files).unwrap();
let got = fs::read_to_string(&file).unwrap();
assert_eq!(got, "NEW\n");
}
#[test]
fn apply_whole_files_no_tmp_residue() {
let tmp = TempDir::new().unwrap();
let files = vec![
("a.rs".to_string(), "fn a() {}".to_string()),
("b.rs".to_string(), "fn b() {}".to_string()),
];
apply_whole_files(tmp.path(), files).unwrap();
for entry in fs::read_dir(tmp.path()).unwrap() {
let entry = entry.unwrap();
let name = entry.file_name().to_string_lossy().to_string();
assert!(
!name.ends_with(".newt-coder-tmp"),
"leftover temp file: {name}"
);
}
}
#[test]
fn apply_whole_files_accepts_str_slice_values() {
let tmp = TempDir::new().unwrap();
let files: Vec<(&str, &str)> = vec![("hello.txt", "hi\n")];
let written = apply_whole_files(tmp.path(), files).unwrap();
assert_eq!(written, vec!["hello.txt".to_string()]);
let got = fs::read_to_string(tmp.path().join("hello.txt")).unwrap();
assert_eq!(got, "hi\n");
}
#[test]
fn edit_error_propagated() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("hello.txt");
fs::write(&file, "aaa\n").unwrap();
let diff = "\
--- a/hello.txt
+++ b/hello.txt
@@ -1,1 +1,1 @@
WRONG
";
let err = edit(&file, diff).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("mismatch"), "expected mismatch error: {msg}");
}
#[test]
fn hunk_old_start_off_by_plus_two_still_applies() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("lib.rs");
fs::write(&file, "alpha\nbeta\ngamma\ndelta\n").unwrap();
let diff = "\
--- a/lib.rs
+++ b/lib.rs
@@ -3,2 +3,3 @@
alpha
+INSERTED
beta
";
apply_patch(tmp.path(), diff).unwrap();
let result = fs::read_to_string(&file).unwrap();
assert_eq!(result, "alpha\nINSERTED\nbeta\ngamma\ndelta\n");
}
#[test]
fn hunk_old_start_off_by_minus_two_still_applies() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("lib.rs");
fs::write(&file, "a\nb\nc\nTARGET\nd\ne\n").unwrap();
let diff = "\
--- a/lib.rs
+++ b/lib.rs
@@ -6,1 +6,2 @@
TARGET
+AFTER
";
apply_patch(tmp.path(), diff).unwrap();
let result = fs::read_to_string(&file).unwrap();
assert_eq!(result, "a\nb\nc\nTARGET\nAFTER\nd\ne\n");
}
#[test]
fn hunk_with_trailing_whitespace_context_applies() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("lib.rs");
fs::write(&file, "pub fn hello() {\n body();\n}\n").unwrap();
let diff = "\
--- a/lib.rs
+++ b/lib.rs
@@ -1,3 +1,4 @@
pub fn hello() {
+ setup();
body();
}
";
apply_patch(tmp.path(), diff).unwrap();
let result = fs::read_to_string(&file).unwrap();
assert_eq!(
result, "pub fn hello() {\n setup();\n body();\n}\n",
"context must come from the file, not the fuzzed hunk text"
);
}
#[test]
fn hunk_with_leading_whitespace_diff_applies_via_trim() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("lib.rs");
fs::write(&file, "fn main() {\n let x = 1;\n}\n").unwrap();
let diff = "\
--- a/lib.rs
+++ b/lib.rs
@@ -1,3 +1,4 @@
fn main() {
let x = 1;
+ let y = 2;
}
";
apply_patch(tmp.path(), diff).unwrap();
let result = fs::read_to_string(&file).unwrap();
assert_eq!(result, "fn main() {\n let x = 1;\n let y = 2;\n}\n");
}
#[test]
fn genuinely_non_matching_hunk_still_rejected() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("lib.rs");
fs::write(&file, "fn main() { println!(\"hello\"); }\n").unwrap();
let diff = "\
--- a/lib.rs
+++ b/lib.rs
@@ -1,1 +1,1 @@
fn TOTALLY_WRONG() {
-old
+new
";
let err = apply_patch(tmp.path(), diff).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("context mismatch"),
"expected context mismatch error, got: {msg}"
);
assert_eq!(
fs::read_to_string(&file).unwrap(),
"fn main() { println!(\"hello\"); }\n"
);
}
#[test]
fn exact_match_preferred_over_later_fuzzy_match() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("lib.rs");
fs::write(&file, "marker\nmarker \nother\n").unwrap();
let diff = "\
--- a/lib.rs
+++ b/lib.rs
@@ -1,1 +1,2 @@
marker
+ADDED
";
apply_patch(tmp.path(), diff).unwrap();
let result = fs::read_to_string(&file).unwrap();
assert_eq!(result, "marker\nADDED\nmarker \nother\n");
}
#[test]
fn ambiguous_whitespace_fuzz_is_rejected() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("lib.rs");
fs::write(&file, "dup\nfiller\ndup\n").unwrap();
let ctx = " dup "; let diff = format!("--- a/lib.rs\n+++ b/lib.rs\n@@ -1,1 +1,2 @@\n{ctx}\n+ADDED\n");
let err = apply_patch(tmp.path(), &diff).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("context mismatch"),
"ambiguous fuzzy match must be rejected, got: {msg}"
);
}
#[test]
fn clean_diff_with_correct_line_numbers_unaffected() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("lib.rs");
fs::write(&file, "one\ntwo\nthree\nfour\n").unwrap();
let diff = "\
--- a/lib.rs
+++ b/lib.rs
@@ -2,2 +2,2 @@
two
-three
+THREE
";
apply_patch(tmp.path(), diff).unwrap();
let result = fs::read_to_string(&file).unwrap();
assert_eq!(result, "one\ntwo\nTHREE\nfour\n");
}
}