Skip to main content

crabtalk_runtime/os/
edit.rs

1//! edit tool — exact string replacement with conflict detection.
2
3use crate::{Env, host::Host};
4use schemars::JsonSchema;
5use serde::Deserialize;
6use wcore::{
7    agent::{AsTool, ToolDescription},
8    model::Tool,
9};
10
11use crate::os::read::MAX_FILE_SIZE;
12
13#[derive(Deserialize, JsonSchema)]
14pub struct Edit {
15    /// Path to the file to edit.
16    pub path: String,
17    /// Exact string to find and replace. Must appear exactly once in the file.
18    pub old_string: String,
19    /// Replacement string.
20    pub new_string: String,
21}
22
23impl ToolDescription for Edit {
24    const DESCRIPTION: &'static str = "Replace an exact string in a file. Fails if the string is not found or appears more than once.";
25}
26
27pub fn tools() -> Vec<Tool> {
28    vec![Edit::as_tool()]
29}
30
31impl<H: Host> Env<H> {
32    pub async fn dispatch_edit(
33        &self,
34        args: &str,
35        conversation_id: Option<u64>,
36    ) -> Result<String, String> {
37        let input: Edit =
38            serde_json::from_str(args).map_err(|e| format!("invalid arguments: {e}"))?;
39
40        if input.old_string.is_empty() {
41            return Err("old_string must not be empty".to_owned());
42        }
43        if input.old_string == input.new_string {
44            return Err("old_string and new_string are identical".to_owned());
45        }
46
47        let conversation_cwd = if let Some(id) = conversation_id {
48            self.host.conversation_cwd(id)
49        } else {
50            None
51        };
52        let cwd = conversation_cwd.as_deref().unwrap_or(&self.cwd);
53
54        let path = if std::path::Path::new(&input.path).is_absolute() {
55            std::path::PathBuf::from(&input.path)
56        } else {
57            cwd.join(&input.path)
58        };
59
60        match std::fs::metadata(&path) {
61            Ok(m) if m.len() > MAX_FILE_SIZE => {
62                return Err(format!(
63                    "file is too large ({} bytes, max {})",
64                    m.len(),
65                    MAX_FILE_SIZE
66                ));
67            }
68            Err(e) => return Err(format!("error reading {}: {e}", path.display())),
69            _ => {}
70        }
71
72        let content = std::fs::read_to_string(&path)
73            .map_err(|e| format!("error reading {}: {e}", path.display()))?;
74
75        let count = content.matches(&input.old_string).count();
76        if count == 0 {
77            return Err("old_string not found".to_owned());
78        }
79        if count > 1 {
80            return Err(format!(
81                "old_string is not unique, found {count} occurrences"
82            ));
83        }
84
85        let new_content = content.replacen(&input.old_string, &input.new_string, 1);
86        std::fs::write(&path, &new_content)
87            .map_err(|e| format!("error writing {}: {e}", path.display()))?;
88
89        Ok("ok".to_owned())
90    }
91}