Skip to main content

roder_edit_core/
patch.rs

1use std::path::{Component, Path, PathBuf};
2
3use anyhow::{Context, bail};
4use serde::{Deserialize, Serialize};
5
6use crate::hunks::{EditHunk, lines_hunk};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9pub struct CodexPatchChange {
10    pub op: CodexPatchOp,
11    pub path: String,
12    pub move_to: Option<String>,
13    pub lines: Vec<String>,
14    pub hunks: Vec<CodexPatchHunk>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19pub enum CodexPatchOp {
20    Add,
21    Delete,
22    Update,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26pub struct CodexPatchHunk {
27    pub old_lines: Vec<String>,
28    pub new_lines: Vec<String>,
29}
30
31pub fn is_codex_patch(patch: &str) -> bool {
32    patch.trim_start().starts_with("*** Begin Patch")
33}
34
35pub fn codex_patch_hunks(patch: &str) -> anyhow::Result<Vec<EditHunk>> {
36    let changes = parse_codex_patch(patch)?;
37    let mut records = Vec::new();
38    for change in changes {
39        let path = change
40            .move_to
41            .as_deref()
42            .unwrap_or(&change.path)
43            .to_string();
44        match change.op {
45            CodexPatchOp::Add => {
46                records.push(lines_hunk(path, Vec::new(), change.lines, records.len()))
47            }
48            CodexPatchOp::Delete => {
49                records.push(lines_hunk(path, Vec::new(), Vec::new(), records.len()))
50            }
51            CodexPatchOp::Update => {
52                for hunk in change.hunks {
53                    records.push(lines_hunk(
54                        path.clone(),
55                        hunk.old_lines,
56                        hunk.new_lines,
57                        records.len(),
58                    ));
59                }
60            }
61        }
62    }
63    Ok(records)
64}
65
66pub fn parse_codex_patch(patch: &str) -> anyhow::Result<Vec<CodexPatchChange>> {
67    let normalized = patch.replace("\r\n", "\n");
68    let mut lines = normalized.split('\n').collect::<Vec<_>>();
69    while lines.last().is_some_and(|line| line.trim().is_empty()) {
70        lines.pop();
71    }
72    if lines.first().map(|line| line.trim()) != Some("*** Begin Patch") {
73        bail!("missing *** Begin Patch");
74    }
75
76    let mut changes = Vec::new();
77    let mut i = 1;
78    while i < lines.len() {
79        let line = lines[i];
80        if line == "*** End Patch" {
81            return Ok(changes);
82        }
83        if let Some(path) = line.strip_prefix("*** Add File: ") {
84            let mut change = CodexPatchChange {
85                op: CodexPatchOp::Add,
86                path: path.trim().to_string(),
87                move_to: None,
88                lines: Vec::new(),
89                hunks: Vec::new(),
90            };
91            i += 1;
92            while i < lines.len() && !lines[i].starts_with("*** ") {
93                let Some(line) = lines[i].strip_prefix('+') else {
94                    bail!(
95                        "add file {} contains non-add line {:?}",
96                        change.path,
97                        lines[i]
98                    );
99                };
100                change.lines.push(line.to_string());
101                i += 1;
102            }
103            changes.push(change);
104            continue;
105        }
106        if let Some(path) = line.strip_prefix("*** Delete File: ") {
107            changes.push(CodexPatchChange {
108                op: CodexPatchOp::Delete,
109                path: path.trim().to_string(),
110                move_to: None,
111                lines: Vec::new(),
112                hunks: Vec::new(),
113            });
114            i += 1;
115            continue;
116        }
117        if let Some(path) = line.strip_prefix("*** Update File: ") {
118            let (change, next) = parse_codex_update(path.trim(), &lines, i + 1)?;
119            changes.push(change);
120            i = next;
121            continue;
122        }
123        bail!("unexpected patch line {:?}", line);
124    }
125    bail!("missing *** End Patch")
126}
127
128fn parse_codex_update(
129    path: &str,
130    lines: &[&str],
131    mut i: usize,
132) -> anyhow::Result<(CodexPatchChange, usize)> {
133    let mut change = CodexPatchChange {
134        op: CodexPatchOp::Update,
135        path: path.to_string(),
136        move_to: None,
137        lines: Vec::new(),
138        hunks: Vec::new(),
139    };
140    while i < lines.len() {
141        let line = lines[i];
142        if line == "*** End Patch"
143            || line.starts_with("*** Add File: ")
144            || line.starts_with("*** Delete File: ")
145            || line.starts_with("*** Update File: ")
146        {
147            return Ok((change, i));
148        }
149        if let Some(path) = line.strip_prefix("*** Move to: ") {
150            change.move_to = Some(path.trim().to_string());
151            i += 1;
152            continue;
153        }
154        if line.starts_with("@@") {
155            let (hunk, next) = parse_codex_patch_hunk(lines, i + 1)
156                .map_err(|err| anyhow::anyhow!("{}: {err}", change.path))?;
157            change.hunks.push(hunk);
158            i = next;
159            continue;
160        }
161        bail!("{}: expected hunk header, got {:?}", change.path, line);
162    }
163    Ok((change, i))
164}
165
166fn parse_codex_patch_hunk(lines: &[&str], mut i: usize) -> anyhow::Result<(CodexPatchHunk, usize)> {
167    let mut hunk = CodexPatchHunk {
168        old_lines: Vec::new(),
169        new_lines: Vec::new(),
170    };
171    while i < lines.len() {
172        let line = lines[i];
173        if line.starts_with("@@") || line.starts_with("*** ") {
174            break;
175        }
176        if line == "*** End of File" {
177            i += 1;
178            continue;
179        }
180        if line.is_empty() {
181            bail!("empty hunk line must be prefixed with space, +, or -");
182        }
183        let body = &line[1..];
184        match line.as_bytes()[0] {
185            b' ' => {
186                hunk.old_lines.push(body.to_string());
187                hunk.new_lines.push(body.to_string());
188            }
189            b'-' => hunk.old_lines.push(body.to_string()),
190            b'+' => hunk.new_lines.push(body.to_string()),
191            prefix => bail!("invalid hunk line prefix {:?}", prefix as char),
192        }
193        i += 1;
194    }
195    if hunk.old_lines.is_empty() && hunk.new_lines.is_empty() {
196        bail!("empty hunk");
197    }
198    Ok((hunk, i))
199}
200
201pub fn apply_codex_patch_to_workspace(root: &Path, patch: &str) -> anyhow::Result<String> {
202    apply_codex_patch_to_workspace_with_external_paths(root, patch, false)
203}
204
205pub fn apply_codex_patch_to_workspace_with_external_paths(
206    root: &Path,
207    patch: &str,
208    allow_external_paths: bool,
209) -> anyhow::Result<String> {
210    let root = root
211        .canonicalize()
212        .with_context(|| format!("workspace root does not exist: {}", root.display()))?;
213    let changes = parse_codex_patch(patch)?;
214    if changes.is_empty() {
215        bail!("no changes found");
216    }
217    let mut summaries = Vec::new();
218    for change in changes {
219        summaries.push(apply_codex_patch_change(
220            &root,
221            change,
222            allow_external_paths,
223        )?);
224    }
225    Ok(format!("Success. {}", summaries.join("\n")))
226}
227
228fn apply_codex_patch_change(
229    root: &Path,
230    change: CodexPatchChange,
231    allow_external_paths: bool,
232) -> anyhow::Result<String> {
233    let path = resolve_for_write(root, &change.path, allow_external_paths)?;
234    match change.op {
235        CodexPatchOp::Add => add_file(root, &path, &change),
236        CodexPatchOp::Delete => delete_file(root, &path),
237        CodexPatchOp::Update => update_file(root, &path, &change, allow_external_paths),
238    }
239}
240
241fn add_file(root: &Path, path: &Path, change: &CodexPatchChange) -> anyhow::Result<String> {
242    if path.exists() {
243        bail!("{} already exists", change.path);
244    }
245    if let Some(parent) = path.parent() {
246        std::fs::create_dir_all(parent)?;
247    }
248    std::fs::write(path, join_patch_lines(&change.lines))?;
249    Ok(format!("Added {}", display(root, path)))
250}
251
252fn delete_file(root: &Path, path: &Path) -> anyhow::Result<String> {
253    std::fs::remove_file(path)?;
254    Ok(format!("Deleted {}", display(root, path)))
255}
256
257fn update_file(
258    root: &Path,
259    path: &PathBuf,
260    change: &CodexPatchChange,
261    allow_external_paths: bool,
262) -> anyhow::Result<String> {
263    let mut text = std::fs::read_to_string(path)?;
264    for hunk in &change.hunks {
265        let old_text = hunk.old_lines.join("\n");
266        let new_text = hunk.new_lines.join("\n");
267        if old_text.is_empty() {
268            text = format!("{new_text}{text}");
269            continue;
270        }
271        let Some(index) = text.find(&old_text) else {
272            bail!("expected hunk not found in {}:\n{}", change.path, old_text);
273        };
274        text.replace_range(index..index + old_text.len(), &new_text);
275    }
276
277    let target_path = if let Some(move_to) = &change.move_to {
278        resolve_for_write(root, move_to, allow_external_paths)?
279    } else {
280        path.clone()
281    };
282    if let Some(parent) = target_path.parent() {
283        std::fs::create_dir_all(parent)?;
284    }
285    std::fs::write(&target_path, text)?;
286    if target_path != *path {
287        std::fs::remove_file(path)?;
288        Ok(format!(
289            "Moved {} to {}",
290            display(root, path),
291            display(root, &target_path)
292        ))
293    } else {
294        Ok(format!("Updated {}", display(root, path)))
295    }
296}
297
298fn join_patch_lines(lines: &[String]) -> String {
299    if lines.is_empty() {
300        String::new()
301    } else {
302        format!("{}\n", lines.join("\n"))
303    }
304}
305
306fn resolve_for_write(
307    root: &Path,
308    input: &str,
309    allow_external_paths: bool,
310) -> anyhow::Result<PathBuf> {
311    let candidate = if Path::new(input).is_absolute() {
312        PathBuf::from(input)
313    } else {
314        root.join(input)
315    };
316    let root = root
317        .canonicalize()
318        .with_context(|| format!("workspace root does not exist: {}", root.display()))?;
319    let normalized = normalize(candidate)?;
320    let normalized_for_check = if normalized.exists() {
321        normalized.canonicalize()?
322    } else if let Some(parent) = normalized.parent().filter(|parent| parent.exists()) {
323        parent.canonicalize()?.join(
324            normalized
325                .file_name()
326                .ok_or_else(|| anyhow::anyhow!("path is required"))?,
327        )
328    } else {
329        normalized.clone()
330    };
331    if !allow_external_paths && !normalized_for_check.starts_with(&root) {
332        bail!(
333            "path {} is outside workspace {}",
334            normalized_for_check.display(),
335            root.display()
336        );
337    }
338    Ok(normalized_for_check)
339}
340
341fn normalize(path: PathBuf) -> anyhow::Result<PathBuf> {
342    let mut normalized = PathBuf::new();
343    for component in path.components() {
344        match component {
345            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
346            Component::RootDir => normalized.push(component.as_os_str()),
347            Component::CurDir => {}
348            Component::Normal(part) => normalized.push(part),
349            Component::ParentDir => {
350                if !normalized.pop() {
351                    bail!("path escapes filesystem root");
352                }
353            }
354        }
355    }
356    Ok(normalized)
357}
358
359fn display(root: &Path, path: &Path) -> String {
360    path.strip_prefix(root)
361        .unwrap_or(path)
362        .to_string_lossy()
363        .replace('\\', "/")
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn parses_and_applies_codex_patch() {
372        let root = temp_dir("roder-edit-core-patch");
373        std::fs::create_dir_all(&root).unwrap();
374        std::fs::write(root.join("a.txt"), "old\n").unwrap();
375        let output = apply_codex_patch_to_workspace(
376            &root,
377            "*** Begin Patch\n*** Update File: a.txt\n@@\n-old\n+new\n*** End Patch\n",
378        )
379        .unwrap();
380        assert!(output.contains("Updated a.txt"));
381        assert_eq!(
382            std::fs::read_to_string(root.join("a.txt")).unwrap(),
383            "new\n"
384        );
385        let _ = std::fs::remove_dir_all(root);
386    }
387
388    #[test]
389    fn rejects_paths_outside_workspace() {
390        let root = temp_dir("roder-edit-core-outside");
391        std::fs::create_dir_all(&root).unwrap();
392        let err = apply_codex_patch_to_workspace(
393            &root,
394            "*** Begin Patch\n*** Add File: ../x.txt\n+no\n*** End Patch\n",
395        )
396        .unwrap_err();
397        assert!(err.to_string().contains("outside workspace"));
398        let _ = std::fs::remove_dir_all(root);
399    }
400
401    #[test]
402    fn can_allow_paths_outside_workspace() {
403        let root = temp_dir("roder-edit-core-allow-root");
404        let outside = temp_dir("roder-edit-core-allow-outside");
405        std::fs::create_dir_all(&root).unwrap();
406        std::fs::create_dir_all(&outside).unwrap();
407        let target = outside.join("x.txt");
408        let output = apply_codex_patch_to_workspace_with_external_paths(
409            &root,
410            &format!(
411                "*** Begin Patch\n*** Add File: {}\n+yes\n*** End Patch\n",
412                target.display()
413            ),
414            true,
415        )
416        .unwrap();
417
418        assert!(output.contains("Success. Added"));
419        assert_eq!(std::fs::read_to_string(&target).unwrap(), "yes\n");
420        let _ = std::fs::remove_dir_all(root);
421        let _ = std::fs::remove_dir_all(outside);
422    }
423
424    fn temp_dir(prefix: &str) -> PathBuf {
425        let nanos = std::time::SystemTime::now()
426            .duration_since(std::time::UNIX_EPOCH)
427            .unwrap()
428            .as_nanos();
429        std::env::temp_dir().join(format!("{prefix}-{nanos}"))
430    }
431}