use std::path::Path;
use std::path::PathBuf;
use crate::tools::registry::{ToolContext, ToolError, ToolExecution};
use crate::{tools::ToolDefinition, tools::ToolOutput, tools::ToolUseRequest, utils};
const NAME: &str = "read_file_range";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReadFileRangeInput {
path: String,
start_line: u32,
end_line: Option<u32>,
}
pub fn exec(path: &Path, root: &Path, start_line: u32, end_line: Option<u32>) -> ToolOutput {
if !super::path::is_within_root(path, root) {
return ToolOutput::failed(
"read_file_range",
format!("path escapes workspace root: {}", path.display()),
);
}
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
return ToolOutput::failed("read_file_range", format!("read failed: {e}"));
}
};
let start = start_line.max(1) as usize;
let end = end_line.map(|e| e.max(start as u32) as usize).unwrap_or(start + 20);
let lines: Vec<String> = content
.lines()
.enumerate()
.skip(start.saturating_sub(1))
.take(end.saturating_sub(start) + 1)
.map(|(i, line)| format!("{}: {}", i + 1, utils::truncate_line(line)))
.collect();
if lines.is_empty() {
return ToolOutput::failed("read_file_range", format!("no lines in range {start}-{end}"));
}
ToolOutput::ok("read_file_range", lines)
}
pub fn definition() -> ToolDefinition {
ToolDefinition::new(
NAME,
r#"read_file_range
Read a 1-indexed line range from a file under the workspace root.
Use this to inspect file contents after finding a path with find_files or search_text.
Prefer targeted ranges over reading entire large files. Paths are contained to the root;
escapes are rejected. Output is capped at 65536 bytes; long lines truncate at 512 chars."#,
serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path relative to the workspace root." },
"start_line": { "type": "integer" },
"end_line": { "type": "integer" }
},
"required": ["path", "start_line"]
}),
)
}
pub fn parse_arguments(arguments: &str) -> Result<ReadFileRangeInput, ToolError> {
let args = serde_json::from_str::<serde_json::Value>(arguments).unwrap_or(serde_json::Value::Null);
Ok(ReadFileRangeInput {
path: args
.get("path")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
start_line: args.get("start_line").and_then(|value| value.as_u64()).unwrap_or(1) as u32,
end_line: args
.get("end_line")
.and_then(|value| value.as_u64())
.map(|line| line as u32),
})
}
pub fn execute_request(request: &ToolUseRequest, ctx: &ToolContext<'_>) -> ToolExecution {
match parse_arguments(&request.arguments) {
Ok(input) => ToolExecution::output(exec_input(&input, ctx.root)),
Err(error) => ToolExecution::output(ToolOutput::failed(NAME, error.to_string())),
}
}
fn exec_input(input: &ReadFileRangeInput, root: &Path) -> ToolOutput {
let path = super::path::resolve_within_root(root, &input.path).unwrap_or_else(|_| PathBuf::from(&input.path));
exec(&path, root, input.start_line, input.end_line)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{app::ToolStatus, tools};
#[test]
fn read_file_range_reads_specific_lines() {
let root = std::env::current_dir().unwrap();
let path = root.join("Cargo.toml");
let output = exec(&path, &root, 1, Some(3));
assert_eq!(output.status, ToolStatus::Ok);
assert_eq!(output.display.lines.len(), 3);
assert!(output.display.lines[0].starts_with("1:"));
}
#[test]
fn read_file_range_outside_root_fails() {
let root = std::env::current_dir().unwrap();
let outside = root.parent().unwrap().join("some_file.txt");
let output = exec(&outside, &root, 1, Some(10));
assert_eq!(output.status, ToolStatus::Failed);
assert!(
output
.error
.as_ref()
.is_some_and(|e| e.contains("escapes workspace root"))
);
}
#[test]
fn read_file_range_nonexistent_file_fails() {
let root = std::env::current_dir().unwrap();
let path = root.join("nonexistent_file_zzz.rs");
let output = exec(&path, &root, 1, Some(10));
assert_eq!(output.status, ToolStatus::Failed);
}
#[test]
fn parse_arguments_reads_required_and_optional_fields() {
let input = parse_arguments(r#"{"path":"Cargo.toml","start_line":2,"end_line":4}"#).expect("parse");
assert_eq!(input.path, "Cargo.toml");
assert_eq!(input.start_line, 2);
assert_eq!(input.end_line, Some(4));
}
#[test]
fn parse_arguments_malformed_json_uses_safe_defaults() {
let input = parse_arguments("not valid json").expect("parse");
assert_eq!(input.path, "");
assert_eq!(input.start_line, 1);
assert_eq!(input.end_line, None);
}
#[test]
fn registry_execute_reads_range() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(dir.path().join("alpha.txt"), "a\nb\nc\n").expect("write");
let request = ToolUseRequest::new(
NAME.to_string(),
serde_json::json!({"path":"alpha.txt","start_line":2,"end_line":3}).to_string(),
"call_1".to_string(),
);
let output = tools::registry::execute(&request, &tools::registry::ToolContext::new(dir.path())).output;
assert_eq!(output.status, ToolStatus::Ok);
assert_eq!(output.display.lines, vec!["2: b", "3: c"]);
}
}