1use serde::{Deserialize, Serialize};
2use std::{
3 path::{Component, Path, PathBuf},
4 str::FromStr,
5};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ToolResult {
10 pub content: String,
11 pub is_error: bool,
12}
13
14pub trait Tool: Send + Sync {
16 fn name(&self) -> &str;
17 fn description(&self) -> &str;
18 fn needs_approval(&self) -> bool;
19 fn execute(&self, input: serde_json::Value, cwd: &Path) -> ToolResult;
20}
21
22fn resolve_under_cwd(cwd: &Path, rel: &str) -> Result<PathBuf, String> {
23 let path = PathBuf::from_str(rel).map_err(|e| format!("Invalid path '{rel}': {e}"))?;
24 if path.is_absolute() {
25 return Err("Path must be relative".into());
26 }
27
28 let mut clean = PathBuf::new();
29 for comp in path.components() {
30 match comp {
31 Component::CurDir => {}
32 Component::Normal(part) => clean.push(part),
33 Component::ParentDir => return Err("Path must not contain '..'".into()),
34 Component::Prefix(_) | Component::RootDir => return Err("Path must be relative".into()),
35 }
36 }
37
38 if clean.as_os_str().is_empty() {
39 return Err("Path must not be empty".into());
40 }
41
42 Ok(cwd.join(clean))
43}
44
45pub struct ReadFile;
49
50const READ_FILE_MAX_BYTES: usize = 256 * 1024; impl Tool for ReadFile {
53 fn name(&self) -> &str {
54 "read_file"
55 }
56
57 fn description(&self) -> &str {
58 "Read the contents of a file under the working directory"
59 }
60
61 fn needs_approval(&self) -> bool {
62 false
63 }
64
65 fn execute(&self, input: serde_json::Value, cwd: &Path) -> ToolResult {
66 let path_str = match input.get("path").and_then(|v| v.as_str()) {
67 Some(s) => s,
68 None => {
69 return ToolResult {
70 content: "Missing 'path' parameter".into(),
71 is_error: true,
72 };
73 }
74 };
75
76 let full_path = match resolve_under_cwd(cwd, path_str) {
77 Ok(p) => p,
78 Err(e) => {
79 return ToolResult {
80 content: e,
81 is_error: true,
82 };
83 }
84 };
85
86 let canonical_cwd = match cwd.canonicalize() {
88 Ok(p) => p,
89 Err(e) => {
90 return ToolResult {
91 content: format!("Cannot resolve cwd: {e}"),
92 is_error: true,
93 };
94 }
95 };
96 let canonical_path = match full_path.canonicalize() {
97 Ok(p) => p,
98 Err(e) => {
99 return ToolResult {
100 content: format!("Cannot resolve path: {e}"),
101 is_error: true,
102 };
103 }
104 };
105 if !canonical_path.starts_with(&canonical_cwd) {
106 return ToolResult {
107 content: "Path escapes working directory".into(),
108 is_error: true,
109 };
110 }
111
112 let metadata = match std::fs::metadata(&canonical_path) {
113 Ok(m) => m,
114 Err(e) => {
115 return ToolResult {
116 content: format!("Cannot read file: {e}"),
117 is_error: true,
118 };
119 }
120 };
121
122 if metadata.len() as usize > READ_FILE_MAX_BYTES {
123 return ToolResult {
124 content: format!(
125 "File too large ({} bytes, limit {})",
126 metadata.len(),
127 READ_FILE_MAX_BYTES
128 ),
129 is_error: true,
130 };
131 }
132
133 match std::fs::read_to_string(&canonical_path) {
134 Ok(contents) => ToolResult {
135 content: contents,
136 is_error: false,
137 },
138 Err(e) => ToolResult {
139 content: format!("Read error: {e}"),
140 is_error: true,
141 },
142 }
143 }
144}
145
146pub struct Shell;
150
151const SHELL_MAX_OUTPUT_BYTES: usize = 128 * 1024; impl Tool for Shell {
154 fn name(&self) -> &str {
155 "shell"
156 }
157
158 fn description(&self) -> &str {
159 "Run a shell command in the working directory"
160 }
161
162 fn needs_approval(&self) -> bool {
163 true
164 }
165
166 fn execute(&self, input: serde_json::Value, cwd: &Path) -> ToolResult {
167 let command = match input.get("command").and_then(|v| v.as_str()) {
168 Some(s) => s,
169 None => {
170 return ToolResult {
171 content: "Missing 'command' parameter".into(),
172 is_error: true,
173 };
174 }
175 };
176
177 let mut cmd = if cfg!(windows) {
178 let mut c = std::process::Command::new("cmd");
179 c.arg("/C").arg(command);
180 c
181 } else {
182 let mut c = std::process::Command::new("sh");
183 c.arg("-c").arg(command);
184 c
185 };
186
187 let output = cmd.current_dir(cwd).output();
188
189 match output {
190 Ok(out) => {
191 let mut content = String::new();
192 let stdout = String::from_utf8_lossy(&out.stdout);
193 let stderr = String::from_utf8_lossy(&out.stderr);
194
195 if !stdout.is_empty() {
196 content.push_str(&truncate(&stdout, SHELL_MAX_OUTPUT_BYTES));
197 }
198 if !stderr.is_empty() {
199 if !content.is_empty() {
200 content.push_str("\n--- stderr ---\n");
201 }
202 content.push_str(&truncate(&stderr, SHELL_MAX_OUTPUT_BYTES));
203 }
204 if content.is_empty() {
205 content = format!("(exit {})", out.status.code().unwrap_or(-1));
206 }
207
208 ToolResult {
209 content,
210 is_error: !out.status.success(),
211 }
212 }
213 Err(e) => ToolResult {
214 content: format!("Command execution failed: {e}"),
215 is_error: true,
216 },
217 }
218 }
219}
220
221fn truncate(s: &str, max_bytes: usize) -> String {
222 if s.len() <= max_bytes {
223 s.to_string()
224 } else {
225 let mut end = max_bytes;
226 while !s.is_char_boundary(end) && end > 0 {
227 end -= 1;
228 }
229 format!("{}... [truncated]", &s[..end])
230 }
231}
232
233pub struct ApplyPatch;
240
241impl Tool for ApplyPatch {
242 fn name(&self) -> &str {
243 "apply_patch"
244 }
245
246 fn description(&self) -> &str {
247 "Apply structured file edits under the working directory"
248 }
249
250 fn needs_approval(&self) -> bool {
251 true
252 }
253
254 fn execute(&self, input: serde_json::Value, cwd: &Path) -> ToolResult {
255 let patch = match input.get("patch").and_then(|v| v.as_str()) {
256 Some(s) => s,
257 None => {
258 return ToolResult {
259 content: "Missing 'patch' parameter".into(),
260 is_error: true,
261 };
262 }
263 };
264
265 match apply_patch_text(cwd, patch) {
266 Ok(summary) => ToolResult {
267 content: summary,
268 is_error: false,
269 },
270 Err(e) => ToolResult {
271 content: format!("apply_patch failed: {e}"),
272 is_error: true,
273 },
274 }
275 }
276}
277
278#[derive(Debug, Clone)]
279enum PatchHunk {
280 AddFile {
281 path: String,
282 lines: Vec<String>,
283 },
284 DeleteFile {
285 path: String,
286 },
287 UpdateFile {
288 path: String,
289 move_to: Option<String>,
290 chunks: Vec<UpdateChunk>,
291 },
292}
293
294#[derive(Debug, Clone)]
295struct UpdateChunk {
296 before: Vec<String>,
297 after: Vec<String>,
298}
299
300fn apply_patch_text(cwd: &Path, patch: &str) -> Result<String, String> {
301 let mut lines: Vec<&str> = patch.lines().collect();
302
303 if lines.last().copied() == Some("") {
305 lines.pop();
306 }
307
308 if lines.first().copied() != Some("*** Begin Patch") {
309 return Err("Patch must start with '*** Begin Patch'".into());
310 }
311 if lines.last().copied() != Some("*** End Patch") {
312 return Err("Patch must end with '*** End Patch'".into());
313 }
314
315 let hunks = parse_hunks(&lines[1..lines.len() - 1])?;
316 let mut applied = Vec::new();
317
318 for h in hunks {
319 match h {
320 PatchHunk::AddFile { path, lines } => {
321 let dest = resolve_under_cwd(cwd, &path)?;
322 if let Some(parent) = dest.parent() {
323 std::fs::create_dir_all(parent).map_err(|e| format!("{e}"))?;
324 }
325 let mut content = lines.join("\n");
326 if !content.ends_with('\n') {
327 content.push('\n');
328 }
329 std::fs::write(&dest, content).map_err(|e| format!("{e}"))?;
330 applied.push(format!("add {path}"));
331 }
332 PatchHunk::DeleteFile { path } => {
333 let dest = resolve_under_cwd(cwd, &path)?;
334 std::fs::remove_file(&dest).map_err(|e| format!("{e}"))?;
335 applied.push(format!("delete {path}"));
336 }
337 PatchHunk::UpdateFile {
338 path,
339 move_to,
340 chunks,
341 } => {
342 let src = resolve_under_cwd(cwd, &path)?;
343 let raw = std::fs::read_to_string(&src).map_err(|e| format!("{e}"))?;
344 let mut file_lines: Vec<String> = raw.lines().map(|s| s.to_string()).collect();
345
346 for chunk in &chunks {
347 apply_update_chunk(&mut file_lines, chunk)?;
348 }
349
350 let mut new_content = file_lines.join("\n");
351 if !new_content.ends_with('\n') {
352 new_content.push('\n');
353 }
354 std::fs::write(&src, new_content).map_err(|e| format!("{e}"))?;
355
356 if let Some(to) = move_to {
357 let dest = resolve_under_cwd(cwd, &to)?;
358 if let Some(parent) = dest.parent() {
359 std::fs::create_dir_all(parent).map_err(|e| format!("{e}"))?;
360 }
361 std::fs::rename(&src, &dest).map_err(|e| format!("{e}"))?;
362 applied.push(format!("update {path} -> {to}"));
363 } else {
364 applied.push(format!("update {path}"));
365 }
366 }
367 }
368 }
369
370 Ok(applied.join("\n"))
371}
372
373fn parse_hunks(lines: &[&str]) -> Result<Vec<PatchHunk>, String> {
374 let mut i = 0usize;
375 let mut hunks = Vec::new();
376
377 while i < lines.len() {
378 let line = lines[i];
379 if let Some(rest) = line.strip_prefix("*** Add File: ") {
380 let path = rest.trim().to_string();
381 i += 1;
382 let mut add_lines = Vec::new();
383 while i < lines.len() && !lines[i].starts_with("*** ") {
384 let l = lines[i];
385 if let Some(content) = l.strip_prefix('+') {
386 add_lines.push(content.to_string());
387 } else {
388 return Err(format!("Add File lines must start with '+': {l}"));
389 }
390 i += 1;
391 }
392 hunks.push(PatchHunk::AddFile {
393 path,
394 lines: add_lines,
395 });
396 continue;
397 }
398
399 if let Some(rest) = line.strip_prefix("*** Delete File: ") {
400 let path = rest.trim().to_string();
401 i += 1;
402 hunks.push(PatchHunk::DeleteFile { path });
403 continue;
404 }
405
406 if let Some(rest) = line.strip_prefix("*** Update File: ") {
407 let path = rest.trim().to_string();
408 i += 1;
409
410 let mut move_to: Option<String> = None;
411 if i < lines.len() {
412 if let Some(rest) = lines[i].strip_prefix("*** Move to: ") {
413 move_to = Some(rest.trim().to_string());
414 i += 1;
415 }
416 }
417
418 let mut chunks: Vec<UpdateChunk> = Vec::new();
419 let mut current = UpdateChunk {
420 before: Vec::new(),
421 after: Vec::new(),
422 };
423
424 while i < lines.len() && !lines[i].starts_with("*** ") {
425 let l = lines[i];
426 if l.starts_with("@@") {
427 if !current.before.is_empty() || !current.after.is_empty() {
428 chunks.push(current);
429 current = UpdateChunk {
430 before: Vec::new(),
431 after: Vec::new(),
432 };
433 }
434 i += 1;
435 continue;
436 }
437 if l == "*** End of File" {
438 i += 1;
439 continue;
440 }
441
442 let (prefix, content) = l.split_at(1);
443 match prefix {
444 " " => {
445 current.before.push(content.to_string());
446 current.after.push(content.to_string());
447 }
448 "-" => {
449 current.before.push(content.to_string());
450 }
451 "+" => {
452 current.after.push(content.to_string());
453 }
454 _ => return Err(format!("Invalid update line: {l}")),
455 }
456 i += 1;
457 }
458
459 if !current.before.is_empty() || !current.after.is_empty() {
460 chunks.push(current);
461 }
462
463 if chunks.is_empty() {
464 return Err(format!("Update File hunk for '{path}' had no changes"));
465 }
466
467 hunks.push(PatchHunk::UpdateFile {
468 path,
469 move_to,
470 chunks,
471 });
472 continue;
473 }
474
475 return Err(format!("Unexpected line in patch: {line}"));
476 }
477
478 Ok(hunks)
479}
480
481fn apply_update_chunk(file_lines: &mut Vec<String>, chunk: &UpdateChunk) -> Result<(), String> {
482 if chunk.before.is_empty() {
483 return Err("Chunk has empty 'before' context; refusing to apply ambiguous patch".into());
484 }
485
486 let mut matches = Vec::new();
487 for start in 0..=file_lines.len().saturating_sub(chunk.before.len()) {
488 if file_lines[start..start + chunk.before.len()] == chunk.before[..] {
489 matches.push(start);
490 }
491 }
492
493 match matches.as_slice() {
494 [] => Err("Chunk context not found in file".into()),
495 [start] => {
496 let start = *start;
497 file_lines.splice(start..start + chunk.before.len(), chunk.after.clone());
498 Ok(())
499 }
500 _ => Err("Chunk context matched multiple locations; refusing to apply".into()),
501 }
502}
503
504pub fn builtin_tool_instances() -> Vec<Box<dyn Tool>> {
508 vec![Box::new(ReadFile), Box::new(Shell), Box::new(ApplyPatch)]
509}
510
511pub fn builtin_tools() -> Vec<ToolSpec> {
513 vec![
514 ToolSpec {
515 name: "shell".to_string(),
516 description: "Run local commands inside the working directory".to_string(),
517 needs_approval: true,
518 input_schema: serde_json::json!({
519 "type": "object",
520 "properties": {
521 "command": {
522 "type": "string",
523 "description": "The shell command to execute"
524 }
525 },
526 "required": ["command"]
527 }),
528 },
529 ToolSpec {
530 name: "read_file".to_string(),
531 description: "Read the contents of a file under the working directory".to_string(),
532 needs_approval: false,
533 input_schema: serde_json::json!({
534 "type": "object",
535 "properties": {
536 "path": {
537 "type": "string",
538 "description": "Relative path to the file to read"
539 }
540 },
541 "required": ["path"]
542 }),
543 },
544 ToolSpec {
545 name: "apply_patch".to_string(),
546 description: "Apply structured file edits".to_string(),
547 needs_approval: true,
548 input_schema: serde_json::json!({
549 "type": "object",
550 "properties": {
551 "patch": {
552 "type": "string",
553 "description": "The patch content in structured format"
554 }
555 },
556 "required": ["patch"]
557 }),
558 },
559 ]
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct ToolSpec {
564 pub name: String,
565 pub description: String,
566 pub needs_approval: bool,
567 pub input_schema: serde_json::Value,
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 fn temp_dir() -> std::path::PathBuf {
575 let dir = std::env::temp_dir().join(format!("clawed_tools_test_{}", uuid::Uuid::new_v4()));
576 std::fs::create_dir_all(&dir).unwrap();
577 dir
578 }
579
580 #[test]
581 fn read_file_reads_existing_file() {
582 let dir = temp_dir();
583 let file = dir.join("hello.txt");
584 std::fs::write(&file, "hello world").unwrap();
585
586 let tool = ReadFile;
587 let result = tool.execute(serde_json::json!({"path": "hello.txt"}), &dir);
588
589 assert!(!result.is_error);
590 assert_eq!(result.content, "hello world");
591
592 std::fs::remove_dir_all(&dir).ok();
593 }
594
595 #[test]
596 fn read_file_errors_on_missing_file() {
597 let dir = temp_dir();
598 let tool = ReadFile;
599 let result = tool.execute(serde_json::json!({"path": "nonexistent.txt"}), &dir);
600
601 assert!(result.is_error);
602 assert!(result.content.contains("Cannot resolve path"));
603
604 std::fs::remove_dir_all(&dir).ok();
605 }
606
607 #[test]
608 fn read_file_errors_on_missing_param() {
609 let dir = temp_dir();
610 let tool = ReadFile;
611 let result = tool.execute(serde_json::json!({}), &dir);
612
613 assert!(result.is_error);
614 assert!(result.content.contains("Missing 'path'"));
615
616 std::fs::remove_dir_all(&dir).ok();
617 }
618
619 #[test]
620 fn read_file_blocks_path_escape() {
621 let dir = temp_dir();
622 let tool = ReadFile;
623 let result = tool.execute(serde_json::json!({"path": "../../../etc/passwd"}), &dir);
624
625 assert!(result.is_error);
626 assert!(
627 result.content.contains("escapes working directory")
628 || result.content.contains("must not contain '..'")
629 );
630
631 std::fs::remove_dir_all(&dir).ok();
632 }
633
634 #[test]
635 fn shell_runs_echo() {
636 let dir = temp_dir();
637 let tool = Shell;
638 let result = tool.execute(serde_json::json!({"command": "echo hello"}), &dir);
639
640 assert!(!result.is_error);
641 assert!(result.content.contains("hello"));
642
643 std::fs::remove_dir_all(&dir).ok();
644 }
645
646 #[test]
647 fn shell_errors_on_bad_command() {
648 let dir = temp_dir();
649 let tool = Shell;
650 let result = tool.execute(serde_json::json!({"command": "exit 42"}), &dir);
651
652 assert!(result.is_error);
653
654 std::fs::remove_dir_all(&dir).ok();
655 }
656
657 #[test]
658 fn shell_errors_on_missing_param() {
659 let dir = temp_dir();
660 let tool = Shell;
661 let result = tool.execute(serde_json::json!({}), &dir);
662
663 assert!(result.is_error);
664 assert!(result.content.contains("Missing 'command'"));
665
666 std::fs::remove_dir_all(&dir).ok();
667 }
668
669 #[test]
670 fn builtin_tool_instances_returns_tools() {
671 let tools = builtin_tool_instances();
672 assert!(tools.len() >= 3);
673 let names: Vec<_> = tools.iter().map(|t| t.name()).collect();
674 assert!(names.contains(&"read_file"));
675 assert!(names.contains(&"shell"));
676 assert!(names.contains(&"apply_patch"));
677 }
678
679 #[test]
680 fn truncate_respects_byte_limit() {
681 let s = "hello world";
682 assert_eq!(truncate(s, 100), "hello world");
683 let truncated = truncate(s, 5);
684 assert!(truncated.len() > 5); assert!(truncated.contains("... [truncated]"));
686 }
687
688 #[test]
689 fn apply_patch_add_update_delete_roundtrip() {
690 let dir = temp_dir();
691
692 let tool = ApplyPatch;
693
694 let add_patch = r#"*** Begin Patch
695*** Add File: hello.txt
696+hello
697*** End Patch"#;
698 let res = tool.execute(serde_json::json!({ "patch": add_patch }), &dir);
699 assert!(!res.is_error, "{:?}", res.content);
700 assert!(dir.join("hello.txt").exists());
701
702 let update_patch = r#"*** Begin Patch
703*** Update File: hello.txt
704@@
705-hello
706+hello world
707*** End Patch"#;
708 let res = tool.execute(serde_json::json!({ "patch": update_patch }), &dir);
709 assert!(!res.is_error, "{:?}", res.content);
710 let contents = std::fs::read_to_string(dir.join("hello.txt")).unwrap();
711 assert!(contents.contains("hello world"));
712
713 let delete_patch = r#"*** Begin Patch
714*** Delete File: hello.txt
715*** End Patch"#;
716 let res = tool.execute(serde_json::json!({ "patch": delete_patch }), &dir);
717 assert!(!res.is_error, "{:?}", res.content);
718 assert!(!dir.join("hello.txt").exists());
719
720 std::fs::remove_dir_all(&dir).ok();
721 }
722}