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#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ToolResult {
13 pub content: String,
14 pub is_error: bool,
15}
16
17pub 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
48pub struct ReadFile;
52
53const READ_FILE_MAX_BYTES: usize = 256 * 1024; impl 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 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
149pub struct Shell;
153
154const SHELL_MAX_OUTPUT_BYTES: usize = 128 * 1024; #[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
265pub 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 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
536pub fn builtin_tool_instances() -> Vec<Box<dyn Tool>> {
540 vec![Box::new(ReadFile), Box::new(Shell), Box::new(ApplyPatch)]
541}
542
543pub 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); 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}