openmoat_core/patch/
replay.rs1use super::strip_header;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Replayed {
21 pub path: String,
23 pub content: Option<String>,
25}
26
27enum Op<'a> {
29 Add,
30 Update { to: Option<&'a str> },
31 Delete,
32}
33
34struct Section<'a> {
36 op: Op<'a>,
37 path: &'a str,
38 body: Vec<&'a str>,
39}
40
41#[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(§ion.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, §ion.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
77fn 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
121fn 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#[derive(Default)]
136struct Hunk<'a> {
137 anchor: Option<&'a str>,
138 old: Vec<&'a str>,
139 new: Vec<&'a str>,
140}
141
142fn 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
167fn 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
204fn 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 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 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}