Skip to main content

openmoat_core/patch/
replay.rs

1//! The content a Codex `apply_patch` envelope leaves in each file it adds or
2//! updates, worked out from the patch and the files as they are, so a caller
3//! can judge what a file will hold before the patch is applied.
4//!
5//! An added file holds its `+` lines. An updated file has each hunk's old
6//! lines (context and `-`) replaced by its new lines (context and `+`), found
7//! in order from where the last hunk ended, as Codex finds them: exactly, then
8//! ignoring trailing white space, then ignoring white space at both ends. A
9//! `@@ <line>` header moves the search past that line first. A file whose
10//! hunks cannot be found this way is reported with no content: Codex may still
11//! apply the patch with a match this replay does not make, so its outcome is
12//! unknown, never guessed.
13
14use super::strip_header;
15
16/// One file the patch adds or updates and the content it leaves there:
17/// `None` when that cannot be worked out (the file to update cannot be read,
18/// or a hunk is not found in it).
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Replayed {
21    /// The path as the patch names it (a move's destination for a moved file).
22    pub path: String,
23    /// The whole content the patch leaves in the file.
24    pub content: Option<String>,
25}
26
27/// What a file section of the patch does.
28enum Op<'a> {
29    Add,
30    Update { to: Option<&'a str> },
31    Delete,
32}
33
34/// One file section: its header, the path it names and its body lines.
35struct Section<'a> {
36    op: Op<'a>,
37    path: &'a str,
38    body: Vec<&'a str>,
39}
40
41/// The content the patch `text` leaves in every file it adds or updates, in
42/// the order it names them, `read` giving a file's content as it is now
43/// (`None` when it cannot be read). A file the patch touches twice is
44/// replayed in order. Deleted files and a move's source are left out: they
45/// hold nothing afterwards.
46#[must_use]
47pub fn replay(text: &str, read: &dyn Fn(&str) -> Option<String>) -> Vec<Replayed> {
48    let mut out: Vec<Replayed> = Vec::new();
49    for section in sections(text) {
50        let (dest, content) = match section.op {
51            Op::Delete => {
52                out.retain(|r| r.path != section.path);
53                continue;
54            }
55            Op::Add => (section.path, Some(added(&section.body))),
56            Op::Update { to } => {
57                let before = match out.iter().find(|r| r.path == section.path) {
58                    Some(earlier) => earlier.content.clone(),
59                    None => read(section.path),
60                };
61                let content = before.and_then(|text| updated(&text, &section.body));
62                if to.is_some() {
63                    out.retain(|r| r.path != section.path);
64                }
65                (to.unwrap_or(section.path), content)
66            }
67        };
68        out.retain(|r| r.path != dest);
69        out.push(Replayed {
70            path: dest.to_owned(),
71            content,
72        });
73    }
74    out
75}
76
77/// The file sections of `text`, split at the headers [`super::writes`]
78/// reads. A `*** Move to:` line belongs to the update before it.
79fn sections(text: &str) -> Vec<Section<'_>> {
80    let mut out: Vec<Section<'_>> = Vec::new();
81    for line in text.lines() {
82        let trimmed = line.trim();
83        let header = |h: &str| strip_header(trimmed, h).map(str::trim);
84        if let Some(path) = header("*** Add File:") {
85            out.push(Section {
86                op: Op::Add,
87                path,
88                body: Vec::new(),
89            });
90        } else if let Some(path) = header("*** Update File:") {
91            out.push(Section {
92                op: Op::Update { to: None },
93                path,
94                body: Vec::new(),
95            });
96        } else if let Some(path) = header("*** Delete File:") {
97            out.push(Section {
98                op: Op::Delete,
99                path,
100                body: Vec::new(),
101            });
102        } else if let Some(dest) = header("*** Move to:") {
103            if let Some(Section {
104                op: Op::Update { to },
105                ..
106            }) = out.last_mut()
107            {
108                *to = Some(dest);
109            }
110        } else if !trimmed.eq_ignore_ascii_case("*** End Patch")
111            && !trimmed.eq_ignore_ascii_case("*** Begin Patch")
112            && let Some(section) = out.last_mut()
113        {
114            section.body.push(line);
115        }
116    }
117    out.retain(|s| !s.path.is_empty());
118    out
119}
120
121/// An added file's content: its `+` lines.
122fn added(body: &[&str]) -> String {
123    let mut text = String::new();
124    for line in body {
125        if let Some(rest) = line.strip_prefix('+') {
126            text.push_str(rest);
127            text.push('\n');
128        }
129    }
130    text
131}
132
133/// One hunk of an update: the line after `@@`, if any, and its old and new
134/// lines.
135#[derive(Default)]
136struct Hunk<'a> {
137    anchor: Option<&'a str>,
138    old: Vec<&'a str>,
139    new: Vec<&'a str>,
140}
141
142/// `text` with the hunks of `body` applied; `None` when one is not found or
143/// the body holds a line that is no hunk line.
144fn updated(text: &str, body: &[&str]) -> Option<String> {
145    let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
146    let mut cursor = 0;
147    for hunk in hunks(body)? {
148        if let Some(anchor) = hunk.anchor {
149            let at = (cursor..lines.len()).find(|&i| lines[i].trim() == anchor.trim())?;
150            cursor = at + 1;
151        }
152        let at = if hunk.old.is_empty() {
153            lines.len()
154        } else {
155            find(&lines, &hunk.old, cursor)?
156        };
157        let new: Vec<String> = hunk.new.iter().map(|l| (*l).to_owned()).collect();
158        let len = new.len();
159        lines.splice(at..at + hunk.old.len(), new);
160        cursor = at + len;
161    }
162    let mut out = lines.join("\n");
163    out.push('\n');
164    Some(out)
165}
166
167/// The hunks of an update's `body`; `None` for a line that is none of
168/// context, `-`, `+`, `@@` or `*** End of File`.
169fn hunks<'a>(body: &[&'a str]) -> Option<Vec<Hunk<'a>>> {
170    let mut out: Vec<Hunk<'a>> = Vec::new();
171    for line in body {
172        if let Some(rest) = line.strip_prefix("@@") {
173            let anchor = rest.trim();
174            out.push(Hunk {
175                anchor: (!anchor.is_empty()).then_some(anchor),
176                ..Hunk::default()
177            });
178            continue;
179        }
180        if line.trim().eq_ignore_ascii_case("*** End of File") {
181            continue;
182        }
183        if out.is_empty() {
184            out.push(Hunk::default());
185        }
186        let hunk = out.last_mut()?;
187        match line.chars().next() {
188            Some('-') => hunk.old.push(&line[1..]),
189            Some('+') => hunk.new.push(&line[1..]),
190            Some(' ') => {
191                hunk.old.push(&line[1..]);
192                hunk.new.push(&line[1..]);
193            }
194            None => {
195                hunk.old.push("");
196                hunk.new.push("");
197            }
198            Some(_) => return None,
199        }
200    }
201    Some(out)
202}
203
204/// Where `old` first occurs in `lines` at or after `from`: exactly, then
205/// ignoring trailing white space, then ignoring white space at both ends.
206fn find(lines: &[String], old: &[&str], from: usize) -> Option<usize> {
207    let same = |eq: &dyn Fn(&str, &str) -> bool| {
208        (from..=lines.len().checked_sub(old.len())?)
209            .find(|&i| old.iter().enumerate().all(|(k, o)| eq(&lines[i + k], o)))
210    };
211    same(&|a, b| a == b)
212        .or_else(|| same(&|a, b| a.trim_end() == b.trim_end()))
213        .or_else(|| same(&|a, b| a.trim() == b.trim()))
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn none(_: &str) -> Option<String> {
221        None
222    }
223
224    #[test]
225    fn an_added_file_holds_its_plus_lines() {
226        let patch = "*** Begin Patch\n*** Add File: .codex/config.toml\n+[features]\n+hooks = false\n*** End Patch\n";
227        assert_eq!(
228            replay(patch, &none),
229            [Replayed {
230                path: ".codex/config.toml".to_owned(),
231                content: Some("[features]\nhooks = false\n".to_owned()),
232            }]
233        );
234    }
235
236    #[test]
237    fn an_update_replaces_each_hunk_in_order() {
238        let now = "model = \"x\"\n[features]\nhooks = true\nweb = true\n";
239        let read = |_: &str| Some(now.to_owned());
240        let patch = "*** Begin Patch\n*** Update File: c.toml\n@@ [features]\n-hooks = true\n+hooks = false\n web = true\n*** End Patch\n";
241        let out = replay(patch, &read);
242        assert_eq!(
243            out[0].content.as_deref(),
244            Some("model = \"x\"\n[features]\nhooks = false\nweb = true\n")
245        );
246        // Trailing white space in the file does not stop the match.
247        let spaced = |_: &str| Some("hooks = true   \n".to_owned());
248        let plain = "*** Update File: c.toml\n@@\n-hooks = true\n+hooks = false\n";
249        assert_eq!(
250            replay(plain, &spaced)[0].content.as_deref(),
251            Some("hooks = false\n")
252        );
253    }
254
255    #[test]
256    fn an_update_that_does_not_apply_or_cannot_be_read_is_unknown() {
257        let read = |_: &str| Some("a\n".to_owned());
258        let patch = "*** Update File: c.toml\n@@\n-missing\n+b\n";
259        assert_eq!(replay(patch, &read)[0].content, None);
260        assert_eq!(replay(patch, &none)[0].content, None);
261        // A line that is no hunk line.
262        assert_eq!(replay("*** Update File: c\nx\n", &read)[0].content, None);
263    }
264
265    #[test]
266    fn a_move_lands_at_its_destination_and_a_delete_leaves_nothing() {
267        let read = |p: &str| (p == "notes.toml").then(|| "[features]\nhooks = true\n".to_owned());
268        let patch = "*** Update File: notes.toml\n*** Move to: .codex/config.toml\n@@\n-hooks = true\n+hooks = false\n\
269                     *** Add File: gone\n+x\n*** Delete File: gone\n";
270        let out = replay(patch, &read);
271        assert_eq!(out.len(), 1, "{out:?}");
272        assert_eq!(out[0].path, ".codex/config.toml");
273        assert_eq!(
274            out[0].content.as_deref(),
275            Some("[features]\nhooks = false\n")
276        );
277    }
278
279    #[test]
280    fn a_file_named_twice_is_replayed_in_order() {
281        let patch = "*** Add File: a\n+one\n*** Update File: a\n@@\n-one\n+two\n";
282        assert_eq!(replay(patch, &none)[0].content.as_deref(), Some("two\n"));
283    }
284}