Skip to main content

clawedcode_tools/
lib.rs

1use serde::{Deserialize, Serialize};
2use std::{
3    path::{Component, Path, PathBuf},
4    str::FromStr,
5};
6
7pub const AGENT_TOOL_NAME: &str = "Agent";
8pub const LEGACY_AGENT_TOOL_NAME: &str = "Task";
9
10/// Result of executing a tool.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ToolResult {
13    pub content: String,
14    pub is_error: bool,
15}
16
17/// A tool that can be invoked by the runtime.
18pub trait Tool: Send + Sync {
19    fn name(&self) -> &str;
20    fn description(&self) -> &str;
21    fn needs_approval(&self) -> bool;
22    fn execute(&self, input: serde_json::Value, cwd: &Path) -> ToolResult;
23}
24
25fn resolve_under_cwd(cwd: &Path, rel: &str) -> Result<PathBuf, String> {
26    let path = PathBuf::from_str(rel).map_err(|e| format!("Invalid path '{rel}': {e}"))?;
27    if path.is_absolute() {
28        return Err("Path must be relative".into());
29    }
30
31    let mut clean = PathBuf::new();
32    for comp in path.components() {
33        match comp {
34            Component::CurDir => {}
35            Component::Normal(part) => clean.push(part),
36            Component::ParentDir => return Err("Path must not contain '..'".into()),
37            Component::Prefix(_) | Component::RootDir => return Err("Path must be relative".into()),
38        }
39    }
40
41    if clean.as_os_str().is_empty() {
42        return Err("Path must not be empty".into());
43    }
44
45    Ok(cwd.join(clean))
46}
47
48// --- ReadFile ---
49
50/// Reads a file under cwd with a size limit.
51pub struct ReadFile;
52
53const READ_FILE_MAX_BYTES: usize = 256 * 1024; // 256 KB
54
55impl Tool for ReadFile {
56    fn name(&self) -> &str {
57        "read_file"
58    }
59
60    fn description(&self) -> &str {
61        "Read the contents of a file under the working directory"
62    }
63
64    fn needs_approval(&self) -> bool {
65        false
66    }
67
68    fn execute(&self, input: serde_json::Value, cwd: &Path) -> ToolResult {
69        let path_str = match input.get("path").and_then(|v| v.as_str()) {
70            Some(s) => s,
71            None => {
72                return ToolResult {
73                    content: "Missing 'path' parameter".into(),
74                    is_error: true,
75                };
76            }
77        };
78
79        let full_path = match resolve_under_cwd(cwd, path_str) {
80            Ok(p) => p,
81            Err(e) => {
82                return ToolResult {
83                    content: e,
84                    is_error: true,
85                };
86            }
87        };
88
89        // Security: ensure the resolved path is under cwd
90        let canonical_cwd = match cwd.canonicalize() {
91            Ok(p) => p,
92            Err(e) => {
93                return ToolResult {
94                    content: format!("Cannot resolve cwd: {e}"),
95                    is_error: true,
96                };
97            }
98        };
99        let canonical_path = match full_path.canonicalize() {
100            Ok(p) => p,
101            Err(e) => {
102                return ToolResult {
103                    content: format!("Cannot resolve path: {e}"),
104                    is_error: true,
105                };
106            }
107        };
108        if !canonical_path.starts_with(&canonical_cwd) {
109            return ToolResult {
110                content: "Path escapes working directory".into(),
111                is_error: true,
112            };
113        }
114
115        let metadata = match std::fs::metadata(&canonical_path) {
116            Ok(m) => m,
117            Err(e) => {
118                return ToolResult {
119                    content: format!("Cannot read file: {e}"),
120                    is_error: true,
121                };
122            }
123        };
124
125        if metadata.len() as usize > READ_FILE_MAX_BYTES {
126            return ToolResult {
127                content: format!(
128                    "File too large ({} bytes, limit {})",
129                    metadata.len(),
130                    READ_FILE_MAX_BYTES
131                ),
132                is_error: true,
133            };
134        }
135
136        match std::fs::read_to_string(&canonical_path) {
137            Ok(contents) => ToolResult {
138                content: contents,
139                is_error: false,
140            },
141            Err(e) => ToolResult {
142                content: format!("Read error: {e}"),
143                is_error: true,
144            },
145        }
146    }
147}
148
149// --- Shell ---
150
151/// Runs a shell command in cwd.
152pub struct Shell;
153
154const SHELL_MAX_OUTPUT_BYTES: usize = 128 * 1024; // 128 KB
155
156impl Tool for Shell {
157    fn name(&self) -> &str {
158        "shell"
159    }
160
161    fn description(&self) -> &str {
162        "Run a shell command in the working directory"
163    }
164
165    fn needs_approval(&self) -> bool {
166        true
167    }
168
169    fn execute(&self, input: serde_json::Value, cwd: &Path) -> ToolResult {
170        let command = match input.get("command").and_then(|v| v.as_str()) {
171            Some(s) => s,
172            None => {
173                return ToolResult {
174                    content: "Missing 'command' parameter".into(),
175                    is_error: true,
176                };
177            }
178        };
179
180        let mut cmd = if cfg!(windows) {
181            let mut c = std::process::Command::new("cmd");
182            c.arg("/C").arg(command);
183            c
184        } else {
185            let mut c = std::process::Command::new("sh");
186            c.arg("-c").arg(command);
187            c
188        };
189
190        let output = cmd.current_dir(cwd).output();
191
192        match output {
193            Ok(out) => {
194                let mut content = String::new();
195                let stdout = String::from_utf8_lossy(&out.stdout);
196                let stderr = String::from_utf8_lossy(&out.stderr);
197
198                if !stdout.is_empty() {
199                    content.push_str(&truncate(&stdout, SHELL_MAX_OUTPUT_BYTES));
200                }
201                if !stderr.is_empty() {
202                    if !content.is_empty() {
203                        content.push_str("\n--- stderr ---\n");
204                    }
205                    content.push_str(&truncate(&stderr, SHELL_MAX_OUTPUT_BYTES));
206                }
207                if content.is_empty() {
208                    content = format!("(exit {})", out.status.code().unwrap_or(-1));
209                }
210
211                ToolResult {
212                    content,
213                    is_error: !out.status.success(),
214                }
215            }
216            Err(e) => ToolResult {
217                content: format!("Command execution failed: {e}"),
218                is_error: true,
219            },
220        }
221    }
222}
223
224fn truncate(s: &str, max_bytes: usize) -> String {
225    if s.len() <= max_bytes {
226        s.to_string()
227    } else {
228        let mut end = max_bytes;
229        while !s.is_char_boundary(end) && end > 0 {
230            end -= 1;
231        }
232        format!("{}... [truncated]", &s[..end])
233    }
234}
235
236// --- ApplyPatch ---
237
238/// Applies structured edits to files under cwd.
239///
240/// Input schema:
241/// `{ "patch": "*** Begin Patch\n...*** End Patch\n" }`
242pub struct ApplyPatch;
243
244impl Tool for ApplyPatch {
245    fn name(&self) -> &str {
246        "apply_patch"
247    }
248
249    fn description(&self) -> &str {
250        "Apply structured file edits under the working directory"
251    }
252
253    fn needs_approval(&self) -> bool {
254        true
255    }
256
257    fn execute(&self, input: serde_json::Value, cwd: &Path) -> ToolResult {
258        let patch = match input.get("patch").and_then(|v| v.as_str()) {
259            Some(s) => s,
260            None => {
261                return ToolResult {
262                    content: "Missing 'patch' parameter".into(),
263                    is_error: true,
264                };
265            }
266        };
267
268        match apply_patch_text(cwd, patch) {
269            Ok(summary) => ToolResult {
270                content: summary,
271                is_error: false,
272            },
273            Err(e) => ToolResult {
274                content: format!("apply_patch failed: {e}"),
275                is_error: true,
276            },
277        }
278    }
279}
280
281#[derive(Debug, Clone)]
282enum PatchHunk {
283    AddFile {
284        path: String,
285        lines: Vec<String>,
286    },
287    DeleteFile {
288        path: String,
289    },
290    UpdateFile {
291        path: String,
292        move_to: Option<String>,
293        chunks: Vec<UpdateChunk>,
294    },
295}
296
297#[derive(Debug, Clone)]
298struct UpdateChunk {
299    before: Vec<String>,
300    after: Vec<String>,
301}
302
303fn apply_patch_text(cwd: &Path, patch: &str) -> Result<String, String> {
304    let mut lines: Vec<&str> = patch.lines().collect();
305
306    // Tolerate trailing newline that creates an empty last line when using split_inclusive elsewhere.
307    if lines.last().copied() == Some("") {
308        lines.pop();
309    }
310
311    if lines.first().copied() != Some("*** Begin Patch") {
312        return Err("Patch must start with '*** Begin Patch'".into());
313    }
314    if lines.last().copied() != Some("*** End Patch") {
315        return Err("Patch must end with '*** End Patch'".into());
316    }
317
318    let hunks = parse_hunks(&lines[1..lines.len() - 1])?;
319    let mut applied = Vec::new();
320
321    for h in hunks {
322        match h {
323            PatchHunk::AddFile { path, lines } => {
324                let dest = resolve_under_cwd(cwd, &path)?;
325                if let Some(parent) = dest.parent() {
326                    std::fs::create_dir_all(parent).map_err(|e| format!("{e}"))?;
327                }
328                let mut content = lines.join("\n");
329                if !content.ends_with('\n') {
330                    content.push('\n');
331                }
332                std::fs::write(&dest, content).map_err(|e| format!("{e}"))?;
333                applied.push(format!("add {path}"));
334            }
335            PatchHunk::DeleteFile { path } => {
336                let dest = resolve_under_cwd(cwd, &path)?;
337                std::fs::remove_file(&dest).map_err(|e| format!("{e}"))?;
338                applied.push(format!("delete {path}"));
339            }
340            PatchHunk::UpdateFile {
341                path,
342                move_to,
343                chunks,
344            } => {
345                let src = resolve_under_cwd(cwd, &path)?;
346                let raw = std::fs::read_to_string(&src).map_err(|e| format!("{e}"))?;
347                let mut file_lines: Vec<String> = raw.lines().map(|s| s.to_string()).collect();
348
349                for chunk in &chunks {
350                    apply_update_chunk(&mut file_lines, chunk)?;
351                }
352
353                let mut new_content = file_lines.join("\n");
354                if !new_content.ends_with('\n') {
355                    new_content.push('\n');
356                }
357                std::fs::write(&src, new_content).map_err(|e| format!("{e}"))?;
358
359                if let Some(to) = move_to {
360                    let dest = resolve_under_cwd(cwd, &to)?;
361                    if let Some(parent) = dest.parent() {
362                        std::fs::create_dir_all(parent).map_err(|e| format!("{e}"))?;
363                    }
364                    std::fs::rename(&src, &dest).map_err(|e| format!("{e}"))?;
365                    applied.push(format!("update {path} -> {to}"));
366                } else {
367                    applied.push(format!("update {path}"));
368                }
369            }
370        }
371    }
372
373    Ok(applied.join("\n"))
374}
375
376fn parse_hunks(lines: &[&str]) -> Result<Vec<PatchHunk>, String> {
377    let mut i = 0usize;
378    let mut hunks = Vec::new();
379
380    while i < lines.len() {
381        let line = lines[i];
382        if let Some(rest) = line.strip_prefix("*** Add File: ") {
383            let path = rest.trim().to_string();
384            i += 1;
385            let mut add_lines = Vec::new();
386            while i < lines.len() && !lines[i].starts_with("*** ") {
387                let l = lines[i];
388                if let Some(content) = l.strip_prefix('+') {
389                    add_lines.push(content.to_string());
390                } else {
391                    return Err(format!("Add File lines must start with '+': {l}"));
392                }
393                i += 1;
394            }
395            hunks.push(PatchHunk::AddFile {
396                path,
397                lines: add_lines,
398            });
399            continue;
400        }
401
402        if let Some(rest) = line.strip_prefix("*** Delete File: ") {
403            let path = rest.trim().to_string();
404            i += 1;
405            hunks.push(PatchHunk::DeleteFile { path });
406            continue;
407        }
408
409        if let Some(rest) = line.strip_prefix("*** Update File: ") {
410            let path = rest.trim().to_string();
411            i += 1;
412
413            let mut move_to: Option<String> = None;
414            if i < lines.len() {
415                if let Some(rest) = lines[i].strip_prefix("*** Move to: ") {
416                    move_to = Some(rest.trim().to_string());
417                    i += 1;
418                }
419            }
420
421            let mut chunks: Vec<UpdateChunk> = Vec::new();
422            let mut current = UpdateChunk {
423                before: Vec::new(),
424                after: Vec::new(),
425            };
426
427            while i < lines.len() && !lines[i].starts_with("*** ") {
428                let l = lines[i];
429                if l.starts_with("@@") {
430                    if !current.before.is_empty() || !current.after.is_empty() {
431                        chunks.push(current);
432                        current = UpdateChunk {
433                            before: Vec::new(),
434                            after: Vec::new(),
435                        };
436                    }
437                    i += 1;
438                    continue;
439                }
440                if l == "*** End of File" {
441                    i += 1;
442                    continue;
443                }
444
445                let (prefix, content) = l.split_at(1);
446                match prefix {
447                    " " => {
448                        current.before.push(content.to_string());
449                        current.after.push(content.to_string());
450                    }
451                    "-" => {
452                        current.before.push(content.to_string());
453                    }
454                    "+" => {
455                        current.after.push(content.to_string());
456                    }
457                    _ => return Err(format!("Invalid update line: {l}")),
458                }
459                i += 1;
460            }
461
462            if !current.before.is_empty() || !current.after.is_empty() {
463                chunks.push(current);
464            }
465
466            if chunks.is_empty() {
467                return Err(format!("Update File hunk for '{path}' had no changes"));
468            }
469
470            hunks.push(PatchHunk::UpdateFile {
471                path,
472                move_to,
473                chunks,
474            });
475            continue;
476        }
477
478        return Err(format!("Unexpected line in patch: {line}"));
479    }
480
481    Ok(hunks)
482}
483
484fn apply_update_chunk(file_lines: &mut Vec<String>, chunk: &UpdateChunk) -> Result<(), String> {
485    if chunk.before.is_empty() {
486        return Err("Chunk has empty 'before' context; refusing to apply ambiguous patch".into());
487    }
488
489    let mut matches = Vec::new();
490    for start in 0..=file_lines.len().saturating_sub(chunk.before.len()) {
491        if file_lines[start..start + chunk.before.len()] == chunk.before[..] {
492            matches.push(start);
493        }
494    }
495
496    match matches.as_slice() {
497        [] => Err("Chunk context not found in file".into()),
498        [start] => {
499            let start = *start;
500            file_lines.splice(start..start + chunk.before.len(), chunk.after.clone());
501            Ok(())
502        }
503        _ => Err("Chunk context matched multiple locations; refusing to apply".into()),
504    }
505}
506
507// --- Registry ---
508
509/// Returns all built-in tool instances.
510pub fn builtin_tool_instances() -> Vec<Box<dyn Tool>> {
511    vec![Box::new(ReadFile), Box::new(Shell), Box::new(ApplyPatch)]
512}
513
514/// Returns the ToolSpec list for API compatibility (kept for existing code).
515pub fn builtin_tools() -> Vec<ToolSpec> {
516    vec![
517        ToolSpec {
518            name: "shell".to_string(),
519            description: "Run local commands inside the working directory. Set run_in_background to true to run long-running commands without blocking.".to_string(),
520            needs_approval: true,
521            input_schema: serde_json::json!({
522                "type": "object",
523                "properties": {
524                    "command": {
525                        "type": "string",
526                        "description": "The shell command to execute"
527                    },
528                    "run_in_background": {
529                        "type": "boolean",
530                        "description": "Run the command in the background without blocking (default: false)",
531                        "default": false
532                    }
533                },
534                "required": ["command"]
535            }),
536        },
537        ToolSpec {
538            name: "read_file".to_string(),
539            description: "Read the contents of a file under the working directory".to_string(),
540            needs_approval: false,
541            input_schema: serde_json::json!({
542                "type": "object",
543                "properties": {
544                    "path": {
545                        "type": "string",
546                        "description": "Relative path to the file to read"
547                    }
548                },
549                "required": ["path"]
550            }),
551        },
552        ToolSpec {
553            name: "apply_patch".to_string(),
554            description: "Apply structured file edits".to_string(),
555            needs_approval: true,
556            input_schema: serde_json::json!({
557                "type": "object",
558                "properties": {
559                    "patch": {
560                        "type": "string",
561                        "description": "The patch content in structured format"
562                    }
563                },
564                "required": ["patch"]
565            }),
566        },
567        ToolSpec {
568            name: AGENT_TOOL_NAME.to_string(),
569            description: "Launch a new agent to handle a bounded task. Use this when the work is complex enough to benefit from a forked sub-agent; omit subagent_type to fork with the current conversation context.".to_string(),
570            needs_approval: false,
571            input_schema: serde_json::json!({
572                "type": "object",
573                "properties": {
574                    "description": {
575                        "type": "string",
576                        "description": "A short 3-5 word description of the delegated task"
577                    },
578                    "prompt": {
579                        "type": "string",
580                        "description": "The task for the spawned agent to perform"
581                    },
582                    "subagent_type": {
583                        "type": "string",
584                        "description": "Optional specialized agent type. Omit to fork with the current conversation context."
585                    }
586                },
587                "required": ["description", "prompt"]
588            }),
589        },
590        ToolSpec {
591            name: LEGACY_AGENT_TOOL_NAME.to_string(),
592            description: "Legacy alias for Agent. Launch a new agent to handle a bounded task.".to_string(),
593            needs_approval: false,
594            input_schema: serde_json::json!({
595                "type": "object",
596                "properties": {
597                    "description": {
598                        "type": "string",
599                        "description": "A short 3-5 word description of the delegated task"
600                    },
601                    "prompt": {
602                        "type": "string",
603                        "description": "The task for the spawned agent to perform"
604                    },
605                    "subagent_type": {
606                        "type": "string",
607                        "description": "Optional specialized agent type. Omit to fork with the current conversation context."
608                    }
609                },
610                "required": ["description", "prompt"]
611            }),
612        },
613        ToolSpec {
614            name: "TaskCreate".to_string(),
615            description: "Create a new task in the task list. Use this tool when you need to create a task with a subject and description.".to_string(),
616            needs_approval: false,
617            input_schema: serde_json::json!({
618                "type": "object",
619                "properties": {
620                    "subject": {
621                        "type": "string",
622                        "description": "A brief title for the task"
623                    },
624                    "description": {
625                        "type": "string",
626                        "description": "What needs to be done"
627                    },
628                    "activeForm": {
629                        "type": "string",
630                        "description": "Present continuous form shown in spinner when in_progress (e.g., 'Running tests')"
631                    },
632                    "metadata": {
633                        "type": "object",
634                        "description": "Arbitrary metadata to attach to the task"
635                    }
636                },
637                "required": ["subject", "description"]
638            }),
639        },
640        ToolSpec {
641            name: "TaskList".to_string(),
642            description: "List all tasks in the task list. Use this tool to see all tasks and their current status.".to_string(),
643            needs_approval: false,
644            input_schema: serde_json::json!({
645                "type": "object",
646                "properties": {}
647            }),
648        },
649        ToolSpec {
650            name: "TaskGet".to_string(),
651            description: "Get a specific task by ID. Use this tool when you need to retrieve detailed information about a single task.".to_string(),
652            needs_approval: false,
653            input_schema: serde_json::json!({
654                "type": "object",
655                "properties": {
656                    "taskId": {
657                        "type": "string",
658                        "description": "The ID of the task to retrieve"
659                    }
660                },
661                "required": ["taskId"]
662            }),
663        },
664        ToolSpec {
665            name: "TaskUpdate".to_string(),
666            description: "Update a task by ID. Use this tool to modify task status, owner, subject, description, or blocking relationships.".to_string(),
667            needs_approval: false,
668            input_schema: serde_json::json!({
669                "type": "object",
670                "properties": {
671                    "taskId": {
672                        "type": "string",
673                        "description": "The ID of the task to update"
674                    },
675                    "subject": {
676                        "type": "string",
677                        "description": "New subject for the task"
678                    },
679                    "description": {
680                        "type": "string",
681                        "description": "New description for the task"
682                    },
683                    "activeForm": {
684                        "type": "string",
685                        "description": "Present continuous form shown in spinner when in_progress"
686                    },
687                    "status": {
688                        "type": "string",
689                        "enum": ["pending", "in_progress", "completed", "deleted"],
690                        "description": "New status for the task"
691                    },
692                    "owner": {
693                        "type": "string",
694                        "description": "New owner for the task"
695                    },
696                    "addBlocks": {
697                        "type": "array",
698                        "items": {"type": "string"},
699                        "description": "Task IDs that this task blocks"
700                    },
701                    "addBlockedBy": {
702                        "type": "array",
703                        "items": {"type": "string"},
704                        "description": "Task IDs that block this task"
705                    },
706                    "metadata": {
707                        "type": "object",
708                        "description": "Metadata keys to merge into the task"
709                    }
710                },
711                "required": ["taskId"]
712            }),
713        },
714        ToolSpec {
715            name: "TaskOutput".to_string(),
716            description: "Get output from a background task by ID. Use this tool to read the output file and status of a previously started background shell command.".to_string(),
717            needs_approval: false,
718            input_schema: serde_json::json!({
719                "type": "object",
720                "properties": {
721                    "task_id": {
722                        "type": "string",
723                        "description": "The ID of the background task"
724                    },
725                    "block": {
726                        "type": "boolean",
727                        "description": "Whether to wait for task completion (default: true)",
728                        "default": true
729                    },
730                    "timeout": {
731                        "type": "number",
732                        "description": "Max wait time in ms (default: 30000)",
733                        "default": 30000
734                    }
735                },
736                "required": ["task_id"]
737            }),
738        },
739        ToolSpec {
740            name: "TaskStop".to_string(),
741            description: "Stop a running background task by ID. Use this tool to kill a background shell command that is still running.".to_string(),
742            needs_approval: true,
743            input_schema: serde_json::json!({
744                "type": "object",
745                "properties": {
746                    "task_id": {
747                        "type": "string",
748                        "description": "The ID of the background task to stop"
749                    },
750                    "shell_id": {
751                        "type": "string",
752                        "description": "Deprecated compatibility alias for task_id"
753                    }
754                },
755                "anyOf": [
756                    { "required": ["task_id"] },
757                    { "required": ["shell_id"] }
758                ]
759            }),
760        },
761        ToolSpec {
762            name: "Agent".to_string(),
763            description: "Launch a specialized agent to handle complex tasks. Use this when you need to perform multi-step operations that require careful planning and execution.".to_string(),
764            needs_approval: false,
765            input_schema: serde_json::json!({
766                "type": "object",
767                "properties": {
768                    "description": {
769                        "type": "string",
770                        "description": "A short description of what the agent will do"
771                    },
772                    "prompt": {
773                        "type": "string",
774                        "description": "The task for the agent to perform"
775                    },
776                    "subagent_type": {
777                        "type": "string",
778                        "description": "Optional type of specialized agent to use"
779                    }
780                },
781                "required": ["description", "prompt"]
782            }),
783        },
784        ToolSpec {
785            name: "Task".to_string(),
786            description: "Launch a specialized agent to handle complex tasks. Use this when you need to perform multi-step operations that require careful planning and execution.".to_string(),
787            needs_approval: false,
788            input_schema: serde_json::json!({
789                "type": "object",
790                "properties": {
791                    "description": {
792                        "type": "string",
793                        "description": "A short description of what the agent will do"
794                    },
795                    "prompt": {
796                        "type": "string",
797                        "description": "The task for the agent to perform"
798                    },
799                    "subagent_type": {
800                        "type": "string",
801                        "description": "Optional type of specialized agent to use"
802                    }
803                },
804                "required": ["description", "prompt"]
805            }),
806        },
807    ]
808}
809
810#[derive(Debug, Clone, Serialize, Deserialize)]
811pub struct ToolSpec {
812    pub name: String,
813    pub description: String,
814    pub needs_approval: bool,
815    pub input_schema: serde_json::Value,
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821
822    fn temp_dir() -> std::path::PathBuf {
823        let dir = std::env::temp_dir().join(format!("clawed_tools_test_{}", uuid::Uuid::new_v4()));
824        std::fs::create_dir_all(&dir).unwrap();
825        dir
826    }
827
828    #[test]
829    fn read_file_reads_existing_file() {
830        let dir = temp_dir();
831        let file = dir.join("hello.txt");
832        std::fs::write(&file, "hello world").unwrap();
833
834        let tool = ReadFile;
835        let result = tool.execute(serde_json::json!({"path": "hello.txt"}), &dir);
836
837        assert!(!result.is_error);
838        assert_eq!(result.content, "hello world");
839
840        std::fs::remove_dir_all(&dir).ok();
841    }
842
843    #[test]
844    fn read_file_errors_on_missing_file() {
845        let dir = temp_dir();
846        let tool = ReadFile;
847        let result = tool.execute(serde_json::json!({"path": "nonexistent.txt"}), &dir);
848
849        assert!(result.is_error);
850        assert!(result.content.contains("Cannot resolve path"));
851
852        std::fs::remove_dir_all(&dir).ok();
853    }
854
855    #[test]
856    fn read_file_errors_on_missing_param() {
857        let dir = temp_dir();
858        let tool = ReadFile;
859        let result = tool.execute(serde_json::json!({}), &dir);
860
861        assert!(result.is_error);
862        assert!(result.content.contains("Missing 'path'"));
863
864        std::fs::remove_dir_all(&dir).ok();
865    }
866
867    #[test]
868    fn read_file_blocks_path_escape() {
869        let dir = temp_dir();
870        let tool = ReadFile;
871        let result = tool.execute(serde_json::json!({"path": "../../../etc/passwd"}), &dir);
872
873        assert!(result.is_error);
874        assert!(
875            result.content.contains("escapes working directory")
876                || result.content.contains("must not contain '..'")
877        );
878
879        std::fs::remove_dir_all(&dir).ok();
880    }
881
882    #[test]
883    fn shell_runs_echo() {
884        let dir = temp_dir();
885        let tool = Shell;
886        let result = tool.execute(serde_json::json!({"command": "echo hello"}), &dir);
887
888        assert!(!result.is_error);
889        assert!(result.content.contains("hello"));
890
891        std::fs::remove_dir_all(&dir).ok();
892    }
893
894    #[test]
895    fn shell_errors_on_bad_command() {
896        let dir = temp_dir();
897        let tool = Shell;
898        let result = tool.execute(serde_json::json!({"command": "exit 42"}), &dir);
899
900        assert!(result.is_error);
901
902        std::fs::remove_dir_all(&dir).ok();
903    }
904
905    #[test]
906    fn shell_errors_on_missing_param() {
907        let dir = temp_dir();
908        let tool = Shell;
909        let result = tool.execute(serde_json::json!({}), &dir);
910
911        assert!(result.is_error);
912        assert!(result.content.contains("Missing 'command'"));
913
914        std::fs::remove_dir_all(&dir).ok();
915    }
916
917    #[test]
918    fn builtin_tool_instances_returns_tools() {
919        let tools = builtin_tool_instances();
920        assert!(tools.len() >= 3);
921        let names: Vec<_> = tools.iter().map(|t| t.name()).collect();
922        assert!(names.contains(&"read_file"));
923        assert!(names.contains(&"shell"));
924        assert!(names.contains(&"apply_patch"));
925    }
926
927    #[test]
928    fn truncate_respects_byte_limit() {
929        let s = "hello world";
930        assert_eq!(truncate(s, 100), "hello world");
931        let truncated = truncate(s, 5);
932        assert!(truncated.len() > 5); // includes "... [truncated]"
933        assert!(truncated.contains("... [truncated]"));
934    }
935
936    #[test]
937    fn apply_patch_add_update_delete_roundtrip() {
938        let dir = temp_dir();
939
940        let tool = ApplyPatch;
941
942        let add_patch = r#"*** Begin Patch
943*** Add File: hello.txt
944+hello
945*** End Patch"#;
946        let res = tool.execute(serde_json::json!({ "patch": add_patch }), &dir);
947        assert!(!res.is_error, "{:?}", res.content);
948        assert!(dir.join("hello.txt").exists());
949
950        let update_patch = r#"*** Begin Patch
951*** Update File: hello.txt
952@@
953-hello
954+hello world
955*** End Patch"#;
956        let res = tool.execute(serde_json::json!({ "patch": update_patch }), &dir);
957        assert!(!res.is_error, "{:?}", res.content);
958        let contents = std::fs::read_to_string(dir.join("hello.txt")).unwrap();
959        assert!(contents.contains("hello world"));
960
961        let delete_patch = r#"*** Begin Patch
962*** Delete File: hello.txt
963*** End Patch"#;
964        let res = tool.execute(serde_json::json!({ "patch": delete_patch }), &dir);
965        assert!(!res.is_error, "{:?}", res.content);
966        assert!(!dir.join("hello.txt").exists());
967
968        std::fs::remove_dir_all(&dir).ok();
969    }
970}