Skip to main content

mcp_servers/coding/tools/read_file/
mod.rs

1use crate::file_ops::{FileError, read_text_file};
2use mcp_utils::display_meta::{ToolDisplayMeta, ToolResultMeta, basename};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7const MAX_LINE_LENGTH: usize = 2000;
8const DEFAULT_LINE_LIMIT: usize = 2000;
9
10#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
11#[serde(rename_all = "camelCase")]
12pub struct ReadFileArgs {
13    /// Path to the file to read (must be an existing file)
14    #[serde(alias = "file_path")]
15    pub file_path: String,
16    /// Starting line number to read from (1-indexed). If not specified, starts from line 1.
17    pub offset: Option<usize>,
18    /// Maximum number of lines to read. If not specified, reads up to 2000 lines.
19    pub limit: Option<usize>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
23#[serde(rename_all = "camelCase")]
24pub struct ReadFileResult {
25    pub status: String,
26    pub file_path: String,
27    pub content: String,
28    pub total_lines: usize,
29    pub lines_shown: usize,
30    pub offset: usize,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub limit: Option<usize>,
33    pub size: usize,
34    /// Raw file content without line numbers (used internally for LSP sync)
35    #[serde(skip_serializing)]
36    pub raw_content: String,
37    /// Display metadata for human-friendly rendering
38    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
39    #[schemars(skip)]
40    pub meta: Option<ToolResultMeta>,
41}
42
43pub async fn read_file_contents(args: ReadFileArgs) -> Result<ReadFileResult, FileError> {
44    let content = read_text_file(Path::new(&args.file_path)).await?;
45
46    let all_lines: Vec<&str> = content.lines().collect();
47    let total_lines = all_lines.len();
48
49    // Default offset to 1 if not provided
50    let offset = args.offset.unwrap_or(1);
51
52    // Validate offset is 1-indexed
53    if offset == 0 {
54        return Err(FileError::InvalidOffset { path: args.file_path });
55    }
56
57    let start_idx = (offset - 1).min(total_lines);
58    let limit = args.limit.unwrap_or(DEFAULT_LINE_LIMIT);
59    let end_idx = (start_idx + limit).min(total_lines);
60    let selected_lines: Vec<&str> = all_lines[start_idx..end_idx].to_vec();
61    let lines_with_numbers: Vec<String> = selected_lines
62        .iter()
63        .enumerate()
64        .map(|(i, line)| {
65            let line_num = offset + i;
66            if line.len() > MAX_LINE_LENGTH {
67                format!("{:5}\t{}... [truncated, {} chars total]", line_num, &line[..MAX_LINE_LENGTH], line.len())
68            } else {
69                format!("{line_num:5}\t{line}")
70            }
71        })
72        .collect();
73
74    let formatted_content = lines_with_numbers.join("\n");
75
76    let display_meta = ToolDisplayMeta::new("Read file", format!("{}, {total_lines} lines", basename(&args.file_path)));
77
78    Ok(ReadFileResult {
79        status: "success".to_string(),
80        file_path: args.file_path,
81        content: formatted_content,
82        total_lines,
83        lines_shown: selected_lines.len(),
84        offset,
85        limit: Some(limit),
86        size: content.len(),
87        raw_content: content,
88        meta: Some(display_meta.into()),
89    })
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use std::fs;
96    use tempfile::TempDir;
97
98    #[tokio::test]
99    async fn test_read_file_with_defaults() {
100        let temp_dir = TempDir::new().unwrap();
101        let test_path = temp_dir.path().join("test_read_defaults.txt");
102        let test_content = "line 1\nline 2\nline 3";
103
104        fs::write(&test_path, test_content).unwrap();
105
106        let result = read_file_contents(ReadFileArgs {
107            file_path: test_path.to_string_lossy().to_string(),
108            offset: None,
109            limit: None,
110        })
111        .await
112        .unwrap();
113
114        assert_eq!(result.status, "success");
115        assert_eq!(result.total_lines, 3);
116        assert_eq!(result.lines_shown, 3);
117        assert_eq!(result.offset, 1);
118        assert_eq!(result.limit, Some(DEFAULT_LINE_LIMIT));
119        assert!(result.content.contains("    1\tline 1"));
120        assert!(result.content.contains("    2\tline 2"));
121        assert!(result.content.contains("    3\tline 3"));
122    }
123
124    #[tokio::test]
125    async fn test_read_file_with_offset_and_limit() {
126        let temp_dir = TempDir::new().unwrap();
127        let test_path = temp_dir.path().join("test_offset_limit.txt");
128        let test_content = "line 1\nline 2\nline 3\nline 4\nline 5";
129
130        fs::write(&test_path, test_content).unwrap();
131
132        let result = read_file_contents(ReadFileArgs {
133            file_path: test_path.to_string_lossy().to_string(),
134            offset: Some(2),
135            limit: Some(2),
136        })
137        .await
138        .unwrap();
139
140        assert_eq!(result.total_lines, 5);
141        assert_eq!(result.lines_shown, 2);
142        assert_eq!(result.offset, 2);
143        assert_eq!(result.limit, Some(2));
144        assert_eq!(result.content, "    2\tline 2\n    3\tline 3");
145    }
146
147    #[tokio::test]
148    async fn test_read_file_line_truncation() {
149        let temp_dir = TempDir::new().unwrap();
150        let test_path = temp_dir.path().join("test_truncation.txt");
151        let short_line = "short";
152        let long_line = "x".repeat(2500);
153        let test_content = format!("{short_line}\n{long_line}");
154
155        fs::write(&test_path, &test_content).unwrap();
156
157        let result = read_file_contents(ReadFileArgs {
158            file_path: test_path.to_string_lossy().to_string(),
159            offset: None,
160            limit: None,
161        })
162        .await
163        .unwrap();
164
165        let lines: Vec<&str> = result.content.split('\n').collect();
166        assert_eq!(lines.len(), 2);
167        assert!(lines[0].contains("short"));
168        assert!(!lines[0].contains("truncated"));
169        assert!(lines[1].contains("truncated"));
170        assert!(lines[1].contains("2500 chars total"));
171    }
172
173    #[tokio::test]
174    async fn test_read_file_default_limit() {
175        let temp_dir = TempDir::new().unwrap();
176        let test_path = temp_dir.path().join("test_default_limit.txt");
177        // Create file with more than DEFAULT_LINE_LIMIT lines
178        let mut lines = Vec::new();
179        for i in 1..=2500 {
180            lines.push(format!("Line {i}"));
181        }
182        let test_content = lines.join("\n");
183
184        fs::write(&test_path, &test_content).unwrap();
185
186        let result = read_file_contents(ReadFileArgs {
187            file_path: test_path.to_string_lossy().to_string(),
188            offset: None,
189            limit: None,
190        })
191        .await
192        .unwrap();
193
194        assert_eq!(result.total_lines, 2500);
195        assert_eq!(result.lines_shown, DEFAULT_LINE_LIMIT);
196        assert_eq!(result.limit, Some(DEFAULT_LINE_LIMIT));
197        assert!(result.content.contains("    1\tLine 1"));
198        assert!(result.content.contains(" 2000\tLine 2000"));
199        assert!(!result.content.contains("Line 2001"));
200    }
201
202    #[tokio::test]
203    async fn test_read_file_invalid_offset() {
204        let temp_dir = TempDir::new().unwrap();
205        let test_path = temp_dir.path().join("test_invalid_offset.txt");
206        let test_content = "line 1";
207
208        fs::write(&test_path, test_content).unwrap();
209
210        let result = read_file_contents(ReadFileArgs {
211            file_path: test_path.to_string_lossy().to_string(),
212            offset: Some(0),
213            limit: None,
214        })
215        .await;
216
217        assert!(matches!(result, Err(FileError::InvalidOffset { .. })));
218    }
219
220    #[tokio::test]
221    async fn test_read_file_nonexistent() {
222        let temp_dir = TempDir::new().unwrap();
223        let nonexistent_path = temp_dir.path().join("nonexistent_file_xyz123.txt");
224
225        let result = read_file_contents(ReadFileArgs {
226            file_path: nonexistent_path.to_string_lossy().to_string(),
227            offset: None,
228            limit: None,
229        })
230        .await;
231
232        assert!(matches!(result, Err(FileError::NotFound { .. })));
233    }
234
235    #[test]
236    fn test_read_file_args_accepts_snake_case_file_path() {
237        let args: ReadFileArgs = serde_json::from_value(serde_json::json!({
238            "file_path": "/tmp/test.txt",
239            "offset": 1,
240            "limit": 10
241        }))
242        .unwrap();
243
244        assert_eq!(args.file_path, "/tmp/test.txt");
245        assert_eq!(args.offset, Some(1));
246        assert_eq!(args.limit, Some(10));
247    }
248}