use pushkin_core::edits::Replacement;
use serde_json::Value;
use super::{FileWrite, Intent, ParseOutcome, ToolAction};
pub(crate) fn patch_action(session: String, command: &str) -> ParseOutcome {
let PatchSections {
files,
has_update,
has_add,
has_delete,
} = parse_apply_patch(command);
if files.is_empty() {
return Err(None);
}
let intent = if has_update {
Intent::MutateNoContent("apply_patch")
} else if has_add || !has_delete {
Intent::Write
} else {
Intent::Delete
};
Ok(ToolAction {
session,
files,
is_stop: false,
intent,
command: None,
})
}
pub(crate) struct PatchSections {
files: Vec<FileWrite>,
has_update: bool,
has_add: bool,
has_delete: bool,
}
pub(crate) fn bare_target(path: &str) -> FileWrite {
FileWrite {
path: relativize(path.trim()),
content: String::new(),
edits: Vec::new(),
}
}
#[derive(Default)]
pub(crate) struct Hunk {
old: Vec<String>,
new: Vec<String>,
}
pub(crate) struct Section {
path: String,
content: String,
is_add: bool,
edits: Vec<Replacement>,
hunk: Option<Hunk>,
moved: bool,
malformed: bool,
}
impl Section {
fn new(path: &str, is_add: bool) -> Self {
Self {
path: relativize(path.trim()),
content: String::new(),
is_add,
edits: Vec::new(),
hunk: None,
moved: false,
malformed: false,
}
}
fn close_hunk(&mut self) {
let Some(hunk) = self.hunk.take() else {
return;
};
if hunk.old.is_empty() && hunk.new.is_empty() {
return;
}
self.edits.push(Replacement {
old: hunk.old.join("\n"),
new: hunk.new.join("\n"),
replace_all: false,
anchor: None,
});
}
fn read(&mut self, line: &str) {
if line.starts_with("@@") {
self.close_hunk();
self.hunk = Some(Hunk::default());
return;
}
if let Some(added) = line.strip_prefix('+') {
if self.is_add {
self.content.push_str(added);
self.content.push('\n');
} else {
self.hunk
.get_or_insert_with(Hunk::default)
.new
.push(added.to_owned());
}
return;
}
if self.is_add {
self.malformed = !line.is_empty();
return;
}
if let Some(removed) = line.strip_prefix('-') {
self.hunk
.get_or_insert_with(Hunk::default)
.old
.push(removed.to_owned());
return;
}
if let Some(context) = line.strip_prefix(' ') {
let hunk = self.hunk.get_or_insert_with(Hunk::default);
hunk.old.push(context.to_owned());
hunk.new.push(context.to_owned());
return;
}
if line.is_empty() {
let hunk = self.hunk.get_or_insert_with(Hunk::default);
hunk.old.push(String::new());
hunk.new.push(String::new());
return;
}
self.malformed = true;
}
fn finish(mut self) -> FileWrite {
self.close_hunk();
let edits = if self.moved || self.malformed {
Vec::new()
} else {
self.edits
};
FileWrite {
path: self.path,
content: self.content,
edits,
}
}
}
pub(crate) fn close_section(files: &mut Vec<FileWrite>, section: Option<Section>) {
if let Some(section) = section {
files.push(section.finish());
}
}
pub(crate) fn parse_apply_patch(command: &str) -> PatchSections {
let mut files = Vec::new();
let mut section: Option<Section> = None;
let mut has_update = false;
let mut has_add = false;
let mut has_delete = false;
for line in command.lines() {
if let Some(path) = line.strip_prefix("*** Add File: ") {
close_section(&mut files, section.take());
has_add = true;
section = Some(Section::new(path, true));
} else if let Some(path) = line.strip_prefix("*** Update File: ") {
close_section(&mut files, section.take());
has_update = true;
section = Some(Section::new(path, false));
} else if let Some(path) = line.strip_prefix("*** Delete File: ") {
close_section(&mut files, section.take());
has_delete = true;
files.push(bare_target(path));
} else if let Some(path) = line.strip_prefix("*** Move to: ") {
files.push(bare_target(path));
if let Some(open) = section.as_mut() {
open.moved = true;
}
} else if line.starts_with("*** End of File") {
} else if line.starts_with("*** End Patch") {
close_section(&mut files, section.take());
} else if let Some(open) = section.as_mut() {
open.read(line);
}
}
close_section(&mut files, section.take());
PatchSections {
files,
has_update,
has_add,
has_delete,
}
}
pub(crate) fn relativize(path: &str) -> String {
let candidate = std::path::Path::new(path);
if candidate.is_relative() {
return path.to_owned();
}
let Ok(cwd) = std::env::current_dir() else {
return path.to_owned();
};
if let Ok(stripped) = candidate.strip_prefix(&cwd) {
return stripped.to_string_lossy().into_owned();
}
if let Ok(canonical_cwd) = cwd.canonicalize() {
if let Ok(stripped) = candidate.strip_prefix(&canonical_cwd) {
return stripped.to_string_lossy().into_owned();
}
}
path.to_owned()
}
pub(crate) fn session_from(value: &Value, key: &str) -> String {
value
.get(key)
.and_then(Value::as_str)
.unwrap_or("anonymous-session")
.to_owned()
}