Skip to main content

clawedcode_tools/
lib.rs

1use serde::{Deserialize, Serialize};
2use std::{
3    path::{Component, Path, PathBuf},
4    str::FromStr,
5};
6
7/// Result of executing a tool.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ToolResult {
10    pub content: String,
11    pub is_error: bool,
12}
13
14/// A tool that can be invoked by the runtime.
15pub 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
45// --- ReadFile ---
46
47/// Reads a file under cwd with a size limit.
48pub struct ReadFile;
49
50const READ_FILE_MAX_BYTES: usize = 256 * 1024; // 256 KB
51
52impl 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        // Security: ensure the resolved path is under cwd
87        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
146// --- Shell ---
147
148/// Runs a shell command in cwd.
149pub struct Shell;
150
151const SHELL_MAX_OUTPUT_BYTES: usize = 128 * 1024; // 128 KB
152
153impl 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
233// --- ApplyPatch ---
234
235/// Applies structured edits to files under cwd.
236///
237/// Input schema:
238/// `{ "patch": "*** Begin Patch\n...*** End Patch\n" }`
239pub 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    // Tolerate trailing newline that creates an empty last line when using split_inclusive elsewhere.
304    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
504// --- Registry ---
505
506/// Returns all built-in tool instances.
507pub fn builtin_tool_instances() -> Vec<Box<dyn Tool>> {
508    vec![Box::new(ReadFile), Box::new(Shell), Box::new(ApplyPatch)]
509}
510
511/// Returns the ToolSpec list for API compatibility (kept for existing code).
512pub 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); // includes "... [truncated]"
685        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}