Skip to main content

sac/tools/
edit.rs

1use std::path::PathBuf;
2
3use serde_json::Value;
4
5use crate::tools::{acquire_write_lock, require_str, ToolResult, ToolRuntime};
6
7const SANDBOX_EDIT_SCRIPT: &str = r#"
8from pathlib import Path
9import json
10import sys
11
12orig = sys.argv[1]
13path = Path(sys.argv[2])
14payload = json.load(sys.stdin)
15old_text = payload["old_text"]
16new_text = payload["new_text"]
17
18if not path.exists():
19    print(f"File not found: {orig}")
20    sys.exit(2)
21
22try:
23    content = path.read_text(encoding="utf-8")
24except Exception as exc:
25    print(f"Error reading {orig}: {exc}")
26    sys.exit(2)
27
28count = content.count(old_text)
29if count == 0:
30    print(f"old_text not found in {orig}")
31    sys.exit(2)
32if count > 1:
33    print(f"old_text appears {count} times — provide more context to make it unique")
34    sys.exit(2)
35
36new_content = content.replace(old_text, new_text, 1)
37try:
38    path.write_text(new_content, encoding="utf-8")
39    print("ok")
40except Exception as exc:
41    print(f"Error writing {orig}: {exc}")
42    sys.exit(2)
43"#;
44
45pub async fn execute(args: Value, runtime: &ToolRuntime) -> ToolResult {
46    let path = match require_str(&args, "path") {
47        Ok(s) => s,
48        Err(e) => return e,
49    };
50    let old_text = match require_str(&args, "old_text") {
51        Ok(s) => s,
52        Err(e) => return e,
53    };
54    let new_text = match require_str(&args, "new_text") {
55        Ok(s) => s,
56        Err(e) => return e,
57    };
58
59    if let Some(sandbox) = &runtime.sandbox {
60        let guest_path = match sandbox.resolve_path(&path) {
61            Ok(path) => path,
62            Err(error) => {
63                return ToolResult {
64                    content: error.to_string(),
65                    is_error: true,
66                }
67            }
68        };
69        let payload = serde_json::json!({
70            "old_text": old_text,
71            "new_text": new_text,
72        });
73        let args = vec![
74            "-c".to_string(),
75            SANDBOX_EDIT_SCRIPT.to_string(),
76            path.clone(),
77            guest_path.display().to_string(),
78        ];
79        return match sandbox
80            .exec("python3", &args, Some(payload.to_string().into_bytes()))
81            .await
82        {
83            Ok(output) if output.status.success() => ToolResult {
84                content: "ok".to_string(),
85                is_error: false,
86            },
87            Ok(output) => ToolResult {
88                content: String::from_utf8_lossy(&output.stdout).trim().to_string(),
89                is_error: true,
90            },
91            Err(error) => ToolResult {
92                content: format!("Error editing {} in sandbox: {}", path, error),
93                is_error: true,
94            },
95        };
96    }
97
98    let path = PathBuf::from(path);
99    if !path.exists() {
100        return ToolResult {
101            content: format!("File not found: {}", path.display()),
102            is_error: true,
103        };
104    }
105
106    let content = match tokio::fs::read_to_string(&path).await {
107        Ok(c) => c,
108        Err(e) => {
109            return ToolResult {
110                content: format!("Error reading {}: {}", path.display(), e),
111                is_error: true,
112            }
113        }
114    };
115
116    let count = content.matches(&old_text as &str).count();
117    if count == 0 {
118        return ToolResult {
119            content: format!("old_text not found in {}", path.display()),
120            is_error: true,
121        };
122    }
123    if count > 1 {
124        return ToolResult {
125            content: format!(
126                "old_text appears {} times — provide more context to make it unique",
127                count
128            ),
129            is_error: true,
130        };
131    }
132
133    let new_content = content.replacen(&old_text, &new_text, 1);
134    let _guard = acquire_write_lock().await;
135    match tokio::fs::write(&path, new_content.as_bytes()).await {
136        Ok(_) => ToolResult {
137            content: "ok".to_string(),
138            is_error: false,
139        },
140        Err(e) => ToolResult {
141            content: format!("Error writing {}: {}", path.display(), e),
142            is_error: true,
143        },
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use serde_json::json;
151    use std::collections::HashSet;
152    use std::sync::Arc;
153    use tokio::sync::Mutex;
154
155    use crate::events::EventSink;
156
157    async fn write_temp(content: &str) -> PathBuf {
158        use std::sync::atomic::{AtomicU64, Ordering};
159        static COUNTER: AtomicU64 = AtomicU64::new(0);
160        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
161        let path = std::env::temp_dir().join(format!("agent_edit_test_{}.txt", id));
162        tokio::fs::write(&path, content).await.unwrap();
163        path
164    }
165
166    fn local_runtime() -> ToolRuntime {
167        ToolRuntime {
168            store_path: PathBuf::new(),
169            session_id: None,
170            worker_executable: None,
171            active_threads: Arc::new(Mutex::new(HashSet::new())),
172            event_sink: EventSink::none(),
173            sandbox: None,
174            mcp: None,
175            skills: None,
176            activated_skills: Arc::new(Mutex::new(HashSet::new())),
177            terminal_manager: crate::terminal::TerminalManager::new(),
178            thread_timeout_secs: crate::tools::thread::DEFAULT_THREAD_TIMEOUT_SECS,
179        }
180    }
181
182    #[tokio::test]
183    async fn test_exact_match() {
184        let path = write_temp("hello world\ngoodbye\n").await;
185        let result = execute(
186            json!({
187                "path": path.to_string_lossy(),
188                "old_text": "hello world",
189                "new_text": "hi earth"
190            }),
191            &local_runtime(),
192        )
193        .await;
194        assert!(!result.is_error, "Got error: {}", result.content);
195        let content = tokio::fs::read_to_string(&path).await.unwrap();
196        assert!(content.contains("hi earth"));
197        let _ = tokio::fs::remove_file(&path).await;
198    }
199
200    #[tokio::test]
201    async fn test_no_match() {
202        let path = write_temp("fn foo() {}\n").await;
203        let result = execute(
204            json!({
205                "path": path.to_string_lossy(),
206                "old_text": "nonexistent text xyz",
207                "new_text": "replacement"
208            }),
209            &local_runtime(),
210        )
211        .await;
212        assert!(result.is_error);
213        assert!(
214            result.content.contains("not found"),
215            "Got: {}",
216            result.content
217        );
218        let _ = tokio::fs::remove_file(&path).await;
219    }
220
221    #[tokio::test]
222    async fn test_multiple_matches() {
223        let path = write_temp("foo\nfoo\nfoo\n").await;
224        let result = execute(
225            json!({
226                "path": path.to_string_lossy(),
227                "old_text": "foo",
228                "new_text": "bar"
229            }),
230            &local_runtime(),
231        )
232        .await;
233        assert!(result.is_error);
234        assert!(
235            result.content.contains("3 times"),
236            "Got: {}",
237            result.content
238        );
239        let _ = tokio::fs::remove_file(&path).await;
240    }
241}