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}