Skip to main content

a_agent/tools/
patch.rs

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    /// What each touched file looked like beforehand, so the change can be
14    /// undone. Absent for files too large to keep in the session.
15    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/// One file as it was before a patch touched it, with a fingerprint of what the
52/// patch left behind so a later restore can tell whether anything else has
53/// changed the file since.
54#[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    /// False when the previous contents were too large to keep, which is
64    /// reported rather than silently truncated.
65    pub restorable: bool,
66}
67
68/// Collapses a rewind range into one entry per path, describing the whole range
69/// rather than each patch in it.
70///
71/// A path the agent touched more than once has one snapshot per patch, and each
72/// one's `after_*` describes only that patch. Checking them individually calls
73/// every snapshot but the last "changed after the agent wrote it", which is both
74/// useless and untrue — the agent's own later edit is what changed it. What
75/// matters is the state at the start of the range and the state the agent last
76/// left the file in.
77///
78/// `newest_first` must be ordered newest to oldest, as the store returns it.
79pub 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            // Later iterations are older, so this becomes the start of the range.
85            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            // Created and then removed again: the range left nothing behind.
112            (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    /// Why this snapshot cannot be put back, or `None` when it can. A file the
129    /// agent wrote and something else then edited is left alone: reverting it
130    /// would throw away the other edit.
131    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(&current) == 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    /// Puts the file back as it was. Callers are expected to have checked
155    /// [`Self::restore_blocker`] first.
156    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
175/// Cheap content fingerprint. Only ever compared against another fingerprint
176/// this program produced, so a non-cryptographic hash is enough.
177pub 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
246/// `snapshot_max_bytes` caps how much of a file's previous contents is kept for
247/// undo; anything larger is recorded as unrestorable instead of truncated, since
248/// a partial restore would corrupt the file.
249pub 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}