use super::strip_header;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Replayed {
pub path: String,
pub content: Option<String>,
}
enum Op<'a> {
Add,
Update { to: Option<&'a str> },
Delete,
}
struct Section<'a> {
op: Op<'a>,
path: &'a str,
body: Vec<&'a str>,
}
#[must_use]
pub fn replay(text: &str, read: &dyn Fn(&str) -> Option<String>) -> Vec<Replayed> {
let mut out: Vec<Replayed> = Vec::new();
for section in sections(text) {
let (dest, content) = match section.op {
Op::Delete => {
out.retain(|r| r.path != section.path);
continue;
}
Op::Add => (section.path, Some(added(§ion.body))),
Op::Update { to } => {
let before = match out.iter().find(|r| r.path == section.path) {
Some(earlier) => earlier.content.clone(),
None => read(section.path),
};
let content = before.and_then(|text| updated(&text, §ion.body));
if to.is_some() {
out.retain(|r| r.path != section.path);
}
(to.unwrap_or(section.path), content)
}
};
out.retain(|r| r.path != dest);
out.push(Replayed {
path: dest.to_owned(),
content,
});
}
out
}
fn sections(text: &str) -> Vec<Section<'_>> {
let mut out: Vec<Section<'_>> = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
let header = |h: &str| strip_header(trimmed, h).map(str::trim);
if let Some(path) = header("*** Add File:") {
out.push(Section {
op: Op::Add,
path,
body: Vec::new(),
});
} else if let Some(path) = header("*** Update File:") {
out.push(Section {
op: Op::Update { to: None },
path,
body: Vec::new(),
});
} else if let Some(path) = header("*** Delete File:") {
out.push(Section {
op: Op::Delete,
path,
body: Vec::new(),
});
} else if let Some(dest) = header("*** Move to:") {
if let Some(Section {
op: Op::Update { to },
..
}) = out.last_mut()
{
*to = Some(dest);
}
} else if !trimmed.eq_ignore_ascii_case("*** End Patch")
&& !trimmed.eq_ignore_ascii_case("*** Begin Patch")
&& let Some(section) = out.last_mut()
{
section.body.push(line);
}
}
out.retain(|s| !s.path.is_empty());
out
}
fn added(body: &[&str]) -> String {
let mut text = String::new();
for line in body {
if let Some(rest) = line.strip_prefix('+') {
text.push_str(rest);
text.push('\n');
}
}
text
}
#[derive(Default)]
struct Hunk<'a> {
anchor: Option<&'a str>,
old: Vec<&'a str>,
new: Vec<&'a str>,
}
fn updated(text: &str, body: &[&str]) -> Option<String> {
let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
let mut cursor = 0;
for hunk in hunks(body)? {
if let Some(anchor) = hunk.anchor {
let at = (cursor..lines.len()).find(|&i| lines[i].trim() == anchor.trim())?;
cursor = at + 1;
}
let at = if hunk.old.is_empty() {
lines.len()
} else {
find(&lines, &hunk.old, cursor)?
};
let new: Vec<String> = hunk.new.iter().map(|l| (*l).to_owned()).collect();
let len = new.len();
lines.splice(at..at + hunk.old.len(), new);
cursor = at + len;
}
let mut out = lines.join("\n");
out.push('\n');
Some(out)
}
fn hunks<'a>(body: &[&'a str]) -> Option<Vec<Hunk<'a>>> {
let mut out: Vec<Hunk<'a>> = Vec::new();
for line in body {
if let Some(rest) = line.strip_prefix("@@") {
let anchor = rest.trim();
out.push(Hunk {
anchor: (!anchor.is_empty()).then_some(anchor),
..Hunk::default()
});
continue;
}
if line.trim().eq_ignore_ascii_case("*** End of File") {
continue;
}
if out.is_empty() {
out.push(Hunk::default());
}
let hunk = out.last_mut()?;
match line.chars().next() {
Some('-') => hunk.old.push(&line[1..]),
Some('+') => hunk.new.push(&line[1..]),
Some(' ') => {
hunk.old.push(&line[1..]);
hunk.new.push(&line[1..]);
}
None => {
hunk.old.push("");
hunk.new.push("");
}
Some(_) => return None,
}
}
Some(out)
}
fn find(lines: &[String], old: &[&str], from: usize) -> Option<usize> {
let same = |eq: &dyn Fn(&str, &str) -> bool| {
(from..=lines.len().checked_sub(old.len())?)
.find(|&i| old.iter().enumerate().all(|(k, o)| eq(&lines[i + k], o)))
};
same(&|a, b| a == b)
.or_else(|| same(&|a, b| a.trim_end() == b.trim_end()))
.or_else(|| same(&|a, b| a.trim() == b.trim()))
}
#[cfg(test)]
mod tests {
use super::*;
fn none(_: &str) -> Option<String> {
None
}
#[test]
fn an_added_file_holds_its_plus_lines() {
let patch = "*** Begin Patch\n*** Add File: .codex/config.toml\n+[features]\n+hooks = false\n*** End Patch\n";
assert_eq!(
replay(patch, &none),
[Replayed {
path: ".codex/config.toml".to_owned(),
content: Some("[features]\nhooks = false\n".to_owned()),
}]
);
}
#[test]
fn an_update_replaces_each_hunk_in_order() {
let now = "model = \"x\"\n[features]\nhooks = true\nweb = true\n";
let read = |_: &str| Some(now.to_owned());
let patch = "*** Begin Patch\n*** Update File: c.toml\n@@ [features]\n-hooks = true\n+hooks = false\n web = true\n*** End Patch\n";
let out = replay(patch, &read);
assert_eq!(
out[0].content.as_deref(),
Some("model = \"x\"\n[features]\nhooks = false\nweb = true\n")
);
let spaced = |_: &str| Some("hooks = true \n".to_owned());
let plain = "*** Update File: c.toml\n@@\n-hooks = true\n+hooks = false\n";
assert_eq!(
replay(plain, &spaced)[0].content.as_deref(),
Some("hooks = false\n")
);
}
#[test]
fn an_update_that_does_not_apply_or_cannot_be_read_is_unknown() {
let read = |_: &str| Some("a\n".to_owned());
let patch = "*** Update File: c.toml\n@@\n-missing\n+b\n";
assert_eq!(replay(patch, &read)[0].content, None);
assert_eq!(replay(patch, &none)[0].content, None);
assert_eq!(replay("*** Update File: c\nx\n", &read)[0].content, None);
}
#[test]
fn a_move_lands_at_its_destination_and_a_delete_leaves_nothing() {
let read = |p: &str| (p == "notes.toml").then(|| "[features]\nhooks = true\n".to_owned());
let patch = "*** Update File: notes.toml\n*** Move to: .codex/config.toml\n@@\n-hooks = true\n+hooks = false\n\
*** Add File: gone\n+x\n*** Delete File: gone\n";
let out = replay(patch, &read);
assert_eq!(out.len(), 1, "{out:?}");
assert_eq!(out[0].path, ".codex/config.toml");
assert_eq!(
out[0].content.as_deref(),
Some("[features]\nhooks = false\n")
);
}
#[test]
fn a_file_named_twice_is_replayed_in_order() {
let patch = "*** Add File: a\n+one\n*** Update File: a\n@@\n-one\n+two\n";
assert_eq!(replay(patch, &none)[0].content.as_deref(), Some("two\n"));
}
}