Skip to main content

adk_devtools/tools/
edit.rs

1//! `edit_file` — exact-string replacement within a file.
2
3use std::sync::Arc;
4
5use adk_core::{Result, Tool, ToolContext};
6use async_trait::async_trait;
7use serde_json::{Value, json};
8
9use crate::error::DevToolError;
10use crate::tools::read::require_str;
11use crate::workspace::Workspace;
12
13/// Replaces an exact substring in a file. Requires the file to have been read
14/// this session, and (unless `replace_all`) the target string to be unique.
15pub struct EditFileTool {
16    workspace: Workspace,
17}
18
19impl EditFileTool {
20    /// Create an `edit_file` tool bound to `workspace`.
21    pub fn new(workspace: Workspace) -> Self {
22        Self { workspace }
23    }
24}
25
26#[async_trait]
27impl Tool for EditFileTool {
28    fn name(&self) -> &str {
29        "edit_file"
30    }
31
32    fn description(&self) -> &str {
33        "Replace an exact string in a file. The file must have been read with read_file \
34         first. By default the target string must occur exactly once; set replace_all=true \
35         to replace every occurrence."
36    }
37
38    fn parameters_schema(&self) -> Option<Value> {
39        Some(json!({
40            "type": "object",
41            "properties": {
42                "path": { "type": "string", "description": "Path relative to the workspace root." },
43                "old_string": { "type": "string", "description": "The exact text to replace." },
44                "new_string": { "type": "string", "description": "The replacement text." },
45                "replace_all": { "type": "boolean", "description": "Replace all occurrences (default false)." }
46            },
47            "required": ["path", "old_string", "new_string"]
48        }))
49    }
50
51    async fn execute(&self, _ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
52        if !self.workspace.is_writable() {
53            return Err(DevToolError::ReadOnly("edit_file").into());
54        }
55        let path = require_str(&args, "path")?;
56        let old_string = require_str(&args, "old_string")?;
57        let new_string = require_str(&args, "new_string")?;
58        let replace_all = args.get("replace_all").and_then(Value::as_bool).unwrap_or(false);
59
60        let resolved = self.workspace.resolve(&path)?;
61        if !self.workspace.was_read(&resolved) {
62            return Err(DevToolError::NotRead(self.workspace.display(&resolved)).into());
63        }
64
65        let original = tokio::fs::read_to_string(&resolved).await.map_err(DevToolError::from)?;
66        let count = original.matches(&old_string).count();
67        let display = self.workspace.display(&resolved);
68        if count == 0 {
69            return Err(DevToolError::NoMatch(display).into());
70        }
71        if count > 1 && !replace_all {
72            return Err(DevToolError::Ambiguous { path: display, count }.into());
73        }
74
75        let updated = if replace_all {
76            original.replace(&old_string, &new_string)
77        } else {
78            original.replacen(&old_string, &new_string, 1)
79        };
80        self.workspace.write_contained(&resolved, updated.as_bytes()).await?;
81
82        Ok(json!({
83            "path": self.workspace.display(&resolved),
84            "replacements": if replace_all { count } else { 1 },
85            "message": format!(
86                "Replaced {} occurrence(s) in {}",
87                if replace_all { count } else { 1 },
88                self.workspace.display(&resolved)
89            ),
90        }))
91    }
92}