aura-gui 0.2.2

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Ediciones de `edit_file`: una o varias en la misma llamada, para no gastar una vuelta del modelo por cambio.
//! Cada `old` se busca en el texto actual del archivo, debe aparecer una sola vez y no puede solaparse con otra; se
//! aplican todas juntas o ninguna (un solo paso para deshacer). Con una sola edición y `old` vacío se crea o
//! sobrescribe el archivo entero.

use crate::ai::types::ToolSpec;
use serde_json::{Value, json};
use std::ops::Range;

/// Un reemplazo: `old` (vacío: el archivo entero) por `new`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Edit {
    pub old: String,
    pub new: String,
}

impl Edit {
    /// El archivo entero con `content` (`write_file`).
    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"],
        }),
    }
}

/// Lee `edits: [{old, new}, …]` y, como siempre, `old`/`new` sueltos (van primero si vienen los dos).
pub fn parse(input: &Value) -> Result<Vec<Edit>, String> {
    let mut edits = Vec::new();
    // Algunos proveedores rellenan old/new opcionales con "" junto a edits.
    // Ese par vacío es un marcador, no una orden de sobrescribir el archivo.
    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)
}

/// Rangos (en caracteres del texto original, ordenados y sin solaparse) y textos nuevos de `edits` sobre `text`; si
/// alguna no encaja, el primer problema y no se aplica ninguna.
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"));
    }
}