Skip to main content

mcp_servers/coding/tools/read_file/
mod.rs

1use 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    /// Path to the file to read (must be an existing file)
13    #[serde(alias = "file_path")]
14    pub file_path: String,
15    /// Starting line number to read from (1-indexed). If not specified, starts from line 1.
16    pub offset: Option<usize>,
17    /// Maximum number of lines to read.
18    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    // Default offset to 1 if not provided
40    let offset = args.offset.unwrap_or(1);
41
42    // Validate offset is 1-indexed
43    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}