Skip to main content

everruns_core/capabilities/
file_system.rs

1//! Session File System Capability
2//!
3//! This capability provides tools for interacting with the session file system.
4//! Each session has its own isolated filesystem stored in the database.
5//!
6//! Tools provided:
7//! - `read_file`: Read file content
8//! - `write_file`: Create or update a file
9//! - `edit_file`: Apply surgical text replacements to an existing file
10//! - `list_directory`: List files in a directory
11//! - `grep_files`: Search files by regex pattern
12//! - `delete_file`: Delete a file or directory
13//! - `stat_file`: Get file metadata
14
15use 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
27/// Image MIME types recognized by LLM vision APIs (OpenAI, Anthropic)
28const 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
36/// Get the image MIME type if the path has a known image extension
37fn 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
45/// Workspace prefix used in file paths
46const 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// ============================================================================
54// Content-type detection (EVE-249)
55// ============================================================================
56
57/// Content type categories for read_file default behavior.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59enum ContentType {
60    /// Source code, markdown, config — standard 2000-line default
61    Text,
62    /// Log files — tail-biased (last 500 lines)
63    Log,
64    /// CSV/TSV data — 100-line default with header prepend
65    Csv,
66    /// Known binary formats — metadata only (no inline content)
67    Binary,
68    /// Minified files — first 500 chars only
69    Minified,
70}
71
72/// Read mode for content-type-aware defaults.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74enum ReadMode {
75    /// Read from the beginning (standard)
76    FromOffset,
77    /// Read from the end (tail-biased for logs)
78    FromEnd,
79    /// Return metadata only, no content
80    MetadataOnly,
81}
82
83/// Detect content type from file extension.
84fn content_type_from_extension(path: &str) -> ContentType {
85    let lower = path.to_lowercase();
86
87    // Check minified first (.min.js, .min.css) before generic .js/.css
88    if lower.ends_with(".min.js") || lower.ends_with(".min.css") {
89        return ContentType::Minified;
90    }
91
92    // Log files
93    if lower.ends_with(".log") || lower.ends_with(".out") {
94        return ContentType::Log;
95    }
96
97    // CSV/TSV data files
98    if lower.ends_with(".csv") || lower.ends_with(".tsv") {
99        return ContentType::Csv;
100    }
101
102    // Binary formats (images already handled separately via image_media_type)
103    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
116/// Resolve effective limit and read mode based on content type.
117/// Returns (limit, read_mode). Explicit user values always win.
118fn effective_read_defaults(
119    path: &str,
120    explicit_offset: bool,
121    explicit_limit: bool,
122) -> (usize, ReadMode) {
123    if explicit_limit && explicit_offset {
124        // User provided both — don't override anything
125        return (0, ReadMode::FromOffset); // limit is already set by caller
126    }
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), // ~20 lines, capped by byte limit
133        ContentType::Text => (
134            crate::tool_output_sanitizer::READ_FILE_DEFAULT_LIMIT,
135            ReadMode::FromOffset,
136        ),
137    }
138}
139
140/// Normalize a file path by stripping the /workspace prefix.
141/// This ensures both file_system and virtual_bash capabilities use the same
142/// path format in the session file store.
143///
144/// Examples:
145/// - `/workspace/foo.txt` -> `/foo.txt`
146/// - `/workspace` -> `/`
147/// - `/foo.txt` -> `/foo.txt` (already normalized)
148fn 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 like "/workspacefoo" - not a valid workspace path
156            path.to_string()
157        }
158    } else {
159        // Path doesn't start with /workspace - use as-is
160        path.to_string()
161    }
162}
163
164/// Add workspace prefix back to a path for display to the user
165fn 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
433/// Session File System capability - provides file operations for session storage
434pub 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        // Constraints the model cannot infer from tool schemas: workspace
470        // root path and a behavioral nudge against guessing. Sandbox-tool
471        // routing is dynamic — when sandbox tools are present, their own
472        // capability prompt names them; we don't preadvertise here.
473        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
500// ============================================================================
501// ReadFileTool
502// ============================================================================
503
504/// Tool to read file content
505pub 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        // Normalize path to strip /workspace prefix for storage
595        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                // Check if this is an image file that should be returned as native image content
611                if let Some(media_type) = image_media_type(&normalized_path) {
612                    // For base64-encoded files, return as image
613                    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                    // Text-encoded image paths still get returned as text (unusual case)
634                }
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                // Non-image binary files: return metadata only. Base64 payloads
642                // are token-expensive and usually not useful to the model.
643                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                // Apply content-type-aware defaults (EVE-249)
656                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                // Metadata-only for known binary extensions
661                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                // Apply content-type defaults when user didn't specify
672                if !explicit_limit {
673                    limit = ct_limit;
674                }
675
676                // Tail-biased reading for log files
677                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                // CSV: prepend header row when reading from an offset past line 0
685                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                // Generate structural outline for unread portions (EVE-248)
703                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                // Reapply hard cap after any post-format decorations (e.g. outlines).
720                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                // Add content_type and read_mode metadata (EVE-249)
737                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                // Unified reading-tool truncation envelope (EVE-339).
753                //
754                // Distinguishing which cap fired:
755                // - When `end_line < total_lines` the line window was clipped
756                //   by `limit`, so this is a line cap and line-based resume is
757                //   safe.
758                // - When `truncated == true` but `end_line == total_lines` the
759                //   line window covered every line and the cut must have come
760                //   from the byte cap inside `format_lines`. Byte truncation
761                //   can cut mid-line, so `next_offset = end_line` is not a
762                //   reliable resume point — emit `without_resume` and let the
763                //   caller narrow `limit` or shift `offset`.
764                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
803// ============================================================================
804// WriteFileTool
805// ============================================================================
806
807/// Tool to write/create a file
808pub 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        // Mutates the shared session workspace: serialize against other
850        // workspace writes (and bash) within a batch to avoid races.
851        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        // Normalize path to strip /workspace prefix for storage
892        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                // Check if it's a user-facing error (like readonly file)
913                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
928// ============================================================================
929// EditFileTool
930// ============================================================================
931
932/// Tool to apply exact text replacements to an existing text file
933pub 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        // Mutates the shared session workspace: serialize against other
997        // workspace writes (and bash) within a batch to avoid races.
998        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(&current_content, &edits) {
1082            Ok(result) => result,
1083            Err(error) => return ToolExecutionResult::tool_error(error),
1084        };
1085
1086        let first_changed_line = first_changed_line(&current_content, &updated_content);
1087        let (diff, diff_truncated) = truncate_diff(render_unified_diff(
1088            &display_path,
1089            &current_content,
1090            &updated_content,
1091        ));
1092
1093        match file_store
1094            .write_file_if_content_matches(
1095                context.session_id,
1096                &normalized_path,
1097                &current_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
1179// ============================================================================
1180// ListDirectoryTool
1181// ============================================================================
1182
1183/// Tool to list directory contents
1184pub 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        // Normalize path to strip /workspace prefix for storage
1268        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
1338// ============================================================================
1339// GrepFilesTool
1340// ============================================================================
1341
1342/// Tool to search files by pattern
1343pub 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
1497// ============================================================================
1498// DeleteFileTool
1499// ============================================================================
1500
1501/// Tool to delete a file or directory
1502pub 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        // Mutates the shared session workspace: serialize against other
1539        // workspace writes (and bash) within a batch to avoid races.
1540        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        // Normalize path to strip /workspace prefix for storage
1577        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
1610// ============================================================================
1611// StatFileTool
1612// ============================================================================
1613
1614/// Tool to get file metadata
1615pub 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        // Normalize path to strip /workspace prefix for storage
1677        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        // OpenAI Responses API rejects schemas with oneOf/anyOf/allOf/enum/not at top level
2212        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        // Read lines 10-14 (0-indexed offset=9, limit=5)
2302        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    // ============================================================================
2341    // EVE-339 — Reading-tool truncation envelope conformance
2342    // ============================================================================
2343
2344    #[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        // First page
2397        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        // Resume from next_offset
2405        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        // After resuming we cover the remaining 500 lines and the envelope
2415        // reports `truncated: false` on the final chunk.
2416        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    // EVE-249: Content-type detection tests
2968    #[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        // .min.js should be Minified, not Text
3025        assert_eq!(
3026            content_type_from_extension("/bundle.min.js"),
3027            ContentType::Minified
3028        );
3029        // Plain .js should be Text
3030        assert_eq!(content_type_from_extension("/app.js"), ContentType::Text);
3031    }
3032
3033    #[test]
3034    fn test_effective_read_defaults_explicit_wins() {
3035        // When user provides both offset and limit, don't override
3036        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}