use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use super::AgentEvent;
use crate::turn::event::{TurnEvent, TurnResult};
#[derive(Debug, Clone)]
pub struct EditInstruction {
pub file: String,
pub instruction: String,
}
pub fn parse_edit_instructions(text: &str) -> Vec<EditInstruction> {
let mut instructions = Vec::new();
let mut current_file: Option<String> = None;
let mut current_lines: Vec<String> = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
let file_from_header = extract_file_from_header(trimmed);
if let Some(file) = file_from_header {
if let Some(prev_file) = current_file.take() {
let instr = current_lines.join("\n").trim().to_string();
if !instr.is_empty() {
instructions.push(EditInstruction {
file: prev_file,
instruction: instr,
});
}
}
current_file = Some(file);
current_lines.clear();
continue;
}
if current_file.is_some() {
current_lines.push(line.to_string());
}
}
if let Some(file) = current_file {
let instr = current_lines.join("\n").trim().to_string();
if !instr.is_empty() {
instructions.push(EditInstruction {
file,
instruction: instr,
});
}
}
if instructions.is_empty() {
for line in text.lines() {
let trimmed = line.trim();
let rest = if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
trimmed.split_once('.').map(|(_, r)| r.trim())
} else if trimmed.starts_with("- ") {
Some(&trimmed[2..])
} else {
None
};
if let Some(rest) = rest {
let words: Vec<&str> = rest
.splitn(2, |c: char| {
c == ':' || c == '\u{2014}' || c == '-' || c == ' '
})
.collect();
if let Some(first_word) = words.first() {
let first_word = first_word.trim().trim_matches('`');
if is_source_file(first_word) {
let file = first_word.to_string();
let instr = if words.len() > 1 {
words[1..].join(" ").trim().to_string()
} else {
String::new()
};
if !instr.is_empty() {
instructions.push(EditInstruction {
file,
instruction: instr,
});
}
}
}
}
}
}
instructions
}
fn extract_file_from_header(line: &str) -> Option<String> {
if let Some(rest) = line.strip_prefix("###").or_else(|| line.strip_prefix("##")) {
let rest = rest.trim();
if let Some(file_part) = rest
.strip_prefix("File:")
.or_else(|| rest.strip_prefix("file:"))
{
let file = file_part.trim().trim_matches('`');
if is_source_file(file) {
return Some(file.to_string());
}
}
let bare = rest.trim_matches('*').trim();
if is_source_file(bare) {
return Some(bare.to_string());
}
}
if line.starts_with("**") && line.contains("**") {
let inner = line
.trim_start_matches("**")
.split("**")
.next()
.unwrap_or("");
let inner = inner
.strip_prefix("File:")
.or_else(|| inner.strip_prefix("file:"))
.unwrap_or(inner)
.trim();
if is_source_file(inner) {
return Some(inner.to_string());
}
}
None
}
fn is_source_file(s: &str) -> bool {
let s = s.trim_matches(|c: char| c == '`' || c == '*' || c == ':' || c == ' ');
s.ends_with(".vue")
|| s.ends_with(".ts")
|| s.ends_with(".tsx")
|| s.ends_with(".js")
|| s.ends_with(".jsx")
|| s.ends_with(".java")
|| s.ends_with(".py")
|| s.ends_with(".rs")
|| s.ends_with(".go")
|| s.ends_with(".svelte")
|| s.ends_with(".html")
|| s.ends_with(".css")
}
pub async fn execute_instructions(
instructions: Vec<EditInstruction>,
runner: &mut crate::turn::runner::TurnRunner,
event_tx: &tokio::sync::mpsc::UnboundedSender<AgentEvent>,
working_dir: &std::path::Path,
) -> (Vec<String>, bool) {
let mut summaries = Vec::new();
let mut all_success = true;
for (i, instr) in instructions.iter().enumerate() {
let file_path = if std::path::Path::new(&instr.file).is_absolute() {
instr.file.clone()
} else {
match find_file(working_dir, &instr.file) {
Some(p) => p.to_string_lossy().to_string(),
None => {
summaries.push(format!(
"EXECUTE {}/{}: {} — file not found",
i + 1,
instructions.len(),
instr.file
));
all_success = false;
continue;
}
}
};
let _ = event_tx.send(AgentEvent::TextDelta(format!(
"\n[EXECUTE {}/{}] Editing {} ...\n",
i + 1,
instructions.len(),
instr.file
)));
let (turn_tx, mut turn_rx) = mpsc::unbounded_channel::<TurnEvent>();
let cancel = CancellationToken::new();
let result = runner
.run_execute(&file_path, &instr.instruction, &turn_tx, cancel)
.await;
while let Ok(event) = turn_rx.try_recv() {
match event {
TurnEvent::ToolCallResult {
ref call_id,
ref name,
ref output,
success,
..
} => {
let _ = event_tx.send(AgentEvent::ToolCallResult {
call_id: call_id.clone(),
name: name.clone(),
output: output.clone(),
success,
duration: std::time::Duration::ZERO,
});
}
TurnEvent::TextDelta(text) => {
let _ = event_tx.send(AgentEvent::TextDelta(text));
}
TurnEvent::ReasoningDelta(text) => {
let _ = event_tx.send(AgentEvent::ReasoningDelta(text));
}
_ => {}
}
}
match result {
TurnResult::UsedTools { .. } => {
summaries.push(format!(
"EXECUTE {}/{}: {} — edited",
i + 1,
instructions.len(),
instr.file
));
}
TurnResult::Responded { ref text, .. } => {
summaries.push(format!(
"EXECUTE {}/{}: {} — no edit (model said: {})",
i + 1,
instructions.len(),
instr.file,
text.chars().take(100).collect::<String>()
));
all_success = false;
}
TurnResult::Failed(ref e) => {
summaries.push(format!(
"EXECUTE {}/{}: {} — failed: {}",
i + 1,
instructions.len(),
instr.file,
e
));
all_success = false;
}
TurnResult::Cancelled => {
summaries.push(format!(
"EXECUTE {}/{}: {} — cancelled",
i + 1,
instructions.len(),
instr.file
));
all_success = false;
break;
}
}
}
(summaries, all_success)
}
fn find_file(dir: &std::path::Path, name: &str) -> Option<std::path::PathBuf> {
let direct = dir.join(name);
if direct.exists() {
return Some(direct);
}
let walker = ignore::WalkBuilder::new(dir)
.hidden(true)
.git_ignore(true)
.max_depth(Some(8))
.build();
for entry in walker {
if let Ok(e) = entry {
if e.file_type().map(|t| t.is_file()).unwrap_or(false) {
if let Some(fname) = e.path().file_name() {
if fname.to_string_lossy() == name {
return Some(e.into_path());
}
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_file_header_sections() {
let text = "\
I'll modify two files:
### File: TopBar.vue
Replace the avatar with a local image.
Keep the tab navigation intact.
### File: App.vue
Import FootBar component and add it to the template.";
let instrs = parse_edit_instructions(text);
assert_eq!(instrs.len(), 2);
assert_eq!(instrs[0].file, "TopBar.vue");
assert!(instrs[0].instruction.contains("avatar"));
assert_eq!(instrs[1].file, "App.vue");
assert!(instrs[1].instruction.contains("FootBar"));
}
#[test]
fn parse_numbered_list() {
let text = "\
Plan:
1. TopBar.vue: change avatar to local image
2. App.vue: import and add FootBar
3. FootBar.vue: redesign with navigation";
let instrs = parse_edit_instructions(text);
assert_eq!(instrs.len(), 3);
assert_eq!(instrs[0].file, "TopBar.vue");
assert_eq!(instrs[2].file, "FootBar.vue");
}
#[test]
fn parse_bold_headers() {
let text = "\
**TopBar.vue**:
Fix the avatar section
**App.vue**:
Add FootBar import";
let instrs = parse_edit_instructions(text);
assert_eq!(instrs.len(), 2);
}
#[test]
fn parse_no_instructions() {
let text = "I looked at the code and everything seems fine.";
let instrs = parse_edit_instructions(text);
assert_eq!(instrs.len(), 0);
}
#[test]
fn parse_single_file_instruction() {
let text = "\
### File: IdeaCenter.vue
Change the formatDate function to output MM-DD HH:mm format.";
let instrs = parse_edit_instructions(text);
assert_eq!(instrs.len(), 1);
assert_eq!(instrs[0].file, "IdeaCenter.vue");
assert!(instrs[0].instruction.contains("formatDate"));
}
}