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