Skip to main content

yapi_core/tools/
edit.rs

1//! `edit`: exact text replacements in one file, preserving its byte order
2//! mark (BOM) and line endings.
3
4use futures_util::future::BoxFuture;
5use serde_json::{Map, Value, json};
6use tokio_util::sync::CancellationToken;
7use yapi_agent::{Tool, UpdateSink};
8use yapi_types::event::ToolResult;
9use yapi_types::message::ToolDeclaration;
10
11use super::edit_diff::{
12    Replacement, apply_edits, detect_line_ending, display_diff, normalize_to_lf,
13    restore_line_endings, unified_patch,
14};
15use super::mutation::with_file_lock;
16use super::{ToolEnv, check_abort, declaration, error_code, string_arg, text_result};
17
18/// The `edit` tool.
19pub struct Edit {
20    env: ToolEnv,
21    declaration: ToolDeclaration,
22}
23
24impl Edit {
25    /// An `edit` tool for `env`.
26    pub fn new(env: ToolEnv) -> Edit {
27        Edit {
28            env,
29            declaration: declaration(
30                "edit",
31                "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.".into(),
32                json!({"type":"object","required":["path","edits"],"properties":{
33                    "path":{"type":"string","description":"Path to the file to edit (relative or absolute)"},
34                    "edits":{"type":"array","items":{"type":"object","required":["oldText","newText"],"properties":{
35                        "oldText":{"type":"string","description":"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call."},
36                        "newText":{"type":"string","description":"Replacement text for this targeted edit."}}},
37                        "description":"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead."}}}),
38            ),
39        }
40    }
41}
42
43fn is_single_edit(value: &Value) -> bool {
44    value["oldText"].is_string() && value["newText"].is_string()
45}
46
47impl Tool for Edit {
48    fn declaration(&self) -> &ToolDeclaration {
49        &self.declaration
50    }
51
52    /// Accepts `edits` as a JSON string or a single object, and the legacy
53    /// top-level `oldText`/`newText`.
54    fn prepare_arguments(&self, mut args: Map<String, Value>) -> Map<String, Value> {
55        match args.get("edits") {
56            Some(Value::String(text)) => match serde_json::from_str::<Value>(text) {
57                Ok(parsed @ Value::Array(_)) => {
58                    args.insert("edits".into(), parsed);
59                }
60                Ok(parsed) if is_single_edit(&parsed) => {
61                    args.insert("edits".into(), Value::Array(vec![parsed]));
62                }
63                _ => {}
64            },
65            Some(single) if is_single_edit(single) => {
66                let single = single.clone();
67                args.insert("edits".into(), Value::Array(vec![single]));
68            }
69            _ => {}
70        }
71        if let (Some(Value::String(old)), Some(Value::String(new))) =
72            (args.get("oldText").cloned(), args.get("newText").cloned())
73        {
74            let mut edits = match args.get("edits") {
75                Some(Value::Array(edits)) => edits.clone(),
76                _ => Vec::new(),
77            };
78            edits.push(json!({"oldText": old, "newText": new}));
79            args.shift_remove("oldText");
80            args.shift_remove("newText");
81            args.insert("edits".into(), Value::Array(edits));
82        }
83        args
84    }
85
86    fn execute(
87        &self,
88        _call_id: String,
89        args: Value,
90        cancel: CancellationToken,
91        _updates: UpdateSink,
92    ) -> BoxFuture<'_, Result<ToolResult, String>> {
93        Box::pin(async move {
94            let edits = args["edits"]
95                .as_array()
96                .into_iter()
97                .flatten()
98                .map(|edit| {
99                    Ok(Replacement {
100                        old_text: string_arg(edit, "oldText")?.to_owned(),
101                        new_text: string_arg(edit, "newText")?.to_owned(),
102                    })
103                })
104                .collect::<Result<Vec<_>, String>>()?;
105            if edits.is_empty() {
106                return Err(
107                    "Edit tool input is invalid. edits must contain at least one replacement."
108                        .into(),
109                );
110            }
111            let path = string_arg(&args, "path")?.to_owned();
112            let absolute = super::path::resolve_to_cwd(&path, &self.env.cwd);
113            with_file_lock(&absolute, async {
114                check_abort(&cancel)?;
115                let metadata = tokio::fs::metadata(&absolute).await;
116                let writable = metadata
117                    .as_ref()
118                    .map(|metadata| !metadata.permissions().readonly());
119                match writable {
120                    Ok(true) => {}
121                    Ok(false) => {
122                        return Err(format!("Could not edit file: {path}. Error code: EACCES."));
123                    }
124                    Err(err) => {
125                        check_abort(&cancel)?;
126                        return Err(format!("Could not edit file: {path}. {}.", error_code(err)));
127                    }
128                }
129                let raw = tokio::fs::read(&absolute)
130                    .await
131                    .map_err(|err| format!("Could not edit file: {path}. {}.", error_code(&err)))?;
132                check_abort(&cancel)?;
133                // Writing back a lossy decoding would change bytes the edit never touched.
134                let Ok(raw) = String::from_utf8(raw) else {
135                    return Err(format!(
136                        "Could not edit file: {path}. File is not valid UTF-8."
137                    ));
138                };
139                let (bom, content) = match raw.strip_prefix('\u{FEFF}') {
140                    Some(rest) => ("\u{FEFF}", rest),
141                    None => ("", raw.as_str()),
142                };
143                let ending = detect_line_ending(content);
144                let normalized = normalize_to_lf(content);
145                let new_content = apply_edits(&normalized, &edits, &path)?;
146                check_abort(&cancel)?;
147                let restored = restore_line_endings(content, &normalized, &new_content, ending);
148                let output = format!("{bom}{restored}");
149                tokio::fs::write(&absolute, output)
150                    .await
151                    .map_err(|err| format!("Could not edit file: {path}. {}.", error_code(&err)))?;
152                check_abort(&cancel)?;
153                let (diff, first_changed) = display_diff(&normalized, &new_content);
154                let patch = unified_patch(&path, &normalized, &new_content);
155                let mut details = json!({"diff": diff, "patch": patch});
156                if let Some(line) = first_changed {
157                    details["firstChangedLine"] = json!(line);
158                }
159                Ok(text_result(
160                    format!("Successfully replaced {} block(s) in {path}.", edits.len()),
161                    Some(details),
162                ))
163            })
164            .await
165        })
166    }
167}