Skip to main content

openmoat_core/
patch.rs

1//! Files named by a Codex `apply_patch` envelope, whether Codex sends it as
2//! its own tool or the agent runs `apply_patch` in a shell.
3//!
4//! ```text
5//! *** Begin Patch
6//! *** Add File: path        (create)
7//! *** Update File: path     (edit; may be followed by `*** Move to: other`)
8//! *** Delete File: path
9//! *** End Patch
10//! ```
11//!
12//! Every named path is written (a delete or a move removes its source). A
13//! patch that names no file is passed on empty and decided as unparseable.
14//!
15//! Codex trims a file header before matching it, so ` *** Delete File: x`
16//! after an added file's lines is a header to it. A line is matched the same
17//! way here, ignoring case as well, and wherever it appears: naming a file the
18//! patch does not touch only makes the decision stricter, while missing one
19//! that Codex writes would let it through unchecked.
20
21mod replay;
22
23pub use replay::{Replayed, replay};
24
25const HEADERS: &[&str] = &[
26    "*** Add File:",
27    "*** Update File:",
28    "*** Delete File:",
29    "*** Move to:",
30];
31
32/// Every file the patch `text` writes, in the order it names them.
33#[must_use]
34pub fn writes(text: &str) -> Vec<String> {
35    let mut paths: Vec<String> = Vec::new();
36    for line in text.lines() {
37        let line = line.trim();
38        let Some(path) = HEADERS
39            .iter()
40            .find_map(|h| strip_header(line, h))
41            .map(str::trim)
42            .filter(|p| !p.is_empty())
43        else {
44            continue;
45        };
46        if !paths.iter().any(|p| p == path) {
47            paths.push(path.to_owned());
48        }
49    }
50    paths
51}
52
53/// `line` after `header`, compared without regard to ASCII case.
54fn strip_header<'a>(line: &'a str, header: &str) -> Option<&'a str> {
55    let head = line.get(..header.len())?;
56    head.eq_ignore_ascii_case(header)
57        .then(|| &line[header.len()..])
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn every_header_names_a_written_file() {
66        let patch = "*** Begin Patch\n*** Add File: src/new.rs\n+fn x() {}\n\
67                     *** Update File: src/lib.rs\n*** Move to: src/core.rs\n@@\n-a\n+b\n\
68                     *** Delete File: old.txt\r\n*** Update File: src/lib.rs\n*** End Patch\n";
69        assert_eq!(
70            writes(patch),
71            ["src/new.rs", "src/lib.rs", "src/core.rs", "old.txt"]
72        );
73    }
74
75    #[test]
76    fn added_lines_that_look_like_headers_are_content() {
77        // An added line `+*** Add File: x` starts with `+`, so it is content.
78        assert_eq!(writes("*** Add File: a\n+*** Add File: ~/.zshrc\n"), ["a"]);
79    }
80
81    #[test]
82    fn indented_and_differently_cased_headers_are_writes() {
83        // Codex trims the line after an added file's content and reads it as
84        // the next header, so the indented delete is applied.
85        let patch = "*** Begin Patch\n*** Add File: a\n+x\n  *** Delete File: ~/.ssh/id_rsa\n\
86                     \t*** update file: ~/.zshrc\n*** End Patch\n";
87        assert_eq!(writes(patch), ["a", "~/.ssh/id_rsa", "~/.zshrc"]);
88    }
89
90    #[test]
91    fn headers_outside_the_envelope_still_count() {
92        let patch = "*** Begin Patch\n*** Add File: a\n+x\n*** End Patch\n*** Delete File: b\n";
93        assert_eq!(writes(patch), ["a", "b"]);
94    }
95
96    #[test]
97    fn a_root_path_is_passed_on_for_the_engine_to_decide() {
98        assert_eq!(writes("*** Delete File: /\n"), ["/"]);
99    }
100
101    #[test]
102    fn no_headers_means_no_files() {
103        assert_eq!(writes("just text"), Vec::<String>::new());
104        assert_eq!(writes("*** Add File:   \n"), Vec::<String>::new());
105    }
106}