Skip to content

Commit d07780b

Browse files
committed
feat: add read_file_lines
1 parent 21a7542 commit d07780b

File tree

5 files changed

+142
-42
lines changed

5 files changed

+142
-42
lines changed

src/fs_service.rs

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,59 +1297,67 @@ impl FileSystemService {
12971297
Ok(result)
12981298
}
12991299

1300-
/// Reads specific lines from a text file starting at the given offset.
1300+
/// Reads lines from a text file starting at the specified offset (0-based), preserving line endings.
13011301
/// Args:
13021302
/// path: Path to the file
1303-
/// offset: Line offset (0-based, starts at first line)
1304-
/// limit: Maximum number of lines to read (None for all remaining)
1305-
/// Returns a vector of lines or an error if the path is invalid or file cannot be read.
1303+
/// offset: Number of lines to skip (0-based)
1304+
/// limit: Optional maximum number of lines to read
1305+
/// Returns a String containing the selected lines with original line endings or an error if the path is invalid or file cannot be read.
13061306
pub async fn read_file_lines(
13071307
&self,
13081308
path: &Path,
13091309
offset: usize,
13101310
limit: Option<usize>,
1311-
) -> ServiceResult<Vec<String>> {
1311+
) -> ServiceResult<String> {
13121312
// Validate file path against allowed directories
13131313
let allowed_directories = self.allowed_directories().await;
13141314
let valid_path = self.validate_path(path, allowed_directories)?;
13151315

1316-
// Open file asynchronously and create a BufReader
1316+
// Open file and get metadata before moving into BufReader
13171317
let file = File::open(&valid_path).await?;
1318-
let reader = BufReader::new(file);
1319-
let mut lines = Vec::new();
1320-
1321-
// Read lines asynchronously
1322-
let mut line_iter = reader.lines();
1323-
let mut current_line = 0;
1324-
1325-
// Skip lines until the offset is reached
1326-
while current_line < offset {
1327-
match line_iter.next_line().await? {
1328-
Some(_) => current_line += 1,
1329-
None => return Ok(lines), // EOF before reaching offset
1318+
let file_size = file.metadata().await?.len();
1319+
let mut reader = BufReader::new(file);
1320+
1321+
// If file is empty or limit is 0, return empty string
1322+
if file_size == 0 || limit == Some(0) {
1323+
return Ok(String::new());
1324+
}
1325+
1326+
// Skip offset lines (0-based indexing)
1327+
let mut buffer = Vec::new();
1328+
for _ in 0..offset {
1329+
buffer.clear();
1330+
if reader.read_until(b'\n', &mut buffer).await? == 0 {
1331+
return Ok(String::new()); // EOF before offset
13301332
}
13311333
}
13321334

1333-
// Read lines up to the limit (or all remaining if limit is None)
1335+
// Read lines up to limit (or all remaining if limit is None)
1336+
let mut result = String::with_capacity(limit.unwrap_or(100) * 100); // Estimate capacity
13341337
match limit {
13351338
Some(max_lines) => {
1336-
let remaining = max_lines; // No need to add offset, track lines read
1337-
for _ in 0..remaining {
1338-
match line_iter.next_line().await? {
1339-
Some(line) => lines.push(line),
1340-
None => break, // Reached EOF
1339+
for _ in 0..max_lines {
1340+
buffer.clear();
1341+
let bytes_read = reader.read_until(b'\n', &mut buffer).await?;
1342+
if bytes_read == 0 {
1343+
break; // Reached EOF
13411344
}
1345+
result.push_str(&String::from_utf8_lossy(&buffer));
13421346
}
13431347
}
13441348
None => {
1345-
// Read all remaining lines
1346-
while let Some(line) = line_iter.next_line().await? {
1347-
lines.push(line);
1349+
loop {
1350+
buffer.clear();
1351+
let bytes_read = reader.read_until(b'\n', &mut buffer).await?;
1352+
if bytes_read == 0 {
1353+
break; // Reached EOF
1354+
}
1355+
result.push_str(&String::from_utf8_lossy(&buffer));
13481356
}
13491357
}
13501358
}
13511359

1352-
Ok(lines)
1360+
Ok(result)
13531361
}
13541362

13551363
pub async fn calculate_directory_size(&self, root_path: &Path) -> ServiceResult<u64> {

src/handler.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,9 @@ impl ServerHandler for FileSystemHandler {
254254
FileSystemTools::TailFileTool(params) => {
255255
TailFileTool::run_tool(params, &self.fs_service).await
256256
}
257+
FileSystemTools::ReadFileLinesTool(params) => {
258+
ReadFileLinesTool::run_tool(params, &self.fs_service).await
259+
}
257260
}
258261
}
259262
}

src/tools.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub use list_allowed_directories::ListAllowedDirectoriesTool;
3030
pub use list_directory::ListDirectoryTool;
3131
pub use list_directory_with_sizes::ListDirectoryWithSizesTool;
3232
pub use move_file::MoveFileTool;
33+
pub use read_file_lines::ReadFileLinesTool;
3334
pub use read_media_file::ReadMediaFileTool;
3435
pub use read_multiple_media_files::ReadMultipleMediaFilesTool;
3536
pub use read_multiple_text_files::ReadMultipleTextFilesTool;
@@ -64,7 +65,8 @@ tool_box!(
6465
ReadMediaFileTool,
6566
ReadMultipleMediaFilesTool,
6667
HeadFileTool,
67-
TailFileTool
68+
TailFileTool,
69+
ReadFileLinesTool
6870
]
6971
);
7072

@@ -92,6 +94,7 @@ impl FileSystemTools {
9294
| FileSystemTools::HeadFileTool(_)
9395
| FileSystemTools::ReadMultipleMediaFilesTool(_)
9496
| FileSystemTools::TailFileTool(_)
97+
| FileSystemTools::ReadFileLinesTool(_)
9598
| FileSystemTools::SearchFilesTool(_) => false,
9699
}
97100
}

src/tools/read_file_lines.rs

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,51 @@
1-
// read_file_lines
1+
use std::path::Path;
2+
3+
use rust_mcp_sdk::{
4+
macros::{JsonSchema, mcp_tool},
5+
schema::{CallToolResult, TextContent, schema_utils::CallToolError},
6+
};
7+
8+
use crate::fs_service::FileSystemService;
9+
10+
// head_file
11+
#[mcp_tool(
12+
name = "read_file_lines",
13+
title="Read File Lines",
14+
description = concat!("Reads lines from a text file starting at a specified line offset (0-based).",
15+
"This function skips the first 'offset' lines and then reads up to 'limit' lines if specified, or reads until the end of the file otherwise.",
16+
"It's useful for partial reads, pagination, or previewing sections of large text files.",
17+
"Only works within allowed directories."),
18+
destructive_hint = false,
19+
idempotent_hint = false,
20+
open_world_hint = false,
21+
read_only_hint = true
22+
)]
23+
#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, JsonSchema)]
24+
pub struct ReadFileLinesTool {
25+
/// The path of the file to get information for.
26+
pub path: String,
27+
/// Number of lines to skip from the start (0-based).
28+
pub offset: u64,
29+
/// Optional maximum number of lines to read after the offset.
30+
pub limit: Option<u64>,
31+
}
32+
33+
impl ReadFileLinesTool {
34+
pub async fn run_tool(
35+
params: Self,
36+
context: &FileSystemService,
37+
) -> std::result::Result<CallToolResult, CallToolError> {
38+
let result = context
39+
.read_file_lines(
40+
&Path::new(&params.path),
41+
params.offset as usize,
42+
params.limit.map(|v| v as usize),
43+
)
44+
.await
45+
.map_err(CallToolError::new)?;
46+
47+
Ok(CallToolResult::text_content(vec![TextContent::from(
48+
result,
49+
)]))
50+
}
51+
}

tests/test_fs_service.rs

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,7 +1330,7 @@ async fn test_read_file_lines_normal() {
13301330
.read_file_lines(&file_path, 1, Some(2))
13311331
.await
13321332
.unwrap();
1333-
assert_eq!(result, vec!["line2", "line3"]);
1333+
assert_eq!(result, "line2\nline3\n"); // No trailing newline
13341334
}
13351335

13361336
#[tokio::test]
@@ -1342,7 +1342,7 @@ async fn test_read_file_lines_empty_file() {
13421342
.read_file_lines(&file_path, 0, Some(5))
13431343
.await
13441344
.unwrap();
1345-
assert_eq!(result, Vec::<String>::new());
1345+
assert_eq!(result, "");
13461346
}
13471347

13481348
#[tokio::test]
@@ -1354,7 +1354,7 @@ async fn test_read_file_lines_offset_beyond_file() {
13541354
.read_file_lines(&file_path, 5, Some(3))
13551355
.await
13561356
.unwrap();
1357-
assert_eq!(result, Vec::<String>::new());
1357+
assert_eq!(result, "");
13581358
}
13591359

13601360
#[tokio::test]
@@ -1368,7 +1368,7 @@ async fn test_read_file_lines_no_limit() {
13681368
.await;
13691369

13701370
let result = service.read_file_lines(&file_path, 2, None).await.unwrap();
1371-
assert_eq!(result, vec!["line3", "line4"]);
1371+
assert_eq!(result, "line3\nline4"); // No trailing newline
13721372
}
13731373

13741374
#[tokio::test]
@@ -1381,29 +1381,65 @@ async fn test_read_file_lines_limit_zero() {
13811381
.read_file_lines(&file_path, 1, Some(0))
13821382
.await
13831383
.unwrap();
1384-
assert_eq!(result, Vec::<String>::new());
1384+
assert_eq!(result, "");
13851385
}
13861386

13871387
#[tokio::test]
1388-
async fn test_read_file_lines_invalid_path() {
1388+
async fn test_read_file_lines_exact_file_length() {
13891389
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
1390-
let invalid_path = temp_dir.join("dir2/test.txt"); // Outside allowed_dirs
1390+
let file_path =
1391+
create_test_file(&temp_dir, "dir1/test.txt", vec!["line1", "line2", "line3"]).await;
13911392

1392-
let result = service.read_file_lines(&invalid_path, 0, Some(3)).await;
1393-
assert!(result.is_err(), "Expected error for invalid path");
1393+
let result = service
1394+
.read_file_lines(&file_path, 0, Some(3))
1395+
.await
1396+
.unwrap();
1397+
assert_eq!(result, "line1\nline2\nline3"); // No trailing newline
13941398
}
13951399

13961400
#[tokio::test]
1397-
async fn test_read_file_lines_exact_file_length() {
1401+
async fn test_read_file_lines_no_newline_at_end() {
1402+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
1403+
let file_path = create_temp_file(
1404+
&temp_dir.join("dir1"),
1405+
"test.txt",
1406+
"line1\nline2\nline3", // No newline at end
1407+
);
1408+
1409+
let result = service
1410+
.read_file_lines(&file_path, 1, Some(2))
1411+
.await
1412+
.unwrap();
1413+
assert_eq!(result, "line2\nline3"); // No trailing newline
1414+
}
1415+
1416+
#[tokio::test]
1417+
async fn test_read_file_lines_windows_line_endings() {
13981418
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
13991419
let file_path =
14001420
create_test_file(&temp_dir, "dir1/test.txt", vec!["line1", "line2", "line3"]).await;
14011421

1422+
// Override to use \r\n explicitly
1423+
let file_path = create_temp_file(
1424+
&temp_dir.join("dir1"),
1425+
"test.txt",
1426+
"line1\r\nline2\r\nline3",
1427+
);
1428+
14021429
let result = service
1403-
.read_file_lines(&file_path, 0, Some(3))
1430+
.read_file_lines(&file_path, 1, Some(2))
14041431
.await
14051432
.unwrap();
1406-
assert_eq!(result, vec!["line1", "line2", "line3"]);
1433+
assert_eq!(result, "line2\r\nline3"); // No trailing newline
1434+
}
1435+
1436+
#[tokio::test]
1437+
async fn test_read_file_lines_invalid_path() {
1438+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
1439+
let invalid_path = temp_dir.join("dir2/test.txt"); // Outside allowed_dirs
1440+
1441+
let result = service.read_file_lines(&invalid_path, 0, Some(3)).await;
1442+
assert!(result.is_err(), "Expected error for invalid path");
14071443
}
14081444

14091445
#[test]

0 commit comments

Comments
 (0)