use crate::ai::types::ToolSpec;
use serde_json::{Value, json};
use std::ops::Range;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Edit {
pub old: String,
pub new: String,
}
impl Edit {
pub fn whole(content: &str) -> Self {
Edit { old: String::new(), new: content.to_owned() }
}
}
pub fn spec(path: &Value) -> ToolSpec {
let pair = json!({ "old": { "type": "string" }, "new": { "type": "string" } });
ToolSpec {
name: "edit_file".into(),
description: "Edita un archivo. Un cambio: `old` (texto exacto que aparece una sola vez) por `new`; con `old` \
vacío crea o sobrescribe el archivo. Varios cambios en el mismo archivo: `edits` = [{old, new}, …] en una \
sola llamada; cada `old` se busca en el archivo tal como está, no puede solaparse con otro y se aplican \
todos o ninguno. Para varios archivos, pide varias llamadas en la misma respuesta."
.into(),
schema: json!({
"type": "object",
"properties": {
"path": path,
"old": pair["old"],
"new": pair["new"],
"edits": { "type": "array", "items": { "type": "object", "properties": pair, "required": ["old", "new"] } },
},
"required": ["path"],
}),
}
}
pub fn parse(input: &Value) -> Result<Vec<Edit>, String> {
let mut edits = Vec::new();
let has_list = input["edits"].as_array().is_some_and(|items| !items.is_empty());
if let Some(new) = input["new"].as_str()
&& !(has_list && new.is_empty() && input["old"].as_str().unwrap_or_default().is_empty())
{
edits.push(Edit { old: input["old"].as_str().unwrap_or_default().to_owned(), new: new.to_owned() });
}
for (index, item) in input["edits"].as_array().into_iter().flatten().enumerate() {
let field = |key: &str| {
item[key]
.as_str()
.map(str::to_owned)
.ok_or_else(|| format!("edit_file: a la edición {} le falta «{key}»", index + 1))
};
edits.push(Edit { old: field("old")?, new: field("new")? });
}
if edits.is_empty() {
return Err("edit_file: falta «new» (o la lista «edits»)".into());
}
if edits.len() > 1 && edits.iter().any(|edit| edit.old.is_empty()) {
return Err(
"edit_file: con varias ediciones cada una necesita su «old»; para reescribir el archivo usa write_file"
.into(),
);
}
Ok(edits)
}
pub fn plan(text: &str, edits: &[Edit]) -> Result<Vec<(Range<usize>, String)>, String> {
let label = |index: usize| if edits.len() > 1 { format!("edición {}: ", index + 1) } else { String::new() };
let mut planned = Vec::with_capacity(edits.len());
for (index, edit) in edits.iter().enumerate() {
if edit.old.is_empty() {
planned.push((0..text.chars().count(), index));
continue;
}
let mut found = text.match_indices(edit.old.as_str()).map(|(byte, _)| byte);
let Some(byte) = found.next() else {
return Err(format!("{}no se encontró el texto a reemplazar", label(index)));
};
let more = found.count();
if more > 0 {
return Err(format!("{}el texto aparece {} veces; incluye más contexto", label(index), more + 1));
}
let start = text[..byte].chars().count();
planned.push((start..start + edit.old.chars().count(), index));
}
planned.sort_by_key(|(range, _)| range.start);
if let Some(pair) = planned.windows(2).find(|pair| pair[1].0.start < pair[0].0.end) {
let (first, second) = (pair[0].1.min(pair[1].1) + 1, pair[0].1.max(pair[1].1) + 1);
return Err(format!("las ediciones {first} y {second} se solapan; júntalas en una"));
}
Ok(planned.into_iter().map(|(range, index)| (range, edits[index].new.clone())).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn edit(old: &str, new: &str) -> Edit {
Edit { old: old.into(), new: new.into() }
}
#[test]
fn lee_una_edicion_suelta_y_una_lista() {
assert_eq!(parse(&json!({"old": "a", "new": "b"})).unwrap(), [edit("a", "b")]);
assert_eq!(parse(&json!({"new": "todo"})).unwrap(), [Edit::whole("todo")]);
let list = json!({"edits": [{"old": "a", "new": "b"}, {"old": "c", "new": "d"}]});
assert_eq!(parse(&list).unwrap(), [edit("a", "b"), edit("c", "d")]);
assert!(parse(&json!({"edits": [{"old": "a"}]})).unwrap_err().contains("edición 1 le falta «new»"));
assert!(parse(&json!({})).unwrap_err().contains("falta «new»"));
assert!(parse(&json!({"edits": [{"old": "", "new": "x"}, {"old": "a", "new": "b"}]})).is_err());
}
#[test]
fn optional_empty_pair_does_not_override_batch() {
let input = json!({"old": "", "new": "", "edits": [
{"old": "uno", "new": "1"}, {"old": "dos", "new": "2"}
]});
assert_eq!(parse(&input).unwrap(), [edit("uno", "1"), edit("dos", "2")]);
assert_eq!(parse(&json!({"old": "", "new": ""})).unwrap(), [Edit::whole("")]);
}
#[test]
fn planifica_en_orden_del_texto_y_en_caracteres() {
let text = "ñandú corre; ñandú salta";
let planned = plan(text, &[edit("salta", "vuela"), edit("ñandú corre", "emú corre")]).unwrap();
assert_eq!(planned, [(0..11, "emú corre".to_owned()), (19..24, "vuela".to_owned())]);
assert_eq!(plan(text, &[Edit::whole("x")]).unwrap(), [(0..24, "x".to_owned())]);
}
#[test]
fn si_una_no_encaja_no_se_aplica_ninguna() {
let text = "uno dos tres dos";
assert_eq!(
plan(text, &[edit("uno", "1"), edit("cuatro", "4")]).unwrap_err(),
"edición 2: no se encontró el texto a reemplazar"
);
assert!(plan(text, &[edit("dos", "2")]).unwrap_err().contains("aparece 2 veces"));
assert!(plan(text, &[edit("uno dos", "x"), edit("dos tres", "y")]).unwrap_err().contains("1 y 2 se solapan"));
}
}