mcp_servers/coding/tools/read_file/
mod.rs1use crate::file_ops::{FileError, read_text_file};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6const MAX_LINE_BYTES: usize = 2000;
7const PAGE_BYTES: usize = 32_000;
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
10#[serde(rename_all = "camelCase")]
11pub struct ReadFileArgs {
12 #[serde(alias = "file_path")]
14 pub file_path: String,
15 pub offset: Option<usize>,
17 pub limit: Option<usize>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
22#[serde(rename_all = "camelCase")]
23pub struct ReadFileResult {
24 pub file_path: String,
25 pub content: String,
26 pub total_lines: usize,
27 pub lines_shown: usize,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub next_offset: Option<usize>,
30 pub size: usize,
31}
32
33pub async fn read_file_contents(args: ReadFileArgs) -> Result<ReadFileResult, FileError> {
34 let content = read_text_file(Path::new(&args.file_path)).await?;
35
36 let all_lines: Vec<&str> = content.lines().collect();
37 let total_lines = all_lines.len();
38
39 let offset = args.offset.unwrap_or(1);
41
42 if offset == 0 {
44 return Err(FileError::InvalidOffset { path: args.file_path });
45 }
46
47 let start_idx = (offset - 1).min(total_lines);
48 let end_idx = args.limit.map_or(total_lines, |limit| (start_idx + limit).min(total_lines));
49 let mut lines_with_numbers = Vec::new();
50 let mut content_bytes = 0;
51 for (line_num, line) in (offset..).zip(&all_lines[start_idx..end_idx]) {
52 let numbered = number_line(line_num, line);
53 content_bytes += numbered.len() + 1;
54 if content_bytes > PAGE_BYTES {
55 break;
56 }
57 lines_with_numbers.push(numbered);
58 }
59 let lines_shown = lines_with_numbers.len();
60 let next_offset = (start_idx + lines_shown < total_lines).then_some(offset + lines_shown);
61
62 let formatted_content = lines_with_numbers.join("\n");
63
64 Ok(ReadFileResult {
65 file_path: args.file_path,
66 content: formatted_content,
67 total_lines,
68 lines_shown,
69 next_offset,
70 size: content.len(),
71 })
72}
73
74fn number_line(line_num: usize, line: &str) -> String {
75 if line.len() > MAX_LINE_BYTES {
76 let prefix = &line[..line.floor_char_boundary(MAX_LINE_BYTES)];
77 format!("{line_num:5}\t{prefix}... [truncated, {} bytes total]", line.len())
78 } else {
79 format!("{line_num:5}\t{line}")
80 }
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use crate::testing::TestWorkspace;
87
88 #[tokio::test]
89 async fn test_read_file_with_defaults() {
90 let workspace = TestWorkspace::new().file("test_read_defaults.txt", "line 1\nline 2\nline 3");
91
92 let result = read_file_contents(ReadFileArgs {
93 file_path: workspace.path_string("test_read_defaults.txt"),
94 offset: None,
95 limit: None,
96 })
97 .await
98 .unwrap();
99
100 assert_eq!(result.total_lines, 3);
101 assert_eq!(result.lines_shown, 3);
102 assert_eq!(result.next_offset, None);
103 assert!(result.content.contains(" 1\tline 1"));
104 assert!(result.content.contains(" 2\tline 2"));
105 assert!(result.content.contains(" 3\tline 3"));
106 }
107
108 #[tokio::test]
109 async fn test_read_file_with_offset_and_limit() {
110 let workspace = TestWorkspace::new().file("test_offset_limit.txt", "line 1\nline 2\nline 3\nline 4\nline 5");
111
112 let result = read_file_contents(ReadFileArgs {
113 file_path: workspace.path_string("test_offset_limit.txt"),
114 offset: Some(2),
115 limit: Some(2),
116 })
117 .await
118 .unwrap();
119
120 assert_eq!(result.total_lines, 5);
121 assert_eq!(result.lines_shown, 2);
122 assert_eq!(result.next_offset, Some(4));
123 assert_eq!(result.content, " 2\tline 2\n 3\tline 3");
124 }
125
126 #[tokio::test]
127 async fn test_read_file_line_truncation() {
128 let long_line = "x".repeat(2500);
129 let workspace = TestWorkspace::new().file("test_truncation.txt", format!("short\n{long_line}"));
130
131 let result = read_file_contents(ReadFileArgs {
132 file_path: workspace.path_string("test_truncation.txt"),
133 offset: None,
134 limit: None,
135 })
136 .await
137 .unwrap();
138
139 let lines: Vec<&str> = result.content.split('\n').collect();
140 assert_eq!(lines.len(), 2);
141 assert!(lines[0].contains("short"));
142 assert!(!lines[0].contains("truncated"));
143 assert!(lines[1].contains("truncated"));
144 assert!(lines[1].contains("2500 bytes total"));
145 }
146
147 #[tokio::test]
148 async fn test_read_file_multibyte_line_truncation() {
149 let long_line = "─".repeat(1000);
150 let workspace = TestWorkspace::new().file("test_multibyte_truncation.txt", long_line);
151
152 let result = read_file_contents(ReadFileArgs {
153 file_path: workspace.path_string("test_multibyte_truncation.txt"),
154 offset: None,
155 limit: None,
156 })
157 .await
158 .unwrap();
159
160 assert_eq!(result.content, format!(" 1\t{}... [truncated, 3000 bytes total]", "─".repeat(666)));
161 }
162
163 #[tokio::test]
164 async fn test_read_file_stops_at_byte_budget_and_continues_from_next_offset() {
165 let workspace = TestWorkspace::new().file("test_byte_budget.txt", vec!["x".repeat(100); 1000].join("\n"));
166 let file_path = workspace.path_string("test_byte_budget.txt");
167
168 let first =
169 read_file_contents(ReadFileArgs { file_path: file_path.clone(), offset: None, limit: None }).await.unwrap();
170 assert!(first.lines_shown < first.total_lines);
171 let next_offset = first.next_offset.expect("lines should remain");
172 assert_eq!(next_offset, 1 + first.lines_shown);
173
174 let second =
175 read_file_contents(ReadFileArgs { file_path, offset: Some(next_offset), limit: None }).await.unwrap();
176 assert!(second.content.starts_with(&format!("{next_offset:5}\t")));
177 }
178
179 #[tokio::test]
180 async fn test_read_file_invalid_offset() {
181 let workspace = TestWorkspace::new().file("test_invalid_offset.txt", "line 1");
182
183 let result = read_file_contents(ReadFileArgs {
184 file_path: workspace.path_string("test_invalid_offset.txt"),
185 offset: Some(0),
186 limit: None,
187 })
188 .await;
189
190 assert!(matches!(result, Err(FileError::InvalidOffset { .. })));
191 }
192
193 #[tokio::test]
194 async fn test_read_file_nonexistent() {
195 let workspace = TestWorkspace::new();
196
197 let result = read_file_contents(ReadFileArgs {
198 file_path: workspace.path_string("nonexistent_file_xyz123.txt"),
199 offset: None,
200 limit: None,
201 })
202 .await;
203
204 assert!(matches!(result, Err(FileError::NotFound { .. })));
205 }
206
207 #[test]
208 fn test_read_file_args_accepts_snake_case_file_path() {
209 let args: ReadFileArgs = serde_json::from_value(serde_json::json!({
210 "file_path": "/tmp/test.txt",
211 "offset": 1,
212 "limit": 10
213 }))
214 .unwrap();
215
216 assert_eq!(args.file_path, "/tmp/test.txt");
217 assert_eq!(args.offset, Some(1));
218 assert_eq!(args.limit, Some(10));
219 }
220}