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; impl 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
236pub 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 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
507pub fn builtin_tool_instances() -> Vec<Box<dyn Tool>> {
511 vec![Box::new(ReadFile), Box::new(Shell), Box::new(ApplyPatch)]
512}
513
514pub 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); 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}