Skip to content

Commit 257315b

Browse files
0xErgodAbderraouf Belalia
andauthored
feat: add optional line numbering to read_text_file tool (#61)
* feat: add optional line numbering to read_text_file tool Implements #60 - Add line numbering flag for read_text_file Changes: - Added `with_line_numbers` optional parameter to ReadTextFile struct - Updated read_text_file service method to format output with line numbers - Line numbers are right-aligned (6 digits) with pipe separator format - Uses 1-based indexing for line numbers - Maintains backward compatibility (defaults to false) - Added comprehensive unit tests for various scenarios - Updated CHANGELOG.md with feature description This feature enables AI agents to obtain file content with line numbers in a single tool invocation, improving efficiency for code modification tasks that require precise line-based targeting. [agent commit] * style: fix formatting and clippy warnings [agent commit] * docs: update capabilities with line numbering parameter [agent commit] * refactor: address PR review feedback for line numbering feature - Remove manual CHANGELOG.md entry (auto-generated at release) - Revert docs/capabilities.md changes (auto-generated via mcp-discovery) - Improve documentation clarity for with_line_numbers parameter format - Add test for Windows line endings (\r\n) - Add edge case tests for newline-only content Addresses review comments on PR #61 [agent commit] * fix: correct fs_service.rs module structure after rebase [agent commit] --------- Co-authored-by: Abderraouf Belalia <abderraoufbelalia@symplectic.link>
1 parent a959b4c commit 257315b

5 files changed

Lines changed: 137 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,5 @@
11
# Changelog
22

3-
## [0.3.8](https://github.com/rust-mcp-stack/rust-mcp-filesystem/compare/v0.3.7...v0.3.8) (2025-10-31)
4-
5-
6-
### ⚙️ Miscellaneous Chores
7-
8-
* Release 0.3.8 ([38f2919](https://github.com/rust-mcp-stack/rust-mcp-filesystem/commit/38f29190b167bc36de4f33edae3bd63f61567aa7))
9-
* Release 0.3.8 ([9030cbb](https://github.com/rust-mcp-stack/rust-mcp-filesystem/commit/9030cbbabca1bca1992a93a63a9d01b367e0d83e))
10-
11-
## [0.3.7](https://github.com/rust-mcp-stack/rust-mcp-filesystem/compare/v0.3.6...v0.3.7) (2025-10-31)
12-
13-
14-
### 🚀 Features
15-
16-
* Update document and installers with npm support ([#68](https://github.com/rust-mcp-stack/rust-mcp-filesystem/issues/68)) ([5b78516](https://github.com/rust-mcp-stack/rust-mcp-filesystem/commit/5b785169e5522cf28097f4b9781462ddfb73aeb2))
17-
18-
19-
### 🐛 Bug Fixes
20-
21-
* Ignore client root change notification when it is not enabled by server ([#65](https://github.com/rust-mcp-stack/rust-mcp-filesystem/issues/65)) ([3ca810a](https://github.com/rust-mcp-stack/rust-mcp-filesystem/commit/3ca810ade142d91d14d1d138e9cc8f5680b35ec5))
22-
233
## [0.3.6](https://github.com/rust-mcp-stack/rust-mcp-filesystem/compare/v0.3.5...v0.3.6) (2025-10-15)
244

255

src/fs_service/io/read.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,25 @@ use tokio::{
2020
const MAX_CONCURRENT_FILE_READ: usize = 5;
2121

2222
impl FileSystemService {
23-
pub async fn read_text_file(&self, file_path: &Path) -> ServiceResult<String> {
23+
pub async fn read_text_file(
24+
&self,
25+
file_path: &Path,
26+
with_line_numbers: bool,
27+
) -> ServiceResult<String> {
2428
let allowed_directories = self.allowed_directories().await;
2529
let valid_path = self.validate_path(file_path, allowed_directories)?;
2630
let content = tokio::fs::read_to_string(valid_path).await?;
27-
Ok(content)
31+
32+
if with_line_numbers {
33+
Ok(content
34+
.lines()
35+
.enumerate()
36+
.map(|(i, line)| format!("{:>6} | {}", i + 1, line))
37+
.collect::<Vec<_>>()
38+
.join("\n"))
39+
} else {
40+
Ok(content)
41+
}
2842
}
2943

3044
/// Reads the first n lines from a text file, preserving line endings.

src/tools/read_multiple_text_files.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ impl ReadMultipleTextFiles {
3535
.map(|path| async move {
3636
{
3737
let content = context
38-
.read_text_file(Path::new(&path))
38+
.read_text_file(Path::new(&path), false)
3939
.await
4040
.map_err(CallToolError::new);
4141

src/tools/read_text_file.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ use crate::fs_service::FileSystemService;
1212
description = concat!("Read the complete contents of a text file from the file system as text. ",
1313
"Handles various text encodings and provides detailed error messages if the ",
1414
"file cannot be read. Use this tool when you need to examine the contents of ",
15-
"a single file. Only works within allowed directories."),
15+
"a single file. Optionally include line numbers for precise code targeting. ",
16+
"Only works within allowed directories."),
1617
destructive_hint = false,
1718
idempotent_hint = false,
1819
open_world_hint = false,
@@ -22,6 +23,11 @@ use crate::fs_service::FileSystemService;
2223
pub struct ReadTextFile {
2324
/// The path of the file to read.
2425
pub path: String,
26+
/// Optional: Include line numbers in output (default: false).
27+
/// When enabled, each line is prefixed with a right-aligned, 1-based line number
28+
/// Followed by a space, a vertical bar (`|`), and another space in the format: ` 123 | <original line content>`
29+
#[serde(default)]
30+
pub with_line_numbers: Option<bool>,
2531
}
2632

2733
impl ReadTextFile {
@@ -30,7 +36,10 @@ impl ReadTextFile {
3036
context: &FileSystemService,
3137
) -> std::result::Result<CallToolResult, CallToolError> {
3238
let content = context
33-
.read_text_file(Path::new(&params.path))
39+
.read_text_file(
40+
Path::new(&params.path),
41+
params.with_line_numbers.unwrap_or(false),
42+
)
3443
.await
3544
.map_err(CallToolError::new)?;
3645

tests/test_fs_service.rs

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,118 @@ async fn test_unzip_file_non_existent() {
230230
async fn test_read_file() {
231231
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
232232
let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "test.txt", "content");
233-
let content = service.read_text_file(&file_path).await.unwrap();
233+
let content = service.read_text_file(&file_path, false).await.unwrap();
234234
assert_eq!(content, "content");
235235
}
236236

237+
#[tokio::test]
238+
async fn test_read_text_file_with_line_numbers() {
239+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
240+
let file_path = create_temp_file(
241+
temp_dir.join("dir1").as_path(),
242+
"test.txt",
243+
"line1\nline2\nline3",
244+
);
245+
let content = service.read_text_file(&file_path, true).await.unwrap();
246+
assert_eq!(content, " 1 | line1\n 2 | line2\n 3 | line3");
247+
}
248+
249+
#[tokio::test]
250+
async fn test_read_text_file_without_line_numbers() {
251+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
252+
let file_path = create_temp_file(
253+
temp_dir.join("dir1").as_path(),
254+
"test.txt",
255+
"line1\nline2\nline3",
256+
);
257+
let content = service.read_text_file(&file_path, false).await.unwrap();
258+
assert_eq!(content, "line1\nline2\nline3");
259+
}
260+
261+
#[tokio::test]
262+
async fn test_read_text_file_with_line_numbers_empty_file() {
263+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
264+
let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "empty.txt", "");
265+
let content = service.read_text_file(&file_path, true).await.unwrap();
266+
assert_eq!(content, "");
267+
}
268+
269+
#[tokio::test]
270+
async fn test_read_text_file_with_line_numbers_single_line() {
271+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
272+
let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "single.txt", "single line");
273+
let content = service.read_text_file(&file_path, true).await.unwrap();
274+
assert_eq!(content, " 1 | single line");
275+
}
276+
277+
#[tokio::test]
278+
async fn test_read_text_file_with_line_numbers_no_trailing_newline() {
279+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
280+
let file_path = create_temp_file(
281+
temp_dir.join("dir1").as_path(),
282+
"no_newline.txt",
283+
"line1\nline2",
284+
);
285+
let content = service.read_text_file(&file_path, true).await.unwrap();
286+
assert_eq!(content, " 1 | line1\n 2 | line2");
287+
}
288+
289+
#[tokio::test]
290+
async fn test_read_text_file_with_line_numbers_large_file() {
291+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
292+
// Create a file with more than 999 lines to test padding
293+
let mut lines = Vec::new();
294+
for i in 1..=1000 {
295+
lines.push(format!("line{i}"));
296+
}
297+
let file_content = lines.join("\n");
298+
let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "large.txt", &file_content);
299+
let content = service.read_text_file(&file_path, true).await.unwrap();
300+
301+
// Check first line
302+
assert!(content.starts_with(" 1 | line1\n"));
303+
// Check line 999
304+
assert!(content.contains(" 999 | line999\n"));
305+
// Check line 1000 (6 digits with right padding)
306+
assert!(content.contains(" 1000 | line1000"));
307+
}
308+
309+
#[tokio::test]
310+
async fn test_read_text_file_with_line_numbers_windows_line_endings() {
311+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
312+
let file_path = create_temp_file(
313+
temp_dir.join("dir1").as_path(),
314+
"windows.txt",
315+
"line1\r\nline2\r\nline3",
316+
);
317+
let content = service.read_text_file(&file_path, true).await.unwrap();
318+
assert_eq!(content, " 1 | line1\n 2 | line2\n 3 | line3");
319+
}
320+
321+
#[tokio::test]
322+
async fn test_read_text_file_with_line_numbers_single_newline_unix() {
323+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
324+
// A file with just "\n" is treated by lines() as having one empty line before the newline
325+
// To get two empty lines, we need "\n\n"
326+
let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "newline_unix.txt", "\n\n");
327+
let content = service.read_text_file(&file_path, true).await.unwrap();
328+
assert_eq!(content, " 1 | \n 2 | ");
329+
}
330+
331+
#[tokio::test]
332+
async fn test_read_text_file_with_line_numbers_single_newline_windows() {
333+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
334+
// A file with just "\r\n" is treated by lines() as having one empty line
335+
// To get two empty lines, we need "\r\n\r\n"
336+
let file_path = create_temp_file(
337+
temp_dir.join("dir1").as_path(),
338+
"newline_windows.txt",
339+
"\r\n\r\n",
340+
);
341+
let content = service.read_text_file(&file_path, true).await.unwrap();
342+
assert_eq!(content, " 1 | \n 2 | ");
343+
}
344+
237345
#[tokio::test]
238346
async fn test_create_directory() {
239347
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);

0 commit comments

Comments
 (0)