1use std::collections::{BTreeMap, BTreeSet};
2use std::fs::{self, OpenOptions};
3use std::io::Write;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7
8use super::path::unrestricted_path;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct PatchSummary {
12 pub files: Vec<PatchFileSummary>,
13 pub snapshots: Vec<FileSnapshot>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct PatchFileSummary {
20 pub path: String,
21 pub added: usize,
22 pub removed: usize,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum FileChange {
27 Added,
28 Modified,
29 Deleted,
30}
31
32impl FileChange {
33 pub fn as_str(self) -> &'static str {
34 match self {
35 Self::Added => "added",
36 Self::Modified => "modified",
37 Self::Deleted => "deleted",
38 }
39 }
40
41 pub fn parse(value: &str) -> Option<Self> {
42 match value {
43 "added" => Some(Self::Added),
44 "modified" => Some(Self::Modified),
45 "deleted" => Some(Self::Deleted),
46 _ => None,
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct FileSnapshot {
56 pub path: PathBuf,
57 pub change: FileChange,
58 pub before: Option<String>,
59 pub after_len: u64,
60 pub after_hash: u64,
61 pub added: usize,
62 pub removed: usize,
63 pub restorable: bool,
66}
67
68pub fn coalesce_snapshots(newest_first: Vec<FileSnapshot>) -> Vec<FileSnapshot> {
80 let mut order = Vec::new();
81 let mut grouped: BTreeMap<PathBuf, (FileSnapshot, FileSnapshot)> = BTreeMap::new();
82 for snapshot in newest_first {
83 match grouped.get_mut(&snapshot.path) {
84 Some((oldest, _)) => {
86 oldest.added += snapshot.added;
87 oldest.removed += snapshot.removed;
88 let added = oldest.added;
89 let removed = oldest.removed;
90 *oldest = FileSnapshot {
91 added,
92 removed,
93 ..snapshot
94 };
95 }
96 None => {
97 order.push(snapshot.path.clone());
98 grouped.insert(snapshot.path.clone(), (snapshot.clone(), snapshot));
99 }
100 }
101 }
102
103 let mut coalesced = Vec::new();
104 for path in order {
105 let Some((oldest, newest)) = grouped.remove(&path) else {
106 continue;
107 };
108 let existed_before = oldest.change != FileChange::Added;
109 let exists_after = newest.change != FileChange::Deleted;
110 let change = match (existed_before, exists_after) {
111 (false, false) => continue,
113 (false, true) => FileChange::Added,
114 (true, false) => FileChange::Deleted,
115 (true, true) => FileChange::Modified,
116 };
117 coalesced.push(FileSnapshot {
118 change,
119 after_len: newest.after_len,
120 after_hash: newest.after_hash,
121 ..oldest
122 });
123 }
124 coalesced
125}
126
127impl FileSnapshot {
128 pub fn restore_blocker(&self) -> Option<String> {
132 if !self.restorable {
133 return Some("the previous contents were too large to keep".into());
134 }
135 let current = std::fs::read_to_string(&self.path);
136 match self.change {
137 FileChange::Deleted => match current {
138 Ok(_) => Some("something recreated this file".into()),
139 Err(_) => None,
140 },
141 FileChange::Added | FileChange::Modified => match current {
142 Ok(current)
143 if current.len() as u64 == self.after_len
144 && content_hash(¤t) == self.after_hash =>
145 {
146 None
147 }
148 Ok(_) => Some("changed since the agent last wrote it".into()),
149 Err(error) => Some(format!("unreadable: {error}")),
150 },
151 }
152 }
153
154 pub fn restore(&self) -> Result<()> {
157 match self.change {
158 FileChange::Added => std::fs::remove_file(&self.path)
159 .with_context(|| format!("remove {}", self.path.display())),
160 FileChange::Modified | FileChange::Deleted => {
161 let before = self
162 .before
163 .as_deref()
164 .context("snapshot kept no previous contents")?;
165 if let Some(parent) = self.path.parent() {
166 std::fs::create_dir_all(parent)?;
167 }
168 std::fs::write(&self.path, before)
169 .with_context(|| format!("write {}", self.path.display()))
170 }
171 }
172 }
173}
174
175pub fn content_hash(content: &str) -> u64 {
178 use std::hash::{Hash, Hasher};
179 let mut hasher = std::collections::hash_map::DefaultHasher::new();
180 content.hash(&mut hasher);
181 hasher.finish()
182}
183
184#[derive(Debug)]
185enum Operation {
186 Add { path: String, content: String },
187 Delete { path: String },
188 Update { path: String, hunks: Vec<Hunk> },
189}
190
191#[derive(Debug)]
192struct Hunk {
193 anchor: Option<String>,
194 lines: Vec<HunkLine>,
195}
196
197#[derive(Debug)]
198enum HunkLine {
199 Context(String),
200 Add(String),
201 Remove(String),
202}
203
204enum Prepared {
205 Add {
206 path: PathBuf,
207 content: String,
208 summary: PatchFileSummary,
209 },
210 Update {
211 path: PathBuf,
212 content: String,
213 before: String,
214 summary: PatchFileSummary,
215 },
216 Delete {
217 path: PathBuf,
218 before: String,
219 summary: PatchFileSummary,
220 },
221}
222
223pub fn affected_paths(patch: &str) -> Result<Vec<String>> {
224 let lines = patch.lines().collect::<Vec<_>>();
225 if lines.first() != Some(&"*** Begin Patch") || lines.last() != Some(&"*** End Patch") {
226 anyhow::bail!("patch must start with '*** Begin Patch' and end with '*** End Patch'");
227 }
228 let mut paths = BTreeSet::new();
229 for line in lines {
230 let path = line
231 .strip_prefix("*** Add File: ")
232 .or_else(|| line.strip_prefix("*** Delete File: "))
233 .or_else(|| line.strip_prefix("*** Update File: "));
234 if let Some(path) = path {
235 validate_patch_path(path)?;
236 paths.insert(path.to_owned());
237 }
238 }
239 Ok(paths.into_iter().collect())
240}
241
242pub async fn apply_patch(root: &Path, patch: &str) -> Result<PatchSummary> {
243 apply_patch_with_snapshots(root, patch, usize::MAX).await
244}
245
246pub async fn apply_patch_with_snapshots(
250 root: &Path,
251 patch: &str,
252 snapshot_max_bytes: usize,
253) -> Result<PatchSummary> {
254 let operations = parse_patch(patch)?;
255 let mut prepared = Vec::with_capacity(operations.len());
256 let mut seen = BTreeSet::new();
257 for operation in operations {
258 let requested = match &operation {
259 Operation::Add { path, .. }
260 | Operation::Delete { path }
261 | Operation::Update { path, .. } => path,
262 };
263 if !seen.insert(requested.clone()) {
264 anyhow::bail!("patch contains multiple operations for {requested}");
265 }
266 match operation {
267 Operation::Add { path, content } => {
268 let resolved = unrestricted_path(root, &path, false)?;
269 if resolved.exists() {
270 anyhow::bail!("cannot add existing file: {path}");
271 }
272 let added = content.lines().count();
273 prepared.push(Prepared::Add {
274 path: resolved,
275 content,
276 summary: PatchFileSummary {
277 path,
278 added,
279 removed: 0,
280 },
281 });
282 }
283 Operation::Delete { path } => {
284 let resolved = unrestricted_path(root, &path, true)?;
285 let source = fs::read_to_string(&resolved)
286 .with_context(|| format!("read file before delete: {path}"))?;
287 prepared.push(Prepared::Delete {
288 path: resolved,
289 summary: PatchFileSummary {
290 path,
291 added: 0,
292 removed: source.lines().count(),
293 },
294 before: source,
295 });
296 }
297 Operation::Update { path, hunks } => {
298 let resolved = unrestricted_path(root, &path, true)?;
299 let source = fs::read_to_string(&resolved)
300 .with_context(|| format!("read file before update: {path}"))?;
301 let (content, added, removed) = apply_hunks(&source, &hunks, &path)?;
302 prepared.push(Prepared::Update {
303 path: resolved,
304 content,
305 before: source,
306 summary: PatchFileSummary {
307 path,
308 added,
309 removed,
310 },
311 });
312 }
313 }
314 }
315
316 let mut summaries = Vec::new();
317 let mut snapshots = Vec::new();
318 let keep = |before: String| {
319 let restorable = before.len() <= snapshot_max_bytes;
320 (restorable.then_some(before), restorable)
321 };
322 for operation in prepared {
323 match operation {
324 Prepared::Add {
325 path,
326 content,
327 summary,
328 } => {
329 let parent = path.parent().context("patch target has no parent")?;
330 fs::create_dir_all(parent)?;
331 let mut file = OpenOptions::new()
332 .write(true)
333 .create_new(true)
334 .open(&path)
335 .with_context(|| format!("create {}", path.display()))?;
336 let result = file
337 .write_all(content.as_bytes())
338 .and_then(|()| file.sync_all());
339 if let Err(error) = result {
340 drop(file);
341 let _ = fs::remove_file(&path);
342 return Err(error)
343 .with_context(|| format!("write new file {}", path.display()));
344 }
345 snapshots.push(FileSnapshot {
346 path: path.clone(),
347 change: FileChange::Added,
348 before: None,
349 after_len: content.len() as u64,
350 after_hash: content_hash(&content),
351 added: summary.added,
352 removed: summary.removed,
353 restorable: true,
354 });
355 summaries.push(summary);
356 }
357 Prepared::Update {
358 path,
359 content,
360 before,
361 summary,
362 } => {
363 let mut file = OpenOptions::new()
364 .write(true)
365 .truncate(true)
366 .open(&path)
367 .with_context(|| format!("open {} for update", path.display()))?;
368 file.write_all(content.as_bytes())?;
369 file.sync_all()?;
370 let (before, restorable) = keep(before);
371 snapshots.push(FileSnapshot {
372 path: path.clone(),
373 change: FileChange::Modified,
374 before,
375 after_len: content.len() as u64,
376 after_hash: content_hash(&content),
377 added: summary.added,
378 removed: summary.removed,
379 restorable,
380 });
381 summaries.push(summary);
382 }
383 Prepared::Delete {
384 path,
385 before,
386 summary,
387 } => {
388 fs::remove_file(&path).with_context(|| format!("delete {}", path.display()))?;
389 let (before, restorable) = keep(before);
390 snapshots.push(FileSnapshot {
391 path: path.clone(),
392 change: FileChange::Deleted,
393 before,
394 after_len: 0,
395 after_hash: content_hash(""),
396 added: summary.added,
397 removed: summary.removed,
398 restorable,
399 });
400 summaries.push(summary);
401 }
402 }
403 }
404 Ok(PatchSummary {
405 files: summaries,
406 snapshots,
407 })
408}
409
410fn parse_patch(patch: &str) -> Result<Vec<Operation>> {
411 let lines = patch.lines().collect::<Vec<_>>();
412 if lines.first() != Some(&"*** Begin Patch") || lines.last() != Some(&"*** End Patch") {
413 anyhow::bail!("patch must start with '*** Begin Patch' and end with '*** End Patch'");
414 }
415 let mut operations = Vec::new();
416 let mut index = 1;
417 while index + 1 < lines.len() {
418 let line = lines[index];
419 if let Some(path) = line.strip_prefix("*** Add File: ") {
420 validate_patch_path(path)?;
421 index += 1;
422 let mut content = Vec::new();
423 while index + 1 < lines.len() && !lines[index].starts_with("*** ") {
424 let value = lines[index].strip_prefix('+').with_context(|| {
425 format!("add-file line must start with '+': {}", lines[index])
426 })?;
427 content.push(value);
428 index += 1;
429 }
430 let mut content = content.join("\n");
431 if !content.is_empty() {
432 content.push('\n');
433 }
434 operations.push(Operation::Add {
435 path: path.into(),
436 content,
437 });
438 } else if let Some(path) = line.strip_prefix("*** Delete File: ") {
439 validate_patch_path(path)?;
440 operations.push(Operation::Delete { path: path.into() });
441 index += 1;
442 } else if let Some(path) = line.strip_prefix("*** Update File: ") {
443 validate_patch_path(path)?;
444 index += 1;
445 let mut hunks = Vec::new();
446 while index + 1 < lines.len() && !lines[index].starts_with("*** ") {
447 let header = lines[index]
448 .strip_prefix("@@")
449 .with_context(|| format!("expected hunk header, found: {}", lines[index]))?;
450 let anchor = (!header.trim().is_empty()).then(|| header.trim().to_owned());
451 index += 1;
452 let mut hunk_lines = Vec::new();
453 while index + 1 < lines.len()
454 && !lines[index].starts_with("@@")
455 && !lines[index].starts_with("*** ")
456 {
457 let value = lines[index];
458 let (prefix, content) = value.split_at(1);
459 hunk_lines.push(match prefix {
460 " " => HunkLine::Context(content.into()),
461 "+" => HunkLine::Add(content.into()),
462 "-" => HunkLine::Remove(content.into()),
463 _ => anyhow::bail!("hunk line must start with ' ', '+' or '-': {value}"),
464 });
465 index += 1;
466 }
467 if hunk_lines.is_empty() {
468 anyhow::bail!("empty update hunk for {path}");
469 }
470 hunks.push(Hunk {
471 anchor,
472 lines: hunk_lines,
473 });
474 }
475 if hunks.is_empty() {
476 anyhow::bail!("update operation has no hunks: {path}");
477 }
478 operations.push(Operation::Update {
479 path: path.into(),
480 hunks,
481 });
482 } else {
483 anyhow::bail!("unknown patch operation: {line}");
484 }
485 }
486 if operations.is_empty() {
487 anyhow::bail!("patch contains no operations");
488 }
489 Ok(operations)
490}
491
492fn validate_patch_path(path: &str) -> Result<()> {
493 if path.trim().is_empty() {
494 anyhow::bail!("patch path must not be empty");
495 }
496 Ok(())
497}
498
499fn apply_hunks(source: &str, hunks: &[Hunk], path: &str) -> Result<(String, usize, usize)> {
500 let had_newline = source.ends_with('\n');
501 let mut lines = source.lines().map(ToOwned::to_owned).collect::<Vec<_>>();
502 let mut cursor = 0;
503 let mut total_added = 0;
504 let mut total_removed = 0;
505
506 for hunk in hunks {
507 if let Some(anchor) = &hunk.anchor
508 && let Some(position) = lines[cursor..]
509 .iter()
510 .position(|line| line.contains(anchor))
511 {
512 cursor += position;
513 }
514 let old = hunk
515 .lines
516 .iter()
517 .filter_map(|line| match line {
518 HunkLine::Context(value) | HunkLine::Remove(value) => Some(value.as_str()),
519 HunkLine::Add(_) => None,
520 })
521 .collect::<Vec<_>>();
522 let new = hunk
523 .lines
524 .iter()
525 .filter_map(|line| match line {
526 HunkLine::Context(value) | HunkLine::Add(value) => Some(value.clone()),
527 HunkLine::Remove(_) => None,
528 })
529 .collect::<Vec<_>>();
530 let position = if old.is_empty() {
531 cursor
532 } else {
533 find_sequence(&lines, &old, cursor)
534 .with_context(|| format!("context not found in {path}"))?
535 };
536 total_added += hunk
537 .lines
538 .iter()
539 .filter(|line| matches!(line, HunkLine::Add(_)))
540 .count();
541 total_removed += hunk
542 .lines
543 .iter()
544 .filter(|line| matches!(line, HunkLine::Remove(_)))
545 .count();
546 lines.splice(position..position + old.len(), new.iter().cloned());
547 cursor = position + new.len();
548 }
549 let mut result = lines.join("\n");
550 if had_newline {
551 result.push('\n');
552 }
553 Ok((result, total_added, total_removed))
554}
555
556fn find_sequence(lines: &[String], needle: &[&str], start: usize) -> Option<usize> {
557 (start..=lines.len().saturating_sub(needle.len())).find(|&position| {
558 lines[position..position + needle.len()]
559 .iter()
560 .map(String::as_str)
561 .eq(needle.iter().copied())
562 })
563}