mcp_servers/coding/tools/read_file/
mod.rs1use 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 #[serde(alias = "file_path")]
15 pub file_path: String,
16 pub offset: Option<usize>,
18 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 #[serde(skip_serializing)]
36 pub raw_content: String,
37 #[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 let offset = args.offset.unwrap_or(1);
51
52 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 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}