1use super::{Capability, CapabilityStatus};
16use crate::session_file::SessionFile;
17use crate::tool_output_sanitizer::build_binary_read_file_result;
18use crate::tool_types::ToolHints;
19use crate::tools::{Tool, ToolExecutionResult, ToolResultImage};
20use crate::traits::ToolContext;
21use crate::truncation_info::{TruncationInfo, TruncationReason};
22use async_trait::async_trait;
23use serde_json::{Value, json};
24use sha2::{Digest, Sha256};
25use similar::TextDiff;
26
27const IMAGE_EXTENSIONS: &[(&str, &str)] = &[
29 (".png", "image/png"),
30 (".jpg", "image/jpeg"),
31 (".jpeg", "image/jpeg"),
32 (".gif", "image/gif"),
33 (".webp", "image/webp"),
34];
35
36fn image_media_type(path: &str) -> Option<&'static str> {
38 let lower = path.to_lowercase();
39 IMAGE_EXTENSIONS
40 .iter()
41 .find(|(ext, _)| lower.ends_with(ext))
42 .map(|(_, mime)| *mime)
43}
44
45const WORKSPACE_PREFIX: &str = "/workspace";
47const MAX_EDIT_DIFF_CHARS: usize = 16_000;
48const LIST_DIRECTORY_DEFAULT_LIMIT: usize = 200;
49const LIST_DIRECTORY_MAX_LIMIT: usize = 1_000;
50const GREP_FILES_DEFAULT_LIMIT: usize = 200;
51const GREP_FILES_MAX_LIMIT: usize = 1_000;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59enum ContentType {
60 Text,
62 Log,
64 Csv,
66 Binary,
68 Minified,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74enum ReadMode {
75 FromOffset,
77 FromEnd,
79 MetadataOnly,
81}
82
83fn content_type_from_extension(path: &str) -> ContentType {
85 let lower = path.to_lowercase();
86
87 if lower.ends_with(".min.js") || lower.ends_with(".min.css") {
89 return ContentType::Minified;
90 }
91
92 if lower.ends_with(".log") || lower.ends_with(".out") {
94 return ContentType::Log;
95 }
96
97 if lower.ends_with(".csv") || lower.ends_with(".tsv") {
99 return ContentType::Csv;
100 }
101
102 const BINARY_EXTENSIONS: &[&str] = &[
104 ".wasm", ".zip", ".tar", ".gz", ".bz2", ".xz", ".zst", ".7z", ".rar", ".exe", ".dll",
105 ".so", ".dylib", ".bin", ".dat", ".o", ".a", ".pyc", ".class", ".woff", ".woff2", ".ttf",
106 ".otf", ".eot", ".ico", ".bmp", ".tiff", ".tif", ".psd", ".mp3", ".mp4", ".avi", ".mov",
107 ".flv", ".wmv", ".pdf",
108 ];
109 if BINARY_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)) {
110 return ContentType::Binary;
111 }
112
113 ContentType::Text
114}
115
116fn effective_read_defaults(
119 path: &str,
120 explicit_offset: bool,
121 explicit_limit: bool,
122) -> (usize, ReadMode) {
123 if explicit_limit && explicit_offset {
124 return (0, ReadMode::FromOffset); }
127 match content_type_from_extension(path) {
128 ContentType::Log if !explicit_offset => (500, ReadMode::FromEnd),
129 ContentType::Log => (500, ReadMode::FromOffset),
130 ContentType::Csv => (100, ReadMode::FromOffset),
131 ContentType::Binary => (0, ReadMode::MetadataOnly),
132 ContentType::Minified => (20, ReadMode::FromOffset), ContentType::Text => (
134 crate::tool_output_sanitizer::READ_FILE_DEFAULT_LIMIT,
135 ReadMode::FromOffset,
136 ),
137 }
138}
139
140fn normalize_path(path: &str) -> String {
149 if path == WORKSPACE_PREFIX {
150 "/".to_string()
151 } else if let Some(stripped) = path.strip_prefix(WORKSPACE_PREFIX) {
152 if stripped.starts_with('/') {
153 stripped.to_string()
154 } else {
155 path.to_string()
157 }
158 } else {
159 path.to_string()
161 }
162}
163
164fn add_workspace_prefix(path: &str) -> String {
166 if path == "/" {
167 WORKSPACE_PREFIX.to_string()
168 } else if path.starts_with('/') {
169 format!("{}{}", WORKSPACE_PREFIX, path)
170 } else {
171 format!("{}/{}", WORKSPACE_PREFIX, path)
172 }
173}
174
175fn file_content_hash(content: &str, encoding: &str) -> crate::error::Result<String> {
176 let bytes = SessionFile::decode_content(content, encoding)
177 .map_err(|error| anyhow::anyhow!("failed to decode file content for hashing: {error}"))?;
178 Ok(format!("sha256:{:x}", Sha256::digest(bytes)))
179}
180
181fn session_file_content_hash(file: &SessionFile) -> crate::error::Result<String> {
182 file_content_hash(file.content.as_deref().unwrap_or_default(), &file.encoding)
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186enum LineEnding {
187 Lf,
188 Cr,
189 Crlf,
190}
191
192fn strip_utf8_bom(content: &str) -> (bool, &str) {
193 if let Some(stripped) = content.strip_prefix('\u{feff}') {
194 (true, stripped)
195 } else {
196 (false, content)
197 }
198}
199
200fn detect_line_ending(content: &str) -> LineEnding {
201 if content.contains("\r\n") {
202 LineEnding::Crlf
203 } else if content.contains('\r') {
204 LineEnding::Cr
205 } else {
206 LineEnding::Lf
207 }
208}
209
210fn align_to_file_line_endings(content: &str, line_ending: LineEnding) -> String {
211 let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
212 match line_ending {
213 LineEnding::Lf => normalized,
214 LineEnding::Cr => normalized.replace('\n', "\r"),
215 LineEnding::Crlf => normalized.replace('\n', "\r\n"),
216 }
217}
218
219fn normalize_line_endings(content: &str) -> String {
220 content.replace("\r\n", "\n").replace('\r', "\n")
221}
222
223fn truncate_snippet(content: &str, max_chars: usize) -> String {
224 let clean = content.replace('\n', "\\n").replace('\r', "\\r");
225 if clean.chars().count() <= max_chars {
226 clean
227 } else {
228 let truncated: String = clean.chars().take(max_chars).collect();
229 format!("{truncated}...")
230 }
231}
232
233fn first_changed_line(before: &str, after: &str) -> Option<usize> {
234 if before == after {
235 return None;
236 }
237
238 let before = normalize_line_endings(before);
239 let after = normalize_line_endings(after);
240 let before_lines: Vec<&str> = before.split('\n').collect();
241 let after_lines: Vec<&str> = after.split('\n').collect();
242
243 for index in 0..before_lines.len().max(after_lines.len()) {
244 if before_lines.get(index) != after_lines.get(index) {
245 return Some(index + 1);
246 }
247 }
248
249 Some(1)
250}
251
252fn render_unified_diff(path: &str, before: &str, after: &str) -> String {
253 TextDiff::from_lines(
254 &normalize_line_endings(before),
255 &normalize_line_endings(after),
256 )
257 .unified_diff()
258 .context_radius(2)
259 .header(&format!("{path} (before)"), &format!("{path} (after)"))
260 .to_string()
261}
262
263fn truncate_diff(diff: String) -> (String, bool) {
264 if diff.chars().count() <= MAX_EDIT_DIFF_CHARS {
265 return (diff, false);
266 }
267
268 let truncated: String = diff.chars().take(MAX_EDIT_DIFF_CHARS).collect();
269 (
270 format!("{truncated}\n... diff truncated after {MAX_EDIT_DIFF_CHARS} characters ..."),
271 true,
272 )
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
276struct TextEdit {
277 old_text: String,
278 new_text: String,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
282struct PlannedEdit {
283 start: usize,
284 end: usize,
285 replacement: String,
286}
287
288fn parse_text_edits(arguments: &Value) -> std::result::Result<Vec<TextEdit>, String> {
289 let old_text_arg = arguments.get("old_text");
290 let new_text_arg = arguments.get("new_text");
291 let has_single = old_text_arg.is_some() || new_text_arg.is_some();
292 let has_batch = arguments.get("edits").is_some();
293
294 if has_single && has_batch {
295 let has_empty_single_placeholders = matches!(
296 (
297 old_text_arg.and_then(Value::as_str),
298 new_text_arg.and_then(Value::as_str)
299 ),
300 (Some(""), Some(""))
301 );
302 if !has_empty_single_placeholders {
303 return Err("Provide either old_text/new_text or edits, not both".to_string());
304 }
305 }
306
307 if has_single && !has_batch {
308 let old_text = arguments
309 .get("old_text")
310 .and_then(Value::as_str)
311 .ok_or_else(|| "Missing required parameter: old_text".to_string())?;
312 let new_text = arguments
313 .get("new_text")
314 .and_then(Value::as_str)
315 .ok_or_else(|| "Missing required parameter: new_text".to_string())?;
316 if old_text.is_empty() {
317 return Err("old_text cannot be empty".to_string());
318 }
319 return Ok(vec![TextEdit {
320 old_text: old_text.to_string(),
321 new_text: new_text.to_string(),
322 }]);
323 }
324
325 let edits = arguments
326 .get("edits")
327 .and_then(Value::as_array)
328 .ok_or_else(|| "Provide old_text/new_text or a non-empty edits array".to_string())?;
329
330 if edits.is_empty() {
331 return Err("edits must contain at least one replacement".to_string());
332 }
333
334 edits
335 .iter()
336 .enumerate()
337 .map(|(index, edit)| {
338 let old_text = edit
339 .get("old_text")
340 .and_then(Value::as_str)
341 .ok_or_else(|| format!("Edit {} is missing old_text", index + 1))?;
342 let new_text = edit
343 .get("new_text")
344 .and_then(Value::as_str)
345 .ok_or_else(|| format!("Edit {} is missing new_text", index + 1))?;
346 if old_text.is_empty() {
347 return Err(format!("Edit {} has an empty old_text", index + 1));
348 }
349 Ok(TextEdit {
350 old_text: old_text.to_string(),
351 new_text: new_text.to_string(),
352 })
353 })
354 .collect()
355}
356
357fn plan_text_edits(
358 content: &str,
359 edits: &[TextEdit],
360) -> std::result::Result<Vec<PlannedEdit>, String> {
361 let (_, body) = strip_utf8_bom(content);
362 let line_ending = detect_line_ending(body);
363 let mut planned = Vec::with_capacity(edits.len());
364
365 for edit in edits {
366 let old_text = align_to_file_line_endings(
367 edit.old_text
368 .strip_prefix('\u{feff}')
369 .unwrap_or(&edit.old_text),
370 line_ending,
371 );
372 let new_text = align_to_file_line_endings(
373 edit.new_text
374 .strip_prefix('\u{feff}')
375 .unwrap_or(&edit.new_text),
376 line_ending,
377 );
378
379 let mut matches = body.match_indices(&old_text);
380 let Some((start, _)) = matches.next() else {
381 return Err(format!(
382 "Could not find an exact match for old_text: '{}'",
383 truncate_snippet(&old_text, 80)
384 ));
385 };
386 if matches.next().is_some() {
387 return Err(format!(
388 "old_text is ambiguous and matched multiple locations: '{}'",
389 truncate_snippet(&old_text, 80)
390 ));
391 }
392
393 planned.push(PlannedEdit {
394 start,
395 end: start + old_text.len(),
396 replacement: new_text,
397 });
398 }
399
400 planned.sort_by_key(|edit| edit.start);
401 for pair in planned.windows(2) {
402 if pair[1].start < pair[0].end {
403 return Err("Edits overlap in the target file".to_string());
404 }
405 }
406
407 Ok(planned)
408}
409
410fn apply_text_edits(
411 content: &str,
412 edits: &[TextEdit],
413) -> std::result::Result<(String, usize), String> {
414 let (had_bom, body) = strip_utf8_bom(content);
415 let planned = plan_text_edits(content, edits)?;
416
417 let mut edited = String::with_capacity(content.len());
418 let mut cursor = 0;
419 for edit in &planned {
420 edited.push_str(&body[cursor..edit.start]);
421 edited.push_str(&edit.replacement);
422 cursor = edit.end;
423 }
424 edited.push_str(&body[cursor..]);
425
426 if had_bom {
427 edited.insert(0, '\u{feff}');
428 }
429
430 Ok((edited, planned.len()))
431}
432
433pub struct FileSystemCapability;
435
436impl Capability for FileSystemCapability {
437 fn id(&self) -> &str {
438 "session_file_system"
439 }
440
441 fn name(&self) -> &str {
442 "File System"
443 }
444
445 fn description(&self) -> &str {
446 r#"Tools to access and manipulate files in the session workspace - read, write, list, grep, and more.
447
448> [!NOTE]
449> Each session has its own isolated workspace at `/workspace`. Files persist for the session duration.
450
451> [!TIP]
452> Use `list_directory` to explore the workspace structure before reading or writing files."#
453 }
454
455 fn status(&self) -> CapabilityStatus {
456 CapabilityStatus::Available
457 }
458
459 fn icon(&self) -> Option<&str> {
460 Some("hard-drive")
461 }
462
463 fn category(&self) -> Option<&str> {
464 Some("File Operations")
465 }
466
467 fn system_prompt_addition(&self) -> Option<&str> {
468 use crate::tool_output_sanitizer::READ_ECONOMY_HINT;
469 const BASE: &str = concat!(
474 "Workspace root: `/workspace`. All file paths must start with `/workspace`. ",
475 "Directories are created on write. ",
476 "Read files before claiming what they contain — never speculate about code you have not opened.",
477 );
478 static PROMPT: std::sync::LazyLock<String> =
479 std::sync::LazyLock::new(|| format!("{}{}", BASE, READ_ECONOMY_HINT));
480 Some(PROMPT.as_str())
481 }
482
483 fn tools(&self) -> Vec<Box<dyn Tool>> {
484 vec![
485 Box::new(ReadFileTool),
486 Box::new(WriteFileTool),
487 Box::new(EditFileTool),
488 Box::new(ListDirectoryTool),
489 Box::new(GrepFilesTool),
490 Box::new(DeleteFileTool),
491 Box::new(StatFileTool),
492 ]
493 }
494
495 fn features(&self) -> Vec<&'static str> {
496 vec!["file_system"]
497 }
498}
499
500pub struct ReadFileTool;
506
507#[async_trait]
508impl Tool for ReadFileTool {
509 fn name(&self) -> &str {
510 "read_file"
511 }
512
513 fn display_name(&self) -> Option<&str> {
514 Some("Read File")
515 }
516
517 fn description(&self) -> &str {
518 "Read a file from the session workspace (/workspace). Returns text content directly. For image files (PNG, JPEG, GIF, WebP), the image is returned as a native image so you can see it visually. This is NOT for reading files in cloud sandboxes — use the sandbox-specific read tool instead."
519 }
520
521 fn parameters_schema(&self) -> Value {
522 json!({
523 "type": "object",
524 "properties": {
525 "path": {
526 "type": "string",
527 "description": "Absolute path to the file (e.g., '/workspace/docs/readme.txt')"
528 },
529 "offset": {
530 "type": "integer",
531 "description": "Starting line number (0-indexed). Default: 0",
532 "default": 0,
533 "minimum": 0
534 },
535 "limit": {
536 "type": "integer",
537 "description": "Max lines to return. Default varies by file type: 2000 (source/text), 500 (logs, tail-biased), 100 (CSV/TSV with header). Explicit value always wins.",
538 "default": 2000,
539 "minimum": 1
540 }
541 },
542 "required": ["path"],
543 "additionalProperties": false
544 })
545 }
546
547 fn hints(&self) -> ToolHints {
548 ToolHints::default()
549 .with_readonly(true)
550 .with_idempotent(true)
551 }
552
553 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
554 ToolExecutionResult::tool_error(
555 "read_file requires context. This tool must be executed with session context.",
556 )
557 }
558
559 async fn execute_with_context(
560 &self,
561 arguments: Value,
562 context: &ToolContext,
563 ) -> ToolExecutionResult {
564 use crate::tool_output_sanitizer::{
565 READ_FILE_DEFAULT_LIMIT, apply_read_file_hard_cap, format_lines,
566 };
567
568 let path = match arguments.get("path").and_then(|v| v.as_str()) {
569 Some(p) => p,
570 None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
571 };
572
573 let explicit_offset = arguments.get("offset").and_then(|v| v.as_u64()).is_some();
574 let explicit_limit = arguments.get("limit").and_then(|v| v.as_u64()).is_some();
575
576 let mut offset = arguments
577 .get("offset")
578 .and_then(|v| v.as_u64())
579 .unwrap_or(0) as usize;
580 let mut limit = arguments
581 .get("limit")
582 .and_then(|v| v.as_u64())
583 .unwrap_or(READ_FILE_DEFAULT_LIMIT as u64) as usize;
584
585 let file_store = match &context.file_store {
586 Some(store) => store,
587 None => {
588 return ToolExecutionResult::tool_error(
589 "File system not available in this context",
590 );
591 }
592 };
593
594 let normalized_path = normalize_path(path);
596 let display_path = add_workspace_prefix(&normalized_path);
597
598 match file_store
599 .read_file(context.session_id, &normalized_path)
600 .await
601 {
602 Ok(Some(file)) => {
603 if file.is_directory {
604 return ToolExecutionResult::tool_error(format!(
605 "Path '{}' is a directory, not a file. Use list_directory instead.",
606 display_path
607 ));
608 }
609
610 if let Some(media_type) = image_media_type(&normalized_path) {
612 if file.encoding == "base64"
614 && let Some(ref content) = file.content
615 {
616 let content_hash = match file_content_hash(content, &file.encoding) {
617 Ok(hash) => hash,
618 Err(e) => return ToolExecutionResult::internal_error(e),
619 };
620 return ToolExecutionResult::success_with_images(
621 json!({
622 "path": display_path,
623 "media_type": media_type,
624 "size_bytes": file.size_bytes,
625 "content_hash": content_hash
626 }),
627 vec![ToolResultImage {
628 base64: content.clone(),
629 media_type: media_type.to_string(),
630 }],
631 );
632 }
633 }
635
636 let content_hash = match session_file_content_hash(&file) {
637 Ok(hash) => hash,
638 Err(e) => return ToolExecutionResult::internal_error(e),
639 };
640
641 if file.encoding == "base64" {
644 let mut result = build_binary_read_file_result(
645 &display_path,
646 file.size_bytes as usize,
647 "base64",
648 );
649 result["content_hash"] = json!(content_hash);
650 return ToolExecutionResult::success(result);
651 }
652
653 let raw_content = file.content.as_deref().unwrap_or("");
654
655 let (ct_limit, read_mode) =
657 effective_read_defaults(&normalized_path, explicit_offset, explicit_limit);
658 let content_type = content_type_from_extension(&normalized_path);
659
660 if read_mode == ReadMode::MetadataOnly {
662 let mut result = build_binary_read_file_result(
663 &display_path,
664 file.size_bytes as usize,
665 "binary",
666 );
667 result["content_hash"] = json!(content_hash);
668 return ToolExecutionResult::success(result);
669 }
670
671 if !explicit_limit {
673 limit = ct_limit;
674 }
675
676 if read_mode == ReadMode::FromEnd && !explicit_offset {
678 let total = raw_content.lines().count();
679 offset = total.saturating_sub(limit);
680 }
681
682 let (formatted, total_lines, truncated) = format_lines(raw_content, offset, limit);
683
684 let formatted = if content_type == ContentType::Csv && offset > 0 {
686 if let Some(header) = raw_content.lines().next() {
687 format!("1|{header}\n{formatted}")
688 } else {
689 formatted
690 }
691 } else {
692 formatted
693 };
694
695 let shown_count = total_lines.saturating_sub(offset).min(limit);
696 let (start_line, end_line) = if shown_count == 0 {
697 (0, 0)
698 } else {
699 (offset + 1, offset + shown_count)
700 };
701
702 let mut formatted = if truncated && start_line > 0 {
704 let outline_items =
705 crate::outline::generate_outline(raw_content, &normalized_path);
706 if let Some(outline_text) = crate::outline::format_outline(
707 &outline_items,
708 start_line,
709 end_line,
710 total_lines,
711 ) {
712 format!("{formatted}{outline_text}")
713 } else {
714 formatted
715 }
716 } else {
717 formatted
718 };
719 let hard_capped = apply_read_file_hard_cap(&mut formatted);
721 let truncated = truncated || hard_capped;
722
723 let mut result = json!({
724 "path": display_path,
725 "content": formatted,
726 "total_lines": total_lines,
727 "lines_shown": {
728 "start": start_line,
729 "end": end_line
730 },
731 "truncated": truncated,
732 "size_bytes": file.size_bytes,
733 "content_hash": content_hash
734 });
735
736 if content_type != ContentType::Text {
738 let ct_label = match content_type {
739 ContentType::Log => "log",
740 ContentType::Csv => "csv",
741 ContentType::Minified => "minified",
742 _ => "text",
743 };
744 if let Some(obj) = result.as_object_mut() {
745 obj.insert("content_type".to_string(), json!(ct_label));
746 if read_mode == ReadMode::FromEnd {
747 obj.insert("read_mode".to_string(), json!("tail"));
748 }
749 }
750 }
751
752 let truncation = if truncated {
765 if end_line < total_lines {
766 TruncationInfo::with_resume(
767 formatted.len(),
768 Some(file.size_bytes as usize),
769 end_line as u64,
770 format!(
771 "call read_file with offset={} to resume from line {}",
772 end_line,
773 end_line + 1,
774 ),
775 TruncationReason::LineCap,
776 )
777 } else {
778 TruncationInfo::without_resume(
779 formatted.len(),
780 Some(file.size_bytes as usize),
781 TruncationReason::SizeCap,
782 )
783 }
784 } else {
785 TruncationInfo::not_truncated(formatted.len())
786 };
787 truncation.attach(&mut result);
788
789 ToolExecutionResult::success(result)
790 }
791 Ok(None) => {
792 ToolExecutionResult::tool_error(format!("File not found: {}", display_path))
793 }
794 Err(e) => ToolExecutionResult::internal_error(e),
795 }
796 }
797
798 fn requires_context(&self) -> bool {
799 true
800 }
801}
802
803pub struct WriteFileTool;
809
810#[async_trait]
811impl Tool for WriteFileTool {
812 fn name(&self) -> &str {
813 "write_file"
814 }
815
816 fn display_name(&self) -> Option<&str> {
817 Some("Write File")
818 }
819
820 fn description(&self) -> &str {
821 "Create or update a file in the session workspace (/workspace). Parent directories are created automatically. This is NOT for writing files in cloud sandboxes — use sandbox-specific write tools (e.g. daytona_write_file, e2b_write_file) instead."
822 }
823
824 fn parameters_schema(&self) -> Value {
825 json!({
826 "type": "object",
827 "properties": {
828 "path": {
829 "type": "string",
830 "description": "Absolute path for the file (e.g., '/workspace/docs/notes.txt')"
831 },
832 "content": {
833 "type": "string",
834 "description": "Content to write to the file"
835 },
836 "encoding": {
837 "type": "string",
838 "enum": ["text", "base64"],
839 "default": "text",
840 "description": "Content encoding: 'text' for plain text, 'base64' for binary data"
841 }
842 },
843 "required": ["path", "content"],
844 "additionalProperties": false
845 })
846 }
847
848 fn hints(&self) -> ToolHints {
849 ToolHints::default()
852 .with_idempotent(true)
853 .with_concurrency_class("session_workspace")
854 }
855
856 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
857 ToolExecutionResult::tool_error(
858 "write_file requires context. This tool must be executed with session context.",
859 )
860 }
861
862 async fn execute_with_context(
863 &self,
864 arguments: Value,
865 context: &ToolContext,
866 ) -> ToolExecutionResult {
867 let path = match arguments.get("path").and_then(|v| v.as_str()) {
868 Some(p) => p,
869 None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
870 };
871
872 let content = match arguments.get("content").and_then(|v| v.as_str()) {
873 Some(c) => c,
874 None => return ToolExecutionResult::tool_error("Missing required parameter: content"),
875 };
876
877 let encoding = arguments
878 .get("encoding")
879 .and_then(|v| v.as_str())
880 .unwrap_or("text");
881
882 let file_store = match &context.file_store {
883 Some(store) => store,
884 None => {
885 return ToolExecutionResult::tool_error(
886 "File system not available in this context",
887 );
888 }
889 };
890
891 let normalized_path = normalize_path(path);
893 let display_path = add_workspace_prefix(&normalized_path);
894
895 match file_store
896 .write_file(context.session_id, &normalized_path, content, encoding)
897 .await
898 {
899 Ok(file) => {
900 let content_hash = match session_file_content_hash(&file) {
901 Ok(hash) => hash,
902 Err(e) => return ToolExecutionResult::internal_error(e),
903 };
904 ToolExecutionResult::success(json!({
905 "path": display_path,
906 "size_bytes": file.size_bytes,
907 "created": true,
908 "content_hash": content_hash
909 }))
910 }
911 Err(e) => {
912 let msg = e.to_string();
914 if msg.contains("readonly") || msg.contains("is a directory") {
915 ToolExecutionResult::tool_error(msg)
916 } else {
917 ToolExecutionResult::internal_error(e)
918 }
919 }
920 }
921 }
922
923 fn requires_context(&self) -> bool {
924 true
925 }
926}
927
928pub struct EditFileTool;
934
935#[async_trait]
936impl Tool for EditFileTool {
937 fn name(&self) -> &str {
938 "edit_file"
939 }
940
941 fn display_name(&self) -> Option<&str> {
942 Some("Edit File")
943 }
944
945 fn description(&self) -> &str {
946 "Apply one or more exact text replacements to an existing text file. Requires the current content hash from read_file or write_file."
947 }
948
949 fn parameters_schema(&self) -> Value {
950 json!({
951 "type": "object",
952 "properties": {
953 "path": {
954 "type": "string",
955 "description": "Absolute path to the existing text file (e.g., '/workspace/src/main.rs')"
956 },
957 "expected_hash": {
958 "type": "string",
959 "description": "Current content hash from read_file or write_file (format: 'sha256:...')"
960 },
961 "old_text": {
962 "type": "string",
963 "description": "Exact text to replace. Use for single-edit shorthand."
964 },
965 "new_text": {
966 "type": "string",
967 "description": "Replacement text. Use for single-edit shorthand."
968 },
969 "edits": {
970 "type": "array",
971 "description": "Batch multiple replacements in a single file. Each edit matches against the original file content.",
972 "items": {
973 "type": "object",
974 "properties": {
975 "old_text": {
976 "type": "string",
977 "description": "Exact text to replace"
978 },
979 "new_text": {
980 "type": "string",
981 "description": "Replacement text"
982 }
983 },
984 "required": ["old_text", "new_text"],
985 "additionalProperties": false
986 },
987 "minItems": 1
988 }
989 },
990 "required": ["path", "expected_hash"],
991 "additionalProperties": false
992 })
993 }
994
995 fn hints(&self) -> ToolHints {
996 ToolHints::default().with_concurrency_class("session_workspace")
999 }
1000
1001 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1002 ToolExecutionResult::tool_error(
1003 "edit_file requires context. This tool must be executed with session context.",
1004 )
1005 }
1006
1007 async fn execute_with_context(
1008 &self,
1009 arguments: Value,
1010 context: &ToolContext,
1011 ) -> ToolExecutionResult {
1012 let path = match arguments.get("path").and_then(|v| v.as_str()) {
1013 Some(path) => path,
1014 None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
1015 };
1016 let expected_hash = match arguments.get("expected_hash").and_then(|v| v.as_str()) {
1017 Some(hash) => hash,
1018 None => {
1019 return ToolExecutionResult::tool_error(
1020 "Missing required parameter: expected_hash",
1021 );
1022 }
1023 };
1024 let edits = match parse_text_edits(&arguments) {
1025 Ok(edits) => edits,
1026 Err(error) => return ToolExecutionResult::tool_error(error),
1027 };
1028
1029 let file_store = match &context.file_store {
1030 Some(store) => store,
1031 None => {
1032 return ToolExecutionResult::tool_error(
1033 "File system not available in this context",
1034 );
1035 }
1036 };
1037
1038 let normalized_path = normalize_path(path);
1039 let display_path = add_workspace_prefix(&normalized_path);
1040
1041 let existing = match file_store
1042 .read_file(context.session_id, &normalized_path)
1043 .await
1044 {
1045 Ok(Some(file)) => file,
1046 Ok(None) => {
1047 return ToolExecutionResult::tool_error(format!(
1048 "File not found: {}",
1049 display_path
1050 ));
1051 }
1052 Err(e) => return ToolExecutionResult::internal_error(e),
1053 };
1054
1055 if existing.is_directory {
1056 return ToolExecutionResult::tool_error(format!(
1057 "Path '{}' is a directory, not a file. Use list_directory instead.",
1058 display_path
1059 ));
1060 }
1061
1062 if existing.encoding != "text" {
1063 return ToolExecutionResult::tool_error(format!(
1064 "File '{}' is not a text file. edit_file only supports text files; use write_file for binary/base64 content.",
1065 display_path
1066 ));
1067 }
1068
1069 let current_hash = match session_file_content_hash(&existing) {
1070 Ok(hash) => hash,
1071 Err(e) => return ToolExecutionResult::internal_error(e),
1072 };
1073 if expected_hash != current_hash {
1074 return ToolExecutionResult::tool_error(format!(
1075 "File '{}' changed since the last read. Expected {}, found {}. Read the file again before editing.",
1076 display_path, expected_hash, current_hash
1077 ));
1078 }
1079
1080 let current_content = existing.content.unwrap_or_default();
1081 let (updated_content, applied_edits) = match apply_text_edits(¤t_content, &edits) {
1082 Ok(result) => result,
1083 Err(error) => return ToolExecutionResult::tool_error(error),
1084 };
1085
1086 let first_changed_line = first_changed_line(¤t_content, &updated_content);
1087 let (diff, diff_truncated) = truncate_diff(render_unified_diff(
1088 &display_path,
1089 ¤t_content,
1090 &updated_content,
1091 ));
1092
1093 match file_store
1094 .write_file_if_content_matches(
1095 context.session_id,
1096 &normalized_path,
1097 ¤t_content,
1098 "text",
1099 &updated_content,
1100 "text",
1101 )
1102 .await
1103 {
1104 Ok(updated_file) => {
1105 let Some(updated_file) = updated_file else {
1106 let latest = match file_store
1107 .read_file(context.session_id, &normalized_path)
1108 .await
1109 {
1110 Ok(file) => file,
1111 Err(e) => return ToolExecutionResult::internal_error(e),
1112 };
1113
1114 return match latest {
1115 Some(file) if file.is_directory => {
1116 ToolExecutionResult::tool_error(format!(
1117 "Path '{}' is a directory, not a file. Use list_directory instead.",
1118 display_path
1119 ))
1120 }
1121 Some(file) if file.is_readonly => ToolExecutionResult::tool_error(format!(
1122 "Cannot modify readonly file: {}",
1123 display_path
1124 )),
1125 Some(file) if file.encoding != "text" => {
1126 ToolExecutionResult::tool_error(format!(
1127 "File '{}' is not a text file. edit_file only supports text files; use write_file for binary/base64 content.",
1128 display_path
1129 ))
1130 }
1131 Some(file) => {
1132 let latest_hash = match session_file_content_hash(&file) {
1133 Ok(hash) => hash,
1134 Err(e) => return ToolExecutionResult::internal_error(e),
1135 };
1136 ToolExecutionResult::tool_error(format!(
1137 "File '{}' changed since the last read. Expected {}, found {}. Read the file again before editing.",
1138 display_path, expected_hash, latest_hash
1139 ))
1140 }
1141 None => ToolExecutionResult::tool_error(format!(
1142 "File not found: {}",
1143 display_path
1144 )),
1145 };
1146 };
1147
1148 let new_hash = match session_file_content_hash(&updated_file) {
1149 Ok(hash) => hash,
1150 Err(e) => return ToolExecutionResult::internal_error(e),
1151 };
1152 ToolExecutionResult::success(json!({
1153 "path": display_path,
1154 "size_bytes": updated_file.size_bytes,
1155 "content_hash": new_hash,
1156 "previous_content_hash": current_hash,
1157 "applied_edits": applied_edits,
1158 "first_changed_line": first_changed_line,
1159 "diff": diff,
1160 "diff_truncated": diff_truncated
1161 }))
1162 }
1163 Err(e) => {
1164 let msg = e.to_string();
1165 if msg.contains("readonly") || msg.contains("is a directory") {
1166 ToolExecutionResult::tool_error(msg)
1167 } else {
1168 ToolExecutionResult::internal_error(e)
1169 }
1170 }
1171 }
1172 }
1173
1174 fn requires_context(&self) -> bool {
1175 true
1176 }
1177}
1178
1179pub struct ListDirectoryTool;
1185
1186#[async_trait]
1187impl Tool for ListDirectoryTool {
1188 fn name(&self) -> &str {
1189 "list_directory"
1190 }
1191
1192 fn display_name(&self) -> Option<&str> {
1193 Some("List Directory")
1194 }
1195
1196 fn description(&self) -> &str {
1197 "List files and directories at a given path. Returns file metadata including size and type."
1198 }
1199
1200 fn parameters_schema(&self) -> Value {
1201 json!({
1202 "type": "object",
1203 "properties": {
1204 "path": {
1205 "type": "string",
1206 "default": "/workspace",
1207 "description": "Directory path to list (default: '/workspace')"
1208 },
1209 "offset": {
1210 "type": "integer",
1211 "description": "Starting item offset for large directories. Default: 0",
1212 "default": 0,
1213 "minimum": 0
1214 },
1215 "limit": {
1216 "type": "integer",
1217 "description": "Max directory entries to return. Default: 200, maximum: 1000",
1218 "default": LIST_DIRECTORY_DEFAULT_LIMIT,
1219 "minimum": 1,
1220 "maximum": LIST_DIRECTORY_MAX_LIMIT
1221 }
1222 },
1223 "additionalProperties": false
1224 })
1225 }
1226
1227 fn hints(&self) -> ToolHints {
1228 ToolHints::default()
1229 .with_readonly(true)
1230 .with_idempotent(true)
1231 }
1232
1233 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1234 ToolExecutionResult::tool_error(
1235 "list_directory requires context. This tool must be executed with session context.",
1236 )
1237 }
1238
1239 async fn execute_with_context(
1240 &self,
1241 arguments: Value,
1242 context: &ToolContext,
1243 ) -> ToolExecutionResult {
1244 let path = arguments
1245 .get("path")
1246 .and_then(|v| v.as_str())
1247 .unwrap_or("/workspace");
1248 let offset = arguments
1249 .get("offset")
1250 .and_then(|v| v.as_u64())
1251 .unwrap_or(0) as usize;
1252 let limit = match arguments.get("limit").and_then(|v| v.as_u64()) {
1253 Some(0) => return ToolExecutionResult::tool_error("limit must be greater than 0"),
1254 Some(value) => (value as usize).min(LIST_DIRECTORY_MAX_LIMIT),
1255 None => LIST_DIRECTORY_DEFAULT_LIMIT,
1256 };
1257
1258 let file_store = match &context.file_store {
1259 Some(store) => store,
1260 None => {
1261 return ToolExecutionResult::tool_error(
1262 "File system not available in this context",
1263 );
1264 }
1265 };
1266
1267 let normalized_path = normalize_path(path);
1269 let display_path = add_workspace_prefix(&normalized_path);
1270
1271 match file_store
1272 .list_directory(context.session_id, &normalized_path)
1273 .await
1274 {
1275 Ok(files) => {
1276 let total_count = files.len();
1277 let entries: Vec<Value> = files
1278 .iter()
1279 .skip(offset)
1280 .take(limit)
1281 .map(|f| {
1282 json!({
1283 "name": f.name,
1284 "path": add_workspace_prefix(&f.path),
1285 "is_directory": f.is_directory,
1286 "size_bytes": f.size_bytes,
1287 "is_readonly": f.is_readonly
1288 })
1289 })
1290 .collect();
1291
1292 let mut result = json!({
1293 "path": display_path,
1294 "entries": entries,
1295 "count": entries.len(),
1296 "total_count": total_count,
1297 "offset": offset,
1298 "limit": limit
1299 });
1300 let bytes_returned = serde_json::to_string(&entries)
1301 .expect("list_directory entries always serialize")
1302 .len();
1303 let next_offset = offset.saturating_add(entries.len());
1304 let truncation = if next_offset < total_count {
1305 TruncationInfo::with_resume(
1306 bytes_returned,
1307 None,
1308 next_offset as u64,
1309 format!(
1310 "call list_directory with offset={} to resume from item {}",
1311 next_offset,
1312 next_offset + 1
1313 ),
1314 TruncationReason::ItemCap,
1315 )
1316 } else {
1317 TruncationInfo::not_truncated(bytes_returned)
1318 };
1319 truncation.attach(&mut result);
1320 ToolExecutionResult::success(result)
1321 }
1322 Err(e) => {
1323 let msg = e.to_string();
1324 if msg.contains("not found") || msg.contains("not a directory") {
1325 ToolExecutionResult::tool_error(msg)
1326 } else {
1327 ToolExecutionResult::internal_error(e)
1328 }
1329 }
1330 }
1331 }
1332
1333 fn requires_context(&self) -> bool {
1334 true
1335 }
1336}
1337
1338pub struct GrepFilesTool;
1344
1345#[async_trait]
1346impl Tool for GrepFilesTool {
1347 fn name(&self) -> &str {
1348 "grep_files"
1349 }
1350
1351 fn display_name(&self) -> Option<&str> {
1352 Some("Grep Files")
1353 }
1354
1355 fn description(&self) -> &str {
1356 "Search file contents using a regex pattern. Returns matching lines with file paths and line numbers."
1357 }
1358
1359 fn parameters_schema(&self) -> Value {
1360 json!({
1361 "type": "object",
1362 "properties": {
1363 "pattern": {
1364 "type": "string",
1365 "description": "Regex pattern to search for"
1366 },
1367 "path_pattern": {
1368 "type": "string",
1369 "description": "Optional path pattern to filter files (e.g., '*.txt', '/workspace/docs/*')"
1370 },
1371 "offset": {
1372 "type": "integer",
1373 "description": "Starting match offset. Default: 0",
1374 "default": 0,
1375 "minimum": 0
1376 },
1377 "limit": {
1378 "type": "integer",
1379 "description": "Max matches to return. Default: 200, maximum: 1000",
1380 "default": GREP_FILES_DEFAULT_LIMIT,
1381 "minimum": 1,
1382 "maximum": GREP_FILES_MAX_LIMIT
1383 }
1384 },
1385 "required": ["pattern"],
1386 "additionalProperties": false
1387 })
1388 }
1389
1390 fn hints(&self) -> ToolHints {
1391 ToolHints::default()
1392 .with_readonly(true)
1393 .with_idempotent(true)
1394 }
1395
1396 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1397 ToolExecutionResult::tool_error(
1398 "grep_files requires context. This tool must be executed with session context.",
1399 )
1400 }
1401
1402 async fn execute_with_context(
1403 &self,
1404 arguments: Value,
1405 context: &ToolContext,
1406 ) -> ToolExecutionResult {
1407 let pattern = match arguments.get("pattern").and_then(|v| v.as_str()) {
1408 Some(p) => p,
1409 None => return ToolExecutionResult::tool_error("Missing required parameter: pattern"),
1410 };
1411
1412 let path_pattern = arguments.get("path_pattern").and_then(|v| v.as_str());
1413 let offset = arguments
1414 .get("offset")
1415 .and_then(|v| v.as_u64())
1416 .unwrap_or(0) as usize;
1417 let limit = match arguments.get("limit").and_then(|v| v.as_u64()) {
1418 Some(0) => return ToolExecutionResult::tool_error("limit must be greater than 0"),
1419 Some(value) => (value as usize).min(GREP_FILES_MAX_LIMIT),
1420 None => GREP_FILES_DEFAULT_LIMIT,
1421 };
1422
1423 let file_store = match &context.file_store {
1424 Some(store) => store,
1425 None => {
1426 return ToolExecutionResult::tool_error(
1427 "File system not available in this context",
1428 );
1429 }
1430 };
1431
1432 match file_store
1433 .grep_files(context.session_id, pattern, path_pattern)
1434 .await
1435 {
1436 Ok(matches) => {
1437 let total_matches = matches.len();
1438 let results: Vec<Value> = matches
1439 .iter()
1440 .skip(offset)
1441 .take(limit)
1442 .map(|m| {
1443 json!({
1444 "path": add_workspace_prefix(&m.path),
1445 "line_number": m.line_number,
1446 "line": m.line
1447 })
1448 })
1449 .collect();
1450
1451 let mut result = json!({
1452 "pattern": pattern,
1453 "matches": results,
1454 "match_count": results.len(),
1455 "total_matches": total_matches,
1456 "offset": offset,
1457 "limit": limit
1458 });
1459 let bytes_returned = serde_json::to_string(&results)
1460 .expect("grep_files matches always serialize")
1461 .len();
1462 let next_offset = offset.saturating_add(results.len());
1463 let truncation = if next_offset < total_matches {
1464 TruncationInfo::with_resume(
1465 bytes_returned,
1466 None,
1467 next_offset as u64,
1468 format!(
1469 "call grep_files with offset={} to resume from match {}",
1470 next_offset,
1471 next_offset + 1
1472 ),
1473 TruncationReason::LineCap,
1474 )
1475 } else {
1476 TruncationInfo::not_truncated(bytes_returned)
1477 };
1478 truncation.attach(&mut result);
1479 ToolExecutionResult::success(result)
1480 }
1481 Err(e) => {
1482 let msg = e.to_string();
1483 if msg.contains("regex") || msg.contains("pattern") {
1484 ToolExecutionResult::tool_error(format!("Invalid regex pattern: {}", msg))
1485 } else {
1486 ToolExecutionResult::internal_error(e)
1487 }
1488 }
1489 }
1490 }
1491
1492 fn requires_context(&self) -> bool {
1493 true
1494 }
1495}
1496
1497pub struct DeleteFileTool;
1503
1504#[async_trait]
1505impl Tool for DeleteFileTool {
1506 fn name(&self) -> &str {
1507 "delete_file"
1508 }
1509
1510 fn display_name(&self) -> Option<&str> {
1511 Some("Delete File")
1512 }
1513
1514 fn description(&self) -> &str {
1515 "Delete a file or directory. Use recursive=true to delete non-empty directories."
1516 }
1517
1518 fn parameters_schema(&self) -> Value {
1519 json!({
1520 "type": "object",
1521 "properties": {
1522 "path": {
1523 "type": "string",
1524 "description": "Path to the file or directory to delete"
1525 },
1526 "recursive": {
1527 "type": "boolean",
1528 "default": false,
1529 "description": "If true, delete directories and all contents recursively"
1530 }
1531 },
1532 "required": ["path"],
1533 "additionalProperties": false
1534 })
1535 }
1536
1537 fn hints(&self) -> ToolHints {
1538 ToolHints::default()
1541 .with_destructive(true)
1542 .with_idempotent(true)
1543 .with_concurrency_class("session_workspace")
1544 }
1545
1546 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1547 ToolExecutionResult::tool_error(
1548 "delete_file requires context. This tool must be executed with session context.",
1549 )
1550 }
1551
1552 async fn execute_with_context(
1553 &self,
1554 arguments: Value,
1555 context: &ToolContext,
1556 ) -> ToolExecutionResult {
1557 let path = match arguments.get("path").and_then(|v| v.as_str()) {
1558 Some(p) => p,
1559 None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
1560 };
1561
1562 let recursive = arguments
1563 .get("recursive")
1564 .and_then(|v| v.as_bool())
1565 .unwrap_or(false);
1566
1567 let file_store = match &context.file_store {
1568 Some(store) => store,
1569 None => {
1570 return ToolExecutionResult::tool_error(
1571 "File system not available in this context",
1572 );
1573 }
1574 };
1575
1576 let normalized_path = normalize_path(path);
1578 let display_path = add_workspace_prefix(&normalized_path);
1579
1580 match file_store
1581 .delete_file(context.session_id, &normalized_path, recursive)
1582 .await
1583 {
1584 Ok(deleted) => {
1585 if deleted {
1586 ToolExecutionResult::success(json!({
1587 "path": display_path,
1588 "deleted": true
1589 }))
1590 } else {
1591 ToolExecutionResult::tool_error(format!("File not found: {}", display_path))
1592 }
1593 }
1594 Err(e) => {
1595 let msg = e.to_string();
1596 if msg.contains("not empty") || msg.contains("recursive") {
1597 ToolExecutionResult::tool_error(msg)
1598 } else {
1599 ToolExecutionResult::internal_error(e)
1600 }
1601 }
1602 }
1603 }
1604
1605 fn requires_context(&self) -> bool {
1606 true
1607 }
1608}
1609
1610pub struct StatFileTool;
1616
1617#[async_trait]
1618impl Tool for StatFileTool {
1619 fn name(&self) -> &str {
1620 "stat_file"
1621 }
1622
1623 fn display_name(&self) -> Option<&str> {
1624 Some("File Info")
1625 }
1626
1627 fn description(&self) -> &str {
1628 "Get metadata about a file or directory (exists, size, type, dates)."
1629 }
1630
1631 fn parameters_schema(&self) -> Value {
1632 json!({
1633 "type": "object",
1634 "properties": {
1635 "path": {
1636 "type": "string",
1637 "description": "Path to the file or directory"
1638 }
1639 },
1640 "required": ["path"],
1641 "additionalProperties": false
1642 })
1643 }
1644
1645 fn hints(&self) -> ToolHints {
1646 ToolHints::default()
1647 .with_readonly(true)
1648 .with_idempotent(true)
1649 }
1650
1651 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1652 ToolExecutionResult::tool_error(
1653 "stat_file requires context. This tool must be executed with session context.",
1654 )
1655 }
1656
1657 async fn execute_with_context(
1658 &self,
1659 arguments: Value,
1660 context: &ToolContext,
1661 ) -> ToolExecutionResult {
1662 let path = match arguments.get("path").and_then(|v| v.as_str()) {
1663 Some(p) => p,
1664 None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
1665 };
1666
1667 let file_store = match &context.file_store {
1668 Some(store) => store,
1669 None => {
1670 return ToolExecutionResult::tool_error(
1671 "File system not available in this context",
1672 );
1673 }
1674 };
1675
1676 let normalized_path = normalize_path(path);
1678 let display_path = add_workspace_prefix(&normalized_path);
1679
1680 match file_store
1681 .stat_file(context.session_id, &normalized_path)
1682 .await
1683 {
1684 Ok(Some(stat)) => ToolExecutionResult::success(json!({
1685 "path": add_workspace_prefix(&stat.path),
1686 "name": stat.name,
1687 "exists": true,
1688 "is_directory": stat.is_directory,
1689 "is_readonly": stat.is_readonly,
1690 "size_bytes": stat.size_bytes,
1691 "created_at": stat.created_at.to_rfc3339(),
1692 "updated_at": stat.updated_at.to_rfc3339()
1693 })),
1694 Ok(None) => ToolExecutionResult::success(json!({
1695 "path": display_path,
1696 "exists": false
1697 })),
1698 Err(e) => ToolExecutionResult::internal_error(e),
1699 }
1700 }
1701
1702 fn requires_context(&self) -> bool {
1703 true
1704 }
1705}
1706
1707#[cfg(test)]
1708mod tests {
1709 use super::*;
1710 use crate::error::Result;
1711 use crate::session_file::{FileInfo, FileStat, GrepMatch, SessionFile};
1712 use crate::traits::SessionFileSystem;
1713 use crate::typed_id::SessionId;
1714 use chrono::Utc;
1715 use std::collections::HashMap;
1716 use std::sync::{Arc, Mutex};
1717 use uuid::Uuid;
1718
1719 #[derive(Debug, Clone)]
1720 struct StoredFile {
1721 content: Option<String>,
1722 encoding: String,
1723 is_directory: bool,
1724 is_readonly: bool,
1725 created_at: chrono::DateTime<Utc>,
1726 updated_at: chrono::DateTime<Utc>,
1727 }
1728
1729 impl StoredFile {
1730 fn text(content: &str) -> Self {
1731 let now = Utc::now();
1732 Self {
1733 content: Some(content.to_string()),
1734 encoding: "text".to_string(),
1735 is_directory: false,
1736 is_readonly: false,
1737 created_at: now,
1738 updated_at: now,
1739 }
1740 }
1741
1742 fn base64(content: &str) -> Self {
1743 let now = Utc::now();
1744 Self {
1745 content: Some(content.to_string()),
1746 encoding: "base64".to_string(),
1747 is_directory: false,
1748 is_readonly: false,
1749 created_at: now,
1750 updated_at: now,
1751 }
1752 }
1753
1754 fn directory() -> Self {
1755 let now = Utc::now();
1756 Self {
1757 content: None,
1758 encoding: "text".to_string(),
1759 is_directory: true,
1760 is_readonly: false,
1761 created_at: now,
1762 updated_at: now,
1763 }
1764 }
1765
1766 fn readonly_text(content: &str) -> Self {
1767 let mut entry = Self::text(content);
1768 entry.is_readonly = true;
1769 entry
1770 }
1771 }
1772
1773 #[derive(Default)]
1774 struct MockFileStore {
1775 files: Mutex<HashMap<String, StoredFile>>,
1776 conditional_write_injections: Mutex<HashMap<String, StoredFile>>,
1777 }
1778
1779 impl MockFileStore {
1780 fn insert(&self, path: &str, file: StoredFile) {
1781 self.files.lock().unwrap().insert(path.to_string(), file);
1782 }
1783
1784 fn add_text_file(&self, path: &str, content: &str) {
1785 self.insert(path, StoredFile::text(content));
1786 }
1787
1788 fn add_base64_file(&self, path: &str, content: &str) {
1789 self.insert(path, StoredFile::base64(content));
1790 }
1791
1792 fn add_directory(&self, path: &str) {
1793 self.insert(path, StoredFile::directory());
1794 }
1795
1796 fn add_readonly_text_file(&self, path: &str, content: &str) {
1797 self.insert(path, StoredFile::readonly_text(content));
1798 }
1799
1800 fn content(&self, path: &str) -> Option<String> {
1801 self.files
1802 .lock()
1803 .unwrap()
1804 .get(path)
1805 .and_then(|file| file.content.clone())
1806 }
1807
1808 fn inject_conditional_write_change(&self, path: &str, file: StoredFile) {
1809 self.conditional_write_injections
1810 .lock()
1811 .unwrap()
1812 .insert(path.to_string(), file);
1813 }
1814
1815 fn entry_to_session_file(path: &str, entry: &StoredFile) -> SessionFile {
1816 let size_bytes = entry
1817 .content
1818 .as_deref()
1819 .map(|content| {
1820 SessionFile::decode_content(content, &entry.encoding)
1821 .map(|bytes| bytes.len() as i64)
1822 .unwrap_or(content.len() as i64)
1823 })
1824 .unwrap_or(0);
1825
1826 SessionFile {
1827 id: Uuid::new_v4(),
1828 session_id: Uuid::nil(),
1829 path: path.to_string(),
1830 name: path.rsplit('/').next().unwrap_or("").to_string(),
1831 content: entry.content.clone(),
1832 encoding: entry.encoding.clone(),
1833 is_directory: entry.is_directory,
1834 is_readonly: entry.is_readonly,
1835 size_bytes,
1836 created_at: entry.created_at,
1837 updated_at: entry.updated_at,
1838 }
1839 }
1840 }
1841
1842 #[async_trait]
1843 impl SessionFileSystem for MockFileStore {
1844 async fn read_file(
1845 &self,
1846 _session_id: SessionId,
1847 path: &str,
1848 ) -> Result<Option<SessionFile>> {
1849 let files = self.files.lock().unwrap();
1850 Ok(files
1851 .get(path)
1852 .map(|entry| Self::entry_to_session_file(path, entry)))
1853 }
1854
1855 async fn write_file(
1856 &self,
1857 _session_id: SessionId,
1858 path: &str,
1859 content: &str,
1860 encoding: &str,
1861 ) -> Result<SessionFile> {
1862 let mut files = self.files.lock().unwrap();
1863 if let Some(existing) = files.get(path) {
1864 if existing.is_directory {
1865 return Err(anyhow::anyhow!("Path '{}' is a directory", path).into());
1866 }
1867 if existing.is_readonly {
1868 return Err(anyhow::anyhow!("File '{}' is readonly", path).into());
1869 }
1870 }
1871
1872 let created_at = files
1873 .get(path)
1874 .map(|entry| entry.created_at)
1875 .unwrap_or_else(Utc::now);
1876 let entry = StoredFile {
1877 content: Some(content.to_string()),
1878 encoding: encoding.to_string(),
1879 is_directory: false,
1880 is_readonly: false,
1881 created_at,
1882 updated_at: Utc::now(),
1883 };
1884 files.insert(path.to_string(), entry.clone());
1885 Ok(Self::entry_to_session_file(path, &entry))
1886 }
1887
1888 async fn delete_file(
1889 &self,
1890 _session_id: SessionId,
1891 path: &str,
1892 _recursive: bool,
1893 ) -> Result<bool> {
1894 Ok(self.files.lock().unwrap().remove(path).is_some())
1895 }
1896
1897 async fn list_directory(
1898 &self,
1899 _session_id: SessionId,
1900 path: &str,
1901 ) -> Result<Vec<FileInfo>> {
1902 let prefix = if path == "/" {
1903 "/".to_string()
1904 } else {
1905 format!("{}/", path.trim_end_matches('/'))
1906 };
1907 let files = self.files.lock().unwrap();
1908 let mut entries: Vec<FileInfo> = files
1909 .iter()
1910 .filter_map(|(entry_path, entry)| {
1911 if path != "/" && entry_path == path {
1912 return None;
1913 }
1914 let rest = entry_path.strip_prefix(&prefix)?;
1915 if rest.is_empty() || rest.contains('/') {
1916 return None;
1917 }
1918 Some(FileInfo {
1919 id: Uuid::new_v4(),
1920 session_id: Uuid::nil(),
1921 name: rest.to_string(),
1922 path: entry_path.clone(),
1923 is_directory: entry.is_directory,
1924 is_readonly: entry.is_readonly,
1925 size_bytes: entry
1926 .content
1927 .as_ref()
1928 .map(|content| content.len() as i64)
1929 .unwrap_or(0),
1930 created_at: entry.created_at,
1931 updated_at: entry.updated_at,
1932 })
1933 })
1934 .collect();
1935 entries.sort_by(|a, b| a.path.cmp(&b.path));
1936 Ok(entries)
1937 }
1938
1939 async fn stat_file(&self, _session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
1940 let files = self.files.lock().unwrap();
1941 Ok(files.get(path).map(|entry| FileStat {
1942 path: path.to_string(),
1943 name: path.rsplit('/').next().unwrap_or("").to_string(),
1944 is_directory: entry.is_directory,
1945 is_readonly: entry.is_readonly,
1946 size_bytes: entry
1947 .content
1948 .as_ref()
1949 .map(|content| content.len() as i64)
1950 .unwrap_or(0),
1951 created_at: entry.created_at,
1952 updated_at: entry.updated_at,
1953 }))
1954 }
1955
1956 async fn grep_files(
1957 &self,
1958 _session_id: SessionId,
1959 pattern: &str,
1960 _path_pattern: Option<&str>,
1961 ) -> Result<Vec<GrepMatch>> {
1962 let files = self.files.lock().unwrap();
1963 let mut matches = Vec::new();
1964 for (path, entry) in files.iter() {
1965 if entry.is_directory || entry.encoding != "text" {
1966 continue;
1967 }
1968 let Some(content) = entry.content.as_deref() else {
1969 continue;
1970 };
1971 for (idx, line) in content.lines().enumerate() {
1972 if line.contains(pattern) {
1973 matches.push(GrepMatch {
1974 path: path.clone(),
1975 line_number: idx + 1,
1976 line: line.to_string(),
1977 });
1978 }
1979 }
1980 }
1981 matches.sort_by(|a, b| {
1982 a.path
1983 .cmp(&b.path)
1984 .then_with(|| a.line_number.cmp(&b.line_number))
1985 });
1986 Ok(matches)
1987 }
1988
1989 async fn create_directory(&self, _session_id: SessionId, path: &str) -> Result<FileInfo> {
1990 self.add_directory(path);
1991 Ok(FileInfo {
1992 id: Uuid::new_v4(),
1993 session_id: Uuid::nil(),
1994 path: path.to_string(),
1995 name: path.rsplit('/').next().unwrap_or("").to_string(),
1996 is_directory: true,
1997 is_readonly: false,
1998 size_bytes: 0,
1999 created_at: Utc::now(),
2000 updated_at: Utc::now(),
2001 })
2002 }
2003
2004 async fn write_file_if_content_matches(
2005 &self,
2006 _session_id: SessionId,
2007 path: &str,
2008 expected_content: &str,
2009 expected_encoding: &str,
2010 content: &str,
2011 encoding: &str,
2012 ) -> Result<Option<SessionFile>> {
2013 let mut files = self.files.lock().unwrap();
2014 if let Some(injected) = self
2015 .conditional_write_injections
2016 .lock()
2017 .unwrap()
2018 .remove(path)
2019 {
2020 files.insert(path.to_string(), injected);
2021 }
2022
2023 let Some(existing) = files.get(path).cloned() else {
2024 return Ok(None);
2025 };
2026
2027 if existing.is_directory
2028 || existing.is_readonly
2029 || existing.encoding != expected_encoding
2030 || existing.content.unwrap_or_default() != expected_content
2031 {
2032 return Ok(None);
2033 }
2034
2035 let entry = StoredFile {
2036 content: Some(content.to_string()),
2037 encoding: encoding.to_string(),
2038 is_directory: false,
2039 is_readonly: false,
2040 created_at: existing.created_at,
2041 updated_at: Utc::now(),
2042 };
2043 files.insert(path.to_string(), entry.clone());
2044 Ok(Some(Self::entry_to_session_file(path, &entry)))
2045 }
2046 }
2047
2048 fn make_context(file_store: Arc<MockFileStore>) -> ToolContext {
2049 ToolContext::with_file_store(SessionId::new(), file_store)
2050 }
2051
2052 fn expect_success(result: ToolExecutionResult) -> Value {
2053 match result {
2054 ToolExecutionResult::Success(value) => value,
2055 ToolExecutionResult::SuccessWithImages { result, .. } => result,
2056 other => panic!("Expected success, got {other:?}"),
2057 }
2058 }
2059
2060 fn expect_tool_error(result: ToolExecutionResult) -> String {
2061 match result {
2062 ToolExecutionResult::ToolError(message) => message,
2063 other => panic!("Expected tool error, got {other:?}"),
2064 }
2065 }
2066
2067 async fn read_hash(context: &ToolContext, path: &str) -> String {
2068 let result = ReadFileTool
2069 .execute_with_context(json!({ "path": path }), context)
2070 .await;
2071 expect_success(result)["content_hash"]
2072 .as_str()
2073 .unwrap()
2074 .to_string()
2075 }
2076
2077 #[test]
2078 fn test_normalize_path_workspace_root() {
2079 assert_eq!(normalize_path("/workspace"), "/");
2080 }
2081
2082 #[test]
2083 fn test_normalize_path_workspace_file() {
2084 assert_eq!(normalize_path("/workspace/test.txt"), "/test.txt");
2085 }
2086
2087 #[test]
2088 fn test_normalize_path_workspace_nested() {
2089 assert_eq!(
2090 normalize_path("/workspace/foo/bar/test.txt"),
2091 "/foo/bar/test.txt"
2092 );
2093 }
2094
2095 #[test]
2096 fn test_normalize_path_already_normalized() {
2097 assert_eq!(normalize_path("/test.txt"), "/test.txt");
2098 }
2099
2100 #[test]
2101 fn test_normalize_path_invalid_workspace_prefix() {
2102 assert_eq!(normalize_path("/workspacefoo"), "/workspacefoo");
2103 }
2104
2105 #[test]
2106 fn test_add_workspace_prefix_root() {
2107 assert_eq!(add_workspace_prefix("/"), "/workspace");
2108 }
2109
2110 #[test]
2111 fn test_add_workspace_prefix_file() {
2112 assert_eq!(add_workspace_prefix("/test.txt"), "/workspace/test.txt");
2113 }
2114
2115 #[test]
2116 fn test_add_workspace_prefix_nested() {
2117 assert_eq!(
2118 add_workspace_prefix("/foo/bar.txt"),
2119 "/workspace/foo/bar.txt"
2120 );
2121 }
2122
2123 #[test]
2124 fn test_add_workspace_prefix_no_leading_slash() {
2125 assert_eq!(add_workspace_prefix("test.txt"), "/workspace/test.txt");
2126 }
2127
2128 #[test]
2129 fn test_parse_text_edits_rejects_mixed_modes() {
2130 let result = parse_text_edits(&json!({
2131 "old_text": "a",
2132 "new_text": "b",
2133 "edits": [{"old_text": "c", "new_text": "d"}]
2134 }));
2135
2136 assert_eq!(
2137 result.unwrap_err(),
2138 "Provide either old_text/new_text or edits, not both"
2139 );
2140 }
2141
2142 #[test]
2143 fn test_apply_text_edits_rejects_overlaps() {
2144 let result = apply_text_edits(
2145 "abcdef",
2146 &[
2147 TextEdit {
2148 old_text: "abcd".to_string(),
2149 new_text: "wxyz".to_string(),
2150 },
2151 TextEdit {
2152 old_text: "cdef".to_string(),
2153 new_text: "1234".to_string(),
2154 },
2155 ],
2156 );
2157
2158 assert_eq!(result.unwrap_err(), "Edits overlap in the target file");
2159 }
2160
2161 #[test]
2162 fn test_capability_metadata() {
2163 let cap = FileSystemCapability;
2164 assert_eq!(cap.id(), "session_file_system");
2165 assert_eq!(cap.name(), "File System");
2166 assert_eq!(cap.status(), CapabilityStatus::Available);
2167 assert_eq!(cap.icon(), Some("hard-drive"));
2168 assert_eq!(cap.category(), Some("File Operations"));
2169 }
2170
2171 #[test]
2172 fn test_capability_has_tools() {
2173 let cap = FileSystemCapability;
2174 let tools = cap.tools();
2175
2176 assert_eq!(tools.len(), 7);
2177
2178 let tool_names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
2179 assert!(tool_names.contains(&"read_file"));
2180 assert!(tool_names.contains(&"write_file"));
2181 assert!(tool_names.contains(&"edit_file"));
2182 assert!(tool_names.contains(&"list_directory"));
2183 assert!(tool_names.contains(&"grep_files"));
2184 assert!(tool_names.contains(&"delete_file"));
2185 assert!(tool_names.contains(&"stat_file"));
2186 }
2187
2188 #[test]
2189 fn test_capability_has_system_prompt() {
2190 let cap = FileSystemCapability;
2191 let prompt = cap.system_prompt_addition().unwrap();
2192 assert!(prompt.contains("/workspace"));
2193 assert!(prompt.contains("File reading economy"));
2194 assert!(prompt.contains("offset"));
2195 assert!(prompt.contains("total_lines"));
2196 }
2197
2198 #[test]
2199 fn test_tools_require_context() {
2200 assert!(ReadFileTool.requires_context());
2201 assert!(WriteFileTool.requires_context());
2202 assert!(EditFileTool.requires_context());
2203 assert!(ListDirectoryTool.requires_context());
2204 assert!(GrepFilesTool.requires_context());
2205 assert!(DeleteFileTool.requires_context());
2206 assert!(StatFileTool.requires_context());
2207 }
2208
2209 #[test]
2210 fn test_tool_schemas_have_no_top_level_composition_keywords() {
2211 let cap = FileSystemCapability;
2213 let forbidden = ["oneOf", "anyOf", "allOf", "enum", "not"];
2214 for tool in cap.tools() {
2215 let schema = tool.parameters_schema();
2216 for kw in &forbidden {
2217 assert!(
2218 schema.get(*kw).is_none(),
2219 "Tool '{}' schema has forbidden top-level keyword '{}'",
2220 tool.name(),
2221 kw
2222 );
2223 }
2224 }
2225 }
2226
2227 #[tokio::test]
2228 async fn test_read_file_without_context() {
2229 let result = ReadFileTool.execute(json!({"path": "/test.txt"})).await;
2230 assert!(expect_tool_error(result).contains("requires context"));
2231 }
2232
2233 #[tokio::test]
2234 async fn test_write_file_without_context() {
2235 let result = WriteFileTool
2236 .execute(json!({"path": "/test.txt", "content": "hello"}))
2237 .await;
2238 assert!(expect_tool_error(result).contains("requires context"));
2239 }
2240
2241 #[tokio::test]
2242 async fn test_edit_file_without_context() {
2243 let result = EditFileTool
2244 .execute(json!({
2245 "path": "/test.txt",
2246 "expected_hash": "sha256:deadbeef",
2247 "old_text": "hello",
2248 "new_text": "goodbye"
2249 }))
2250 .await;
2251 assert!(expect_tool_error(result).contains("requires context"));
2252 }
2253
2254 #[tokio::test]
2255 async fn test_read_file_missing_path() {
2256 let context = ToolContext::new(SessionId::new());
2257 let result = ReadFileTool.execute_with_context(json!({}), &context).await;
2258 assert!(expect_tool_error(result).contains("Missing required parameter"));
2259 }
2260
2261 #[tokio::test]
2262 async fn test_read_file_no_file_store() {
2263 let context = ToolContext::new(SessionId::new());
2264 let result = ReadFileTool
2265 .execute_with_context(json!({"path": "/test.txt"}), &context)
2266 .await;
2267 assert!(expect_tool_error(result).contains("not available"));
2268 }
2269
2270 #[tokio::test]
2271 async fn test_read_file_returns_content_hash() {
2272 let store = Arc::new(MockFileStore::default());
2273 store.add_text_file("/notes.txt", "hello world");
2274 let context = make_context(store);
2275
2276 let result = ReadFileTool
2277 .execute_with_context(json!({"path": "/workspace/notes.txt"}), &context)
2278 .await;
2279 let value = expect_success(result);
2280
2281 assert_eq!(value["path"], "/workspace/notes.txt");
2282 assert_eq!(value["content"], "1|hello world");
2283 assert_eq!(value["total_lines"], 1);
2284 assert_eq!(value["truncated"], false);
2285 assert_eq!(
2286 value["content_hash"].as_str().unwrap(),
2287 file_content_hash("hello world", "text").unwrap()
2288 );
2289 }
2290
2291 #[tokio::test]
2292 async fn test_read_file_offset_limit() {
2293 let store = Arc::new(MockFileStore::default());
2294 let content = (1..=100)
2295 .map(|i| format!("line {}", i))
2296 .collect::<Vec<_>>()
2297 .join("\n");
2298 store.add_text_file("/big.txt", &content);
2299 let context = make_context(store);
2300
2301 let result = ReadFileTool
2303 .execute_with_context(
2304 json!({"path": "/workspace/big.txt", "offset": 9, "limit": 5}),
2305 &context,
2306 )
2307 .await;
2308 let value = expect_success(result);
2309
2310 assert_eq!(value["total_lines"], 100);
2311 assert_eq!(value["truncated"], true);
2312 assert_eq!(value["lines_shown"]["start"], 10);
2313 assert_eq!(value["lines_shown"]["end"], 14);
2314 let content_str = value["content"].as_str().unwrap();
2315 assert!(content_str.starts_with("10|line 10"));
2316 assert!(content_str.ends_with("14|line 14"));
2317 }
2318
2319 #[tokio::test]
2320 async fn test_read_file_default_limit_truncates() {
2321 let store = Arc::new(MockFileStore::default());
2322 let content = (1..=2500)
2323 .map(|i| format!("line {}", i))
2324 .collect::<Vec<_>>()
2325 .join("\n");
2326 store.add_text_file("/huge.txt", &content);
2327 let context = make_context(store);
2328
2329 let result = ReadFileTool
2330 .execute_with_context(json!({"path": "/workspace/huge.txt"}), &context)
2331 .await;
2332 let value = expect_success(result);
2333
2334 assert_eq!(value["total_lines"], 2500);
2335 assert_eq!(value["truncated"], true);
2336 assert_eq!(value["lines_shown"]["start"], 1);
2337 assert_eq!(value["lines_shown"]["end"], 2000);
2338 }
2339
2340 #[tokio::test]
2345 async fn test_read_file_truncation_envelope_when_not_truncated() {
2346 let store = Arc::new(MockFileStore::default());
2347 store.add_text_file("/notes.txt", "hello world");
2348 let context = make_context(store);
2349
2350 let result = ReadFileTool
2351 .execute_with_context(json!({"path": "/workspace/notes.txt"}), &context)
2352 .await;
2353 let value = expect_success(result);
2354
2355 crate::truncation_info::assert_conforms("read_file", &value);
2356 assert_eq!(value["truncation"]["truncated"], false);
2357 }
2358
2359 #[tokio::test]
2360 async fn test_read_file_truncation_envelope_with_resume() {
2361 let store = Arc::new(MockFileStore::default());
2362 let content = (1..=2500)
2363 .map(|i| format!("line {}", i))
2364 .collect::<Vec<_>>()
2365 .join("\n");
2366 store.add_text_file("/huge.txt", &content);
2367 let context = make_context(store);
2368
2369 let result = ReadFileTool
2370 .execute_with_context(json!({"path": "/workspace/huge.txt"}), &context)
2371 .await;
2372 let value = expect_success(result);
2373
2374 crate::truncation_info::assert_conforms("read_file", &value);
2375 assert_eq!(value["truncation"]["truncated"], true);
2376 assert_eq!(value["truncation"]["reason"], "line_cap");
2377 assert_eq!(value["truncation"]["next_offset"], 2000);
2378 assert!(
2379 value["truncation"]["resume_hint"]
2380 .as_str()
2381 .unwrap()
2382 .contains("offset=2000")
2383 );
2384 }
2385
2386 #[tokio::test]
2387 async fn test_read_file_resume_roundtrip_reaches_end() {
2388 let store = Arc::new(MockFileStore::default());
2389 let content = (1..=2500)
2390 .map(|i| format!("line {}", i))
2391 .collect::<Vec<_>>()
2392 .join("\n");
2393 store.add_text_file("/huge.txt", &content);
2394 let context = make_context(store);
2395
2396 let first = expect_success(
2398 ReadFileTool
2399 .execute_with_context(json!({"path": "/workspace/huge.txt"}), &context)
2400 .await,
2401 );
2402 let next_offset = first["truncation"]["next_offset"].as_u64().unwrap();
2403
2404 let second = expect_success(
2406 ReadFileTool
2407 .execute_with_context(
2408 json!({"path": "/workspace/huge.txt", "offset": next_offset, "limit": 1000}),
2409 &context,
2410 )
2411 .await,
2412 );
2413
2414 assert_eq!(second["truncation"]["truncated"], false);
2417 let shown = &second["lines_shown"];
2418 assert_eq!(shown["start"], 2001);
2419 assert_eq!(shown["end"], 2500);
2420 }
2421
2422 #[tokio::test]
2423 async fn test_list_directory_emits_truncation_envelope() {
2424 let store = Arc::new(MockFileStore::default());
2425 store.add_text_file("/a.txt", "a");
2426 store.add_text_file("/b.txt", "b");
2427 let context = make_context(store);
2428
2429 let result = ListDirectoryTool
2430 .execute_with_context(json!({"path": "/workspace"}), &context)
2431 .await;
2432 let value = expect_success(result);
2433
2434 crate::truncation_info::assert_conforms("list_directory", &value);
2435 assert_eq!(value["truncation"]["truncated"], false);
2436 }
2437
2438 #[tokio::test]
2439 async fn test_list_directory_applies_item_window() {
2440 let store = Arc::new(MockFileStore::default());
2441 store.add_text_file("/a.txt", "a");
2442 store.add_text_file("/b.txt", "b");
2443 store.add_text_file("/c.txt", "c");
2444 let context = make_context(store);
2445
2446 let result = ListDirectoryTool
2447 .execute_with_context(json!({"path": "/workspace", "limit": 2}), &context)
2448 .await;
2449 let value = expect_success(result);
2450
2451 crate::truncation_info::assert_conforms("list_directory", &value);
2452 assert_eq!(value["count"], 2);
2453 assert_eq!(value["total_count"], 3);
2454 assert_eq!(value["truncation"]["truncated"], true);
2455 assert_eq!(value["truncation"]["reason"], "item_cap");
2456 assert_eq!(value["truncation"]["next_offset"], 2);
2457 }
2458
2459 #[tokio::test]
2460 async fn test_grep_files_emits_truncation_envelope() {
2461 let store = Arc::new(MockFileStore::default());
2462 store.add_text_file("/notes.txt", "hello world");
2463 let context = make_context(store);
2464
2465 let result = GrepFilesTool
2466 .execute_with_context(json!({"pattern": "hello"}), &context)
2467 .await;
2468 let value = expect_success(result);
2469
2470 crate::truncation_info::assert_conforms("grep_files", &value);
2471 assert_eq!(value["truncation"]["truncated"], false);
2472 }
2473
2474 #[tokio::test]
2475 async fn test_grep_files_applies_match_window() {
2476 let store = Arc::new(MockFileStore::default());
2477 store.add_text_file("/notes.txt", "hello one\nhello two\nhello three");
2478 let context = make_context(store);
2479
2480 let result = GrepFilesTool
2481 .execute_with_context(json!({"pattern": "hello", "limit": 2}), &context)
2482 .await;
2483 let value = expect_success(result);
2484
2485 crate::truncation_info::assert_conforms("grep_files", &value);
2486 assert_eq!(value["match_count"], 2);
2487 assert_eq!(value["total_matches"], 3);
2488 assert_eq!(value["truncation"]["truncated"], true);
2489 assert_eq!(value["truncation"]["reason"], "line_cap");
2490 assert_eq!(value["truncation"]["next_offset"], 2);
2491 }
2492
2493 #[tokio::test]
2494 async fn test_write_file_returns_content_hash() {
2495 let store = Arc::new(MockFileStore::default());
2496 let context = make_context(store.clone());
2497
2498 let result = WriteFileTool
2499 .execute_with_context(
2500 json!({"path": "/workspace/new.txt", "content": "hello world"}),
2501 &context,
2502 )
2503 .await;
2504 let value = expect_success(result);
2505
2506 assert_eq!(value["path"], "/workspace/new.txt");
2507 assert_eq!(value["size_bytes"], 11);
2508 assert_eq!(
2509 value["content_hash"].as_str().unwrap(),
2510 file_content_hash("hello world", "text").unwrap()
2511 );
2512 assert_eq!(store.content("/new.txt").unwrap(), "hello world");
2513 }
2514
2515 #[tokio::test]
2516 async fn test_edit_file_single_replace_success() {
2517 let store = Arc::new(MockFileStore::default());
2518 store.add_text_file("/notes.txt", "alpha\nbeta\ngamma\n");
2519 let context = make_context(store.clone());
2520 let expected_hash = read_hash(&context, "/workspace/notes.txt").await;
2521
2522 let result = EditFileTool
2523 .execute_with_context(
2524 json!({
2525 "path": "/workspace/notes.txt",
2526 "expected_hash": expected_hash,
2527 "old_text": "beta",
2528 "new_text": "delta"
2529 }),
2530 &context,
2531 )
2532 .await;
2533 let value = expect_success(result);
2534
2535 assert_eq!(
2536 store.content("/notes.txt").unwrap(),
2537 "alpha\ndelta\ngamma\n"
2538 );
2539 assert_eq!(value["applied_edits"], 1);
2540 assert_eq!(value["first_changed_line"], 2);
2541 assert!(value["diff"].as_str().unwrap().contains("-beta"));
2542 assert!(value["diff"].as_str().unwrap().contains("+delta"));
2543 assert_ne!(
2544 value["content_hash"].as_str().unwrap(),
2545 value["previous_content_hash"].as_str().unwrap()
2546 );
2547 }
2548
2549 #[tokio::test]
2550 async fn test_edit_file_batch_replace_success() {
2551 let store = Arc::new(MockFileStore::default());
2552 store.add_text_file("/batch.txt", "one\ntwo\nthree\n");
2553 let context = make_context(store.clone());
2554 let expected_hash = read_hash(&context, "/workspace/batch.txt").await;
2555
2556 let result = EditFileTool
2557 .execute_with_context(
2558 json!({
2559 "path": "/workspace/batch.txt",
2560 "expected_hash": expected_hash,
2561 "edits": [
2562 {"old_text": "one", "new_text": "ONE"},
2563 {"old_text": "three", "new_text": "THREE"}
2564 ]
2565 }),
2566 &context,
2567 )
2568 .await;
2569 let value = expect_success(result);
2570
2571 assert_eq!(store.content("/batch.txt").unwrap(), "ONE\ntwo\nTHREE\n");
2572 assert_eq!(value["applied_edits"], 2);
2573 assert_eq!(value["first_changed_line"], 1);
2574 }
2575
2576 #[tokio::test]
2577 async fn test_edit_file_batch_replace_ignores_empty_single_placeholders() {
2578 let store = Arc::new(MockFileStore::default());
2579 store.add_text_file("/batch-placeholders.txt", "one\ntwo\nthree\n");
2580 let context = make_context(store.clone());
2581 let expected_hash = read_hash(&context, "/workspace/batch-placeholders.txt").await;
2582
2583 let result = EditFileTool
2584 .execute_with_context(
2585 json!({
2586 "path": "/workspace/batch-placeholders.txt",
2587 "expected_hash": expected_hash,
2588 "edits": [
2589 {"old_text": "one", "new_text": "ONE"},
2590 {"old_text": "three", "new_text": "THREE"}
2591 ],
2592 "old_text": "",
2593 "new_text": ""
2594 }),
2595 &context,
2596 )
2597 .await;
2598 let value = expect_success(result);
2599
2600 assert_eq!(
2601 store.content("/batch-placeholders.txt").unwrap(),
2602 "ONE\ntwo\nTHREE\n"
2603 );
2604 assert_eq!(value["applied_edits"], 2);
2605 }
2606
2607 #[tokio::test]
2608 async fn test_edit_file_allows_delete_replacement() {
2609 let store = Arc::new(MockFileStore::default());
2610 store.add_text_file("/delete.txt", "keep\nremove me\nkeep\n");
2611 let context = make_context(store.clone());
2612 let expected_hash = read_hash(&context, "/workspace/delete.txt").await;
2613
2614 let result = EditFileTool
2615 .execute_with_context(
2616 json!({
2617 "path": "/workspace/delete.txt",
2618 "expected_hash": expected_hash,
2619 "old_text": "remove me\n",
2620 "new_text": ""
2621 }),
2622 &context,
2623 )
2624 .await;
2625
2626 expect_success(result);
2627 assert_eq!(store.content("/delete.txt").unwrap(), "keep\nkeep\n");
2628 }
2629
2630 #[tokio::test]
2631 async fn test_edit_file_preserves_bom_and_crlf() {
2632 let store = Arc::new(MockFileStore::default());
2633 store.add_text_file("/windows.txt", "\u{feff}alpha\r\nbeta\r\n");
2634 let context = make_context(store.clone());
2635 let expected_hash = read_hash(&context, "/workspace/windows.txt").await;
2636
2637 let result = EditFileTool
2638 .execute_with_context(
2639 json!({
2640 "path": "/workspace/windows.txt",
2641 "expected_hash": expected_hash,
2642 "old_text": "beta\n",
2643 "new_text": "gamma\n"
2644 }),
2645 &context,
2646 )
2647 .await;
2648
2649 expect_success(result);
2650 assert_eq!(
2651 store.content("/windows.txt").unwrap(),
2652 "\u{feff}alpha\r\ngamma\r\n"
2653 );
2654 }
2655
2656 #[tokio::test]
2657 async fn test_edit_file_preserves_cr_line_endings() {
2658 let store = Arc::new(MockFileStore::default());
2659 store.add_text_file("/classic-mac.txt", "alpha\rbeta\r");
2660 let context = make_context(store.clone());
2661 let expected_hash = read_hash(&context, "/workspace/classic-mac.txt").await;
2662
2663 let result = EditFileTool
2664 .execute_with_context(
2665 json!({
2666 "path": "/workspace/classic-mac.txt",
2667 "expected_hash": expected_hash,
2668 "old_text": "beta\n",
2669 "new_text": "gamma\n"
2670 }),
2671 &context,
2672 )
2673 .await;
2674
2675 expect_success(result);
2676 assert_eq!(store.content("/classic-mac.txt").unwrap(), "alpha\rgamma\r");
2677 }
2678
2679 #[tokio::test]
2680 async fn test_edit_file_rejects_hash_mismatch() {
2681 let store = Arc::new(MockFileStore::default());
2682 store.add_text_file("/stale.txt", "hello");
2683 let context = make_context(store);
2684
2685 let result = EditFileTool
2686 .execute_with_context(
2687 json!({
2688 "path": "/workspace/stale.txt",
2689 "expected_hash": "sha256:stale",
2690 "old_text": "hello",
2691 "new_text": "goodbye"
2692 }),
2693 &context,
2694 )
2695 .await;
2696
2697 assert!(expect_tool_error(result).contains("changed since the last read"));
2698 }
2699
2700 #[tokio::test]
2701 async fn test_edit_file_rejects_binary_file() {
2702 let store = Arc::new(MockFileStore::default());
2703 store.add_base64_file("/image.png", "aGVsbG8=");
2704 let context = make_context(store.clone());
2705 let expected_hash = read_hash(&context, "/workspace/image.png").await;
2706
2707 let result = EditFileTool
2708 .execute_with_context(
2709 json!({
2710 "path": "/workspace/image.png",
2711 "expected_hash": expected_hash,
2712 "old_text": "hello",
2713 "new_text": "goodbye"
2714 }),
2715 &context,
2716 )
2717 .await;
2718
2719 assert!(expect_tool_error(result).contains("only supports text files"));
2720 }
2721
2722 #[tokio::test]
2723 async fn test_read_file_non_image_binary_omits_base64_content() {
2724 let store = Arc::new(MockFileStore::default());
2725 store.add_base64_file("/archive.zip", "UEsDBAoAAAAAAA==");
2726 let context = make_context(store);
2727
2728 let result = ReadFileTool
2729 .execute_with_context(json!({"path": "/workspace/archive.zip"}), &context)
2730 .await;
2731 let value = expect_success(result);
2732
2733 assert_eq!(value["content_type"], "binary");
2734 assert_eq!(value["encoding"], "base64");
2735 assert_eq!(value["truncation"]["truncated"], false);
2736 assert_eq!(value["truncation"]["bytes_returned"], 0);
2737 assert!(value.get("content").is_none());
2738 assert!(value.get("content_hash").is_some());
2739 }
2740
2741 #[tokio::test]
2742 async fn test_edit_file_rejects_directory() {
2743 let store = Arc::new(MockFileStore::default());
2744 store.add_directory("/docs");
2745 let context = make_context(store);
2746
2747 let result = EditFileTool
2748 .execute_with_context(
2749 json!({
2750 "path": "/workspace/docs",
2751 "expected_hash": "sha256:anything",
2752 "old_text": "hello",
2753 "new_text": "goodbye"
2754 }),
2755 &context,
2756 )
2757 .await;
2758
2759 assert!(expect_tool_error(result).contains("is a directory"));
2760 }
2761
2762 #[tokio::test]
2763 async fn test_edit_file_rejects_missing_match() {
2764 let store = Arc::new(MockFileStore::default());
2765 store.add_text_file("/missing.txt", "hello");
2766 let context = make_context(store.clone());
2767 let expected_hash = read_hash(&context, "/workspace/missing.txt").await;
2768
2769 let result = EditFileTool
2770 .execute_with_context(
2771 json!({
2772 "path": "/workspace/missing.txt",
2773 "expected_hash": expected_hash,
2774 "old_text": "absent",
2775 "new_text": "present"
2776 }),
2777 &context,
2778 )
2779 .await;
2780
2781 assert!(expect_tool_error(result).contains("Could not find an exact match"));
2782 }
2783
2784 #[tokio::test]
2785 async fn test_edit_file_rejects_ambiguous_match() {
2786 let store = Arc::new(MockFileStore::default());
2787 store.add_text_file("/ambiguous.txt", "hello\nhello\n");
2788 let context = make_context(store.clone());
2789 let expected_hash = read_hash(&context, "/workspace/ambiguous.txt").await;
2790
2791 let result = EditFileTool
2792 .execute_with_context(
2793 json!({
2794 "path": "/workspace/ambiguous.txt",
2795 "expected_hash": expected_hash,
2796 "old_text": "hello",
2797 "new_text": "goodbye"
2798 }),
2799 &context,
2800 )
2801 .await;
2802
2803 assert!(expect_tool_error(result).contains("matched multiple locations"));
2804 }
2805
2806 #[tokio::test]
2807 async fn test_edit_file_rejects_overlapping_batch_edits() {
2808 let store = Arc::new(MockFileStore::default());
2809 store.add_text_file("/overlap.txt", "abcdef");
2810 let context = make_context(store.clone());
2811 let expected_hash = read_hash(&context, "/workspace/overlap.txt").await;
2812
2813 let result = EditFileTool
2814 .execute_with_context(
2815 json!({
2816 "path": "/workspace/overlap.txt",
2817 "expected_hash": expected_hash,
2818 "edits": [
2819 {"old_text": "abcd", "new_text": "WXYZ"},
2820 {"old_text": "cdef", "new_text": "1234"}
2821 ]
2822 }),
2823 &context,
2824 )
2825 .await;
2826
2827 assert!(expect_tool_error(result).contains("Edits overlap"));
2828 }
2829
2830 #[tokio::test]
2831 async fn test_edit_file_rejects_missing_expected_hash() {
2832 let store = Arc::new(MockFileStore::default());
2833 store.add_text_file("/hashless.txt", "hello");
2834 let context = make_context(store);
2835
2836 let result = EditFileTool
2837 .execute_with_context(
2838 json!({
2839 "path": "/workspace/hashless.txt",
2840 "old_text": "hello",
2841 "new_text": "goodbye"
2842 }),
2843 &context,
2844 )
2845 .await;
2846
2847 assert!(expect_tool_error(result).contains("Missing required parameter: expected_hash"));
2848 }
2849
2850 #[tokio::test]
2851 async fn test_edit_file_rejects_readonly_target() {
2852 let store = Arc::new(MockFileStore::default());
2853 store.add_readonly_text_file("/readonly.txt", "hello");
2854 let context = make_context(store.clone());
2855 let expected_hash = read_hash(&context, "/workspace/readonly.txt").await;
2856
2857 let result = EditFileTool
2858 .execute_with_context(
2859 json!({
2860 "path": "/workspace/readonly.txt",
2861 "expected_hash": expected_hash,
2862 "old_text": "hello",
2863 "new_text": "goodbye"
2864 }),
2865 &context,
2866 )
2867 .await;
2868
2869 assert!(expect_tool_error(result).contains("readonly"));
2870 }
2871
2872 #[tokio::test]
2873 async fn test_edit_file_detects_concurrent_change_during_write() {
2874 let store = Arc::new(MockFileStore::default());
2875 store.add_text_file("/race.txt", "hello");
2876 store.inject_conditional_write_change("/race.txt", StoredFile::text("hola"));
2877 let context = make_context(store.clone());
2878 let expected_hash = read_hash(&context, "/workspace/race.txt").await;
2879
2880 let result = EditFileTool
2881 .execute_with_context(
2882 json!({
2883 "path": "/workspace/race.txt",
2884 "expected_hash": expected_hash,
2885 "old_text": "hello",
2886 "new_text": "goodbye"
2887 }),
2888 &context,
2889 )
2890 .await;
2891
2892 assert!(expect_tool_error(result).contains("changed since the last read"));
2893 assert_eq!(store.content("/race.txt").unwrap(), "hola");
2894 }
2895
2896 #[tokio::test]
2897 async fn test_edit_file_truncates_large_diffs() {
2898 let store = Arc::new(MockFileStore::default());
2899 let original = format!("{}\n", "a".repeat(MAX_EDIT_DIFF_CHARS + 2000));
2900 let replacement = format!("{}\n", "b".repeat(MAX_EDIT_DIFF_CHARS + 2000));
2901 store.add_text_file("/large.txt", &original);
2902 let context = make_context(store.clone());
2903 let expected_hash = read_hash(&context, "/workspace/large.txt").await;
2904
2905 let result = EditFileTool
2906 .execute_with_context(
2907 json!({
2908 "path": "/workspace/large.txt",
2909 "expected_hash": expected_hash,
2910 "old_text": original,
2911 "new_text": replacement
2912 }),
2913 &context,
2914 )
2915 .await;
2916 let value = expect_success(result);
2917
2918 assert_eq!(value["diff_truncated"], true);
2919 assert!(
2920 value["diff"]
2921 .as_str()
2922 .unwrap()
2923 .contains("diff truncated after")
2924 );
2925 }
2926
2927 #[test]
2928 fn test_image_media_type_png() {
2929 assert_eq!(
2930 image_media_type("/workspace/screenshot.png"),
2931 Some("image/png")
2932 );
2933 }
2934
2935 #[test]
2936 fn test_image_media_type_jpeg() {
2937 assert_eq!(image_media_type("/workspace/photo.jpg"), Some("image/jpeg"));
2938 assert_eq!(
2939 image_media_type("/workspace/photo.jpeg"),
2940 Some("image/jpeg")
2941 );
2942 }
2943
2944 #[test]
2945 fn test_image_media_type_gif() {
2946 assert_eq!(image_media_type("/data/anim.gif"), Some("image/gif"));
2947 }
2948
2949 #[test]
2950 fn test_image_media_type_webp() {
2951 assert_eq!(image_media_type("/images/art.webp"), Some("image/webp"));
2952 }
2953
2954 #[test]
2955 fn test_image_media_type_case_insensitive() {
2956 assert_eq!(image_media_type("/workspace/PHOTO.PNG"), Some("image/png"));
2957 assert_eq!(image_media_type("/workspace/image.JPG"), Some("image/jpeg"));
2958 }
2959
2960 #[test]
2961 fn test_image_media_type_not_image() {
2962 assert_eq!(image_media_type("/workspace/readme.txt"), None);
2963 assert_eq!(image_media_type("/workspace/data.json"), None);
2964 assert_eq!(image_media_type("/workspace/script.py"), None);
2965 }
2966
2967 #[test]
2969 fn test_content_type_log_files() {
2970 assert_eq!(content_type_from_extension("/app.log"), ContentType::Log);
2971 assert_eq!(content_type_from_extension("/build.out"), ContentType::Log);
2972 assert_eq!(content_type_from_extension("/debug.LOG"), ContentType::Log);
2973 }
2974
2975 #[test]
2976 fn test_content_type_csv_files() {
2977 assert_eq!(content_type_from_extension("/data.csv"), ContentType::Csv);
2978 assert_eq!(content_type_from_extension("/export.tsv"), ContentType::Csv);
2979 assert_eq!(content_type_from_extension("/data.CSV"), ContentType::Csv);
2980 }
2981
2982 #[test]
2983 fn test_content_type_binary_files() {
2984 assert_eq!(
2985 content_type_from_extension("/app.wasm"),
2986 ContentType::Binary
2987 );
2988 assert_eq!(content_type_from_extension("/lib.so"), ContentType::Binary);
2989 assert_eq!(
2990 content_type_from_extension("/archive.zip"),
2991 ContentType::Binary
2992 );
2993 assert_eq!(
2994 content_type_from_extension("/font.woff2"),
2995 ContentType::Binary
2996 );
2997 }
2998
2999 #[test]
3000 fn test_content_type_minified_files() {
3001 assert_eq!(
3002 content_type_from_extension("/bundle.min.js"),
3003 ContentType::Minified
3004 );
3005 assert_eq!(
3006 content_type_from_extension("/styles.min.css"),
3007 ContentType::Minified
3008 );
3009 }
3010
3011 #[test]
3012 fn test_content_type_text_files() {
3013 assert_eq!(content_type_from_extension("/main.rs"), ContentType::Text);
3014 assert_eq!(content_type_from_extension("/index.ts"), ContentType::Text);
3015 assert_eq!(content_type_from_extension("/README.md"), ContentType::Text);
3016 assert_eq!(
3017 content_type_from_extension("/config.json"),
3018 ContentType::Text
3019 );
3020 }
3021
3022 #[test]
3023 fn test_content_type_minified_before_generic_js() {
3024 assert_eq!(
3026 content_type_from_extension("/bundle.min.js"),
3027 ContentType::Minified
3028 );
3029 assert_eq!(content_type_from_extension("/app.js"), ContentType::Text);
3031 }
3032
3033 #[test]
3034 fn test_effective_read_defaults_explicit_wins() {
3035 let (_, mode) = effective_read_defaults("/app.log", true, true);
3037 assert_eq!(mode, ReadMode::FromOffset);
3038 }
3039
3040 #[test]
3041 fn test_effective_read_defaults_log_tail() {
3042 let (limit, mode) = effective_read_defaults("/app.log", false, false);
3043 assert_eq!(limit, 500);
3044 assert_eq!(mode, ReadMode::FromEnd);
3045 }
3046
3047 #[test]
3048 fn test_effective_read_defaults_csv() {
3049 let (limit, mode) = effective_read_defaults("/data.csv", false, false);
3050 assert_eq!(limit, 100);
3051 assert_eq!(mode, ReadMode::FromOffset);
3052 }
3053
3054 #[test]
3055 fn test_effective_read_defaults_binary() {
3056 let (_, mode) = effective_read_defaults("/app.wasm", false, false);
3057 assert_eq!(mode, ReadMode::MetadataOnly);
3058 }
3059}