use std::collections::VecDeque;
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use anyhow::{Context, Result};
use async_trait::async_trait;
use futures::stream::{self, StreamExt};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Value, json};
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::Semaphore;
use vtcode_commons::diff_paths::looks_like_diff_content;
use crate::tools::file_ops::read_byte_range;
use crate::tools::error_helpers::deserialize_tool_args;
use crate::tools::traits::Tool;
use crate::utils::serde_helpers::{deserialize_maybe_quoted, deserialize_opt_maybe_quoted};
pub struct ReadFileHandler;
const MAX_LINE_LENGTH: usize = 500;
const TAB_WIDTH: usize = 4;
const COMMENT_PREFIXES: &[&str] = &["#", "//", "--"];
const MIN_BATCH_LIMIT: usize = 200;
const DEFAULT_MAX_CONCURRENCY: usize = 8;
const BATCH_CONDENSED_THRESHOLD: usize = 100;
const DEFAULT_BYTE_CHUNK_SIZE: usize = 8192;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ReadFileOutcome {
pub content: String,
pub lines_read: usize,
pub has_more: bool,
pub capped_by_limit: bool,
pub applied_limit: usize,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct ReadFileArgs {
#[serde(alias = "path", alias = "filepath", alias = "target_path", alias = "file")]
pub file_path: String,
#[serde(default = "defaults::offset", deserialize_with = "deserialize_maybe_quoted")]
pub offset: usize,
#[serde(default = "defaults::limit", deserialize_with = "deserialize_maybe_quoted")]
pub limit: usize,
#[serde(default, deserialize_with = "deserialize_read_mode")]
pub mode: ReadMode,
#[serde(default, deserialize_with = "deserialize_indentation")]
pub indentation: Option<IndentationArgs>,
#[serde(default, deserialize_with = "deserialize_opt_maybe_quoted")]
pub max_tokens: Option<usize>,
#[serde(default = "defaults::condense", deserialize_with = "deserialize_maybe_quoted")]
pub condense: bool,
#[serde(default, deserialize_with = "deserialize_opt_maybe_quoted")]
pub offset_bytes: Option<u64>,
#[serde(default, alias = "length", deserialize_with = "deserialize_opt_maybe_quoted")]
pub page_size_bytes: Option<usize>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct BatchReadArgs {
pub reads: Vec<BatchReadRequest>,
#[serde(default = "defaults::max_concurrency")]
pub max_concurrency: usize,
#[serde(default = "defaults::ui_progress")]
pub ui_progress: bool,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct BatchReadRequest {
pub file_path: String,
#[serde(flatten)]
pub range: Option<ReadRange>,
#[serde(default)]
pub ranges: Option<Vec<ReadRange>>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
pub struct ReadRange {
#[serde(default = "defaults::offset", deserialize_with = "deserialize_maybe_quoted")]
pub offset: usize,
#[serde(default = "defaults::batch_limit", deserialize_with = "deserialize_maybe_quoted")]
pub limit: usize,
#[serde(default, deserialize_with = "deserialize_read_mode")]
pub mode: ReadMode,
#[serde(default, deserialize_with = "deserialize_indentation")]
pub indentation: Option<IndentationArgs>,
}
#[derive(Serialize, Clone, Debug)]
pub struct BatchReadResult {
pub file_path: String,
pub ranges: Vec<RangeResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Serialize, Clone, Debug)]
pub struct RangeResult {
pub offset: usize,
pub lines_read: usize,
pub condensed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub omitted_lines: Option<usize>,
pub content: String,
}
#[derive(Clone)]
pub struct BatchProgress {
pub total_files: Arc<AtomicUsize>,
pub completed_files: Arc<AtomicUsize>,
pub current_file: Arc<tokio::sync::RwLock<String>>,
pub total_bytes: Arc<AtomicU64>,
pub bytes_read: Arc<AtomicU64>,
}
impl BatchProgress {
pub fn new(total_files: usize) -> Self {
Self {
total_files: Arc::new(AtomicUsize::new(total_files)),
completed_files: Arc::new(AtomicUsize::new(0)),
current_file: Arc::new(tokio::sync::RwLock::new(String::new())),
total_bytes: Arc::new(AtomicU64::new(0)),
bytes_read: Arc::new(AtomicU64::new(0)),
}
}
pub async fn file_started(&self, file_path: &str) {
let mut current = self.current_file.write().await;
*current = file_path.to_string();
}
pub fn file_completed(&self) {
self.completed_files.fetch_add(1, Ordering::Relaxed);
}
pub fn add_bytes(&self, bytes: u64) {
self.bytes_read.fetch_add(bytes, Ordering::Relaxed);
}
pub fn progress_percent(&self) -> f64 {
let completed = self.completed_files.load(Ordering::Relaxed);
let total = self.total_files.load(Ordering::Relaxed);
if total == 0 {
100.0
} else {
(completed as f64 / total as f64) * 100.0
}
}
pub async fn status_line(&self) -> (String, String) {
let completed = self.completed_files.load(Ordering::Relaxed);
let total = self.total_files.load(Ordering::Relaxed);
let current = self.current_file.read().await;
let file_name = PathBuf::from(current.as_str())
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| current.clone());
let left = format!("Reading {}/{}: {}", completed + 1, total, file_name);
let right = format!("{:.0}%", self.progress_percent());
(left, right)
}
}
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[serde(rename_all = "snake_case")]
pub enum ReadMode {
#[default]
Slice,
Indentation,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
pub struct IndentationArgs {
#[serde(default, deserialize_with = "deserialize_opt_maybe_quoted")]
pub anchor_line: Option<usize>,
#[serde(default = "defaults::max_levels", deserialize_with = "deserialize_maybe_quoted")]
pub max_levels: usize,
#[serde(default = "defaults::include_siblings")]
pub include_siblings: bool,
#[serde(default = "defaults::include_header")]
pub include_header: bool,
#[serde(default, deserialize_with = "deserialize_opt_maybe_quoted")]
pub max_lines: Option<usize>,
}
fn deserialize_read_mode<'de, D>(deserializer: D) -> Result<ReadMode, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
match value {
Value::Null => Ok(ReadMode::Slice),
Value::String(raw) => {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("slice") {
Ok(ReadMode::Slice)
} else if trimmed.eq_ignore_ascii_case("indentation") {
Ok(ReadMode::Indentation)
} else {
Err(serde::de::Error::custom(format!("invalid read mode: {trimmed}")))
}
}
other => Err(serde::de::Error::custom(format!("invalid read mode type: {other}"))),
}
}
fn deserialize_indentation<'de, D>(deserializer: D) -> Result<Option<IndentationArgs>, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
match value {
Value::Null => Ok(None),
Value::Bool(true) => Ok(Some(IndentationArgs::default())),
Value::Bool(false) => Ok(None),
Value::String(raw) => {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("false") {
Ok(None)
} else if trimmed.eq_ignore_ascii_case("true") {
Ok(Some(IndentationArgs::default()))
} else {
Err(serde::de::Error::custom(format!("invalid indentation value: {trimmed}")))
}
}
Value::Object(_) => {
let args = IndentationArgs::deserialize(value).map_err(serde::de::Error::custom)?;
Ok(Some(args))
}
other => Err(serde::de::Error::custom(format!("invalid indentation type: {other}"))),
}
}
#[derive(Clone, Debug)]
struct LineRecord {
number: usize,
raw: String,
display: String,
indent: usize,
}
impl LineRecord {
fn trimmed(&self) -> &str {
self.raw.trim_start()
}
fn is_blank(&self) -> bool {
self.trimmed().is_empty()
}
fn is_comment(&self) -> bool {
COMMENT_PREFIXES.iter().any(|prefix| self.raw.trim().starts_with(prefix))
}
}
impl ReadFileHandler {
pub async fn handle_batch(&self, args: BatchReadArgs) -> Result<Value> {
if args.reads.is_empty() {
return Ok(json!({
"success": false,
"error": "No read requests provided"
}));
}
let progress = BatchProgress::new(args.reads.len());
let semaphore = Arc::new(Semaphore::new(args.max_concurrency.min(args.reads.len())));
let results: Vec<BatchReadResult> = stream::iter(args.reads)
.map(|req| {
let sem = semaphore.clone();
let prog = progress.clone();
async move {
match sem.acquire().await {
Ok(permit) => {
let _permit = permit;
prog.file_started(&req.file_path).await;
let result = self.read_single_batch_request(&req).await;
prog.file_completed();
result
}
Err(e) => {
prog.file_started(&req.file_path).await;
prog.file_completed();
BatchReadResult {
file_path: req.file_path,
ranges: Vec::new(),
error: Some(format!("concurrency limit exceeded: {e}")),
}
}
}
}
})
.buffer_unordered(args.max_concurrency)
.collect()
.await;
let mut content_parts = Vec::new();
let mut buf = String::new();
for result in &results {
if let Some(ref error) = result.error {
buf.clear();
let _ = write!(buf, "== {} (ERROR)\n{}", result.file_path, error);
content_parts.push(std::mem::take(&mut buf));
} else {
for range in &result.ranges {
let end_line = range.offset + range.lines_read.saturating_sub(1);
buf.clear();
let _ =
write!(buf, "== {} (L{}..L{})\n{}", result.file_path, range.offset, end_line, range.content);
content_parts.push(std::mem::take(&mut buf));
}
}
}
let all_success = results.iter().all(|r| r.error.is_none());
Ok(json!({
"success": all_success,
"content": content_parts.join("\n\n"),
"items": results,
"files_read": results.len(),
"files_succeeded": results.iter().filter(|r| r.error.is_none()).count(),
"no_spool": true
}))
}
async fn read_single_batch_request(&self, req: &BatchReadRequest) -> BatchReadResult {
let path = PathBuf::from(&req.file_path);
if !path.is_absolute() {
return BatchReadResult {
file_path: req.file_path.clone(),
ranges: vec![],
error: Some("file_path must be an absolute path".to_string()),
};
}
let ranges_to_read: Vec<ReadRange> = if let Some(ref ranges) = req.ranges {
ranges.clone()
} else if let Some(ref range) = req.range {
vec![range.clone()]
} else {
vec![ReadRange::default()]
};
let mut range_results = Vec::new();
for range in ranges_to_read {
match self.read_range(&path, &range).await {
Ok(result) => range_results.push(result),
Err(e) => {
return BatchReadResult {
file_path: req.file_path.clone(),
ranges: range_results,
error: Some(e.to_string()),
};
}
}
}
BatchReadResult {
file_path: req.file_path.clone(),
ranges: range_results,
error: None,
}
}
async fn read_range(&self, path: &Path, range: &ReadRange) -> Result<RangeResult> {
let offset = range.offset.max(1);
let limit = range.limit.max(1);
let mut collected = match range.mode {
ReadMode::Slice => slice::read(path, offset, limit).await?.lines,
ReadMode::Indentation => {
let indentation = range.indentation.clone().unwrap_or_default();
indentation::read_block(path, offset, limit, indentation).await?
}
};
let original_len = collected.len();
let is_paginated = offset > 1;
let (condensed, omitted) = if is_paginated {
(false, 0)
} else {
condense_for_batch(&mut collected)
};
Ok(RangeResult {
offset,
lines_read: original_len,
condensed,
omitted_lines: (omitted > 0).then_some(omitted),
content: collected.join("\n"),
})
}
fn clamp_to_absolute_cap(requested: usize, absolute_max: usize) -> (usize, bool) {
let capped = requested > absolute_max;
(requested.min(absolute_max), capped)
}
pub(crate) async fn handle_detailed(&self, args: ReadFileArgs) -> Result<ReadFileOutcome> {
let ReadFileArgs {
file_path,
offset,
limit,
mode,
indentation,
max_tokens,
condense,
offset_bytes,
page_size_bytes,
} = args;
let path = PathBuf::from(&file_path);
anyhow::ensure!(path.is_absolute(), "file_path must be an absolute path");
if offset_bytes.is_some() || page_size_bytes.is_some() {
let byte_offset = offset_bytes.unwrap_or(0);
let byte_length = page_size_bytes.unwrap_or(DEFAULT_BYTE_CHUNK_SIZE);
anyhow::ensure!(byte_length > 0, "page_size_bytes must be greater than 0");
return self.read_byte_range(&path, byte_offset, byte_length).await;
}
anyhow::ensure!(offset > 0, "offset must be a 1-indexed line number");
anyhow::ensure!(limit > 0, "limit must be greater than zero");
let absolute_max = crate::tools::read_limits::absolute_line_cap();
let effective_limit = if matches!(mode, ReadMode::Slice) && max_tokens.is_none() && limit < MIN_BATCH_LIMIT {
MIN_BATCH_LIMIT
} else {
limit
};
let (applied_limit, capped_by_limit) = Self::clamp_to_absolute_cap(effective_limit, absolute_max);
let (mut collected, has_more) = match mode {
ReadMode::Slice => {
let result = slice::read(&path, offset, applied_limit).await?;
(result.lines, result.has_more)
}
ReadMode::Indentation => {
let indentation = indentation.unwrap_or_default();
(indentation::read_block(&path, offset, applied_limit, indentation).await?, false)
}
};
let lines_read = collected.len();
if condense {
if offset <= 1 {
condense_collected_lines(&mut collected);
}
}
Ok(ReadFileOutcome {
content: collected.join("\n"),
lines_read,
has_more,
capped_by_limit,
applied_limit,
})
}
async fn read_byte_range(
&self,
file_path: &Path,
offset_bytes: u64,
page_size_bytes: usize,
) -> Result<ReadFileOutcome> {
let result = read_byte_range(file_path, offset_bytes, page_size_bytes, true).await?;
Ok(ReadFileOutcome {
content: result.content,
lines_read: result.lines_read,
has_more: result.has_more,
capped_by_limit: false,
applied_limit: 0,
})
}
pub async fn handle(&self, args: ReadFileArgs) -> Result<String> {
Ok(self.handle_detailed(args).await?.content)
}
}
#[async_trait]
impl Tool for ReadFileHandler {
async fn execute(&self, args: Value) -> Result<Value> {
if args.get("reads").is_some() {
let batch_args: BatchReadArgs = deserialize_tool_args(&args, "read_file")?;
return self.handle_batch(batch_args).await;
}
let args: ReadFileArgs = deserialize_tool_args(&args, "read_file")?;
let file_path = args.file_path.clone();
let outcome = self.handle_detailed(args).await?;
let mut response = json!({
"content": outcome.content,
"file_path": file_path,
"path": file_path,
"success": true,
"no_spool": true
});
if outcome.has_more {
response["has_more"] = json!(true);
}
Ok(response)
}
fn name(&self) -> &str {
"read_file"
}
fn description(&self) -> &str {
"Read file contents with optional line range, indentation-aware block selection, or batch multiple files"
}
fn parameter_schema(&self) -> Option<Value> {
Some(json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute path to the file to read (for single-file mode)"
},
"offset": {
"type": "integer",
"description": "1-indexed line number to start from (default: 1)",
"default": 1,
"minimum": 1
},
"limit": {
"type": "integer",
"description": "Maximum lines to return. Requests above the configurable absolute cap (max_read_lines, default 400) are clamped, and the response carries `next_read_args` to continue reading.",
"default": 400,
"minimum": 1
},
"mode": {
"type": "string",
"enum": ["slice", "indentation"],
"description": "Read mode: slice for simple range, indentation for block",
"default": "slice"
},
"indentation": {
"description": "Indentation settings when mode=indentation",
"anyOf": [
{"type": "boolean"},
{
"type": "object",
"properties": {
"anchor_line": {
"type": "integer",
"description": "Line number to anchor on (defaults to offset)"
},
"max_levels": {
"type": "integer",
"description": "Max indentation depth (0=unlimited)",
"default": 0
},
"include_siblings": {
"type": "boolean",
"description": "Include sibling blocks",
"default": false
},
"include_header": {
"type": "boolean",
"description": "Include header lines above anchor",
"default": true
},
"max_lines": {
"type": "integer",
"description": "Hard cap on returned lines"
}
}
}
]
},
"max_tokens": {
"type": "integer",
"description": "Optional token limit for response (approximate)"
},
"condense": {
"type": "boolean",
"description": "Condense long outputs to head/tail (default: true)",
"default": true
},
"offset_bytes": {
"type": "integer",
"description": "Byte offset (0-indexed) to start reading from. Enables byte-range read mode.",
"minimum": 0
},
"page_size_bytes": {
"type": "integer",
"description": "Number of bytes to read. Accepts alias `length`. Default: 8192.",
"alias": "length",
"minimum": 1
},
"reads": {
"type": "array",
"description": "Batch mode: array of file read requests to execute in parallel",
"items": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute path to the file"
},
"offset": {
"type": "integer",
"description": "1-indexed start line (default: 1)"
},
"limit": {
"type": "integer",
"description": "Max lines to return (default: 500 for batch)"
},
"ranges": {
"type": "array",
"description": "Multiple ranges from the same file",
"items": {
"type": "object",
"properties": {
"offset": { "type": "integer" },
"limit": { "type": "integer" },
"mode": { "type": "string", "enum": ["slice", "indentation"] }
}
}
}
},
"required": ["file_path"]
}
},
"max_concurrency": {
"type": "integer",
"description": "Batch mode: max concurrent file reads (default: 8)",
"default": 8
}
}
}))
}
}
mod slice {
use super::*;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct SliceReadResult {
pub lines: Vec<String>,
pub has_more: bool,
}
pub async fn read(path: &Path, offset: usize, limit: usize) -> Result<SliceReadResult> {
let file = File::open(path)
.await
.context(format!("failed to open file: {}", path.display()))?;
let mut reader = BufReader::new(file);
let mut collected = Vec::new();
let mut seen = 0usize;
let mut buffer = Vec::new();
let mut reached_eof = false;
loop {
buffer.clear();
let bytes_read = reader.read_until(b'\n', &mut buffer).await.context("failed to read file")?;
if bytes_read == 0 {
reached_eof = true;
break;
}
if buffer.last() == Some(&b'\n') {
buffer.pop();
if buffer.last() == Some(&b'\r') {
buffer.pop();
}
}
seen += 1;
if seen < offset {
continue;
}
if collected.len() >= limit {
break;
}
let formatted = format_line(&buffer);
collected.push(formatted);
}
if seen < offset {
anyhow::bail!("offset exceeds file length");
}
Ok(SliceReadResult { lines: collected, has_more: !reached_eof })
}
}
mod indentation {
use super::*;
pub async fn read_block(path: &Path, offset: usize, limit: usize, options: IndentationArgs) -> Result<Vec<String>> {
let anchor_line = options.anchor_line.unwrap_or(offset);
anyhow::ensure!(anchor_line > 0, "anchor_line must be a 1-indexed line number");
let guard_limit = options.max_lines.unwrap_or(limit);
anyhow::ensure!(guard_limit > 0, "max_lines must be greater than zero");
let collected = collect_file_lines(path).await?;
anyhow::ensure!(!collected.is_empty() && anchor_line <= collected.len(), "anchor_line exceeds file length");
let anchor_index = anchor_line - 1;
let effective_indents = compute_effective_indents(&collected);
let anchor_indent = effective_indents[anchor_index];
let min_indent = if options.max_levels == 0 {
0
} else {
anchor_indent.saturating_sub(options.max_levels * TAB_WIDTH)
};
let final_limit = limit.min(guard_limit).min(collected.len());
if final_limit == 1 {
return Ok(vec![format!(
"{}: {}",
collected[anchor_index].number, collected[anchor_index].display
)]);
}
let mut i: isize = anchor_index as isize - 1; let mut j: usize = anchor_index + 1; let mut i_counter_min_indent = 0;
let mut j_counter_min_indent = 0;
let mut out = VecDeque::with_capacity(limit);
out.push_back(&collected[anchor_index]);
while out.len() < final_limit {
let mut progressed = 0;
if i >= 0 {
#[allow(clippy::cast_sign_loss)]
let iu = i as usize;
if effective_indents[iu] >= min_indent {
out.push_front(&collected[iu]);
progressed += 1;
i -= 1;
if effective_indents[iu] == min_indent && !options.include_siblings {
let allow_header_comment = options.include_header && collected[iu].is_comment();
let can_take_line = allow_header_comment || i_counter_min_indent == 0;
if can_take_line {
i_counter_min_indent += 1;
} else {
out.pop_front();
progressed -= 1;
i = -1;
}
}
if out.len() >= final_limit {
break;
}
} else {
i = -1;
}
}
if j < collected.len() {
let ju = j;
if effective_indents[ju] >= min_indent {
out.push_back(&collected[ju]);
progressed += 1;
j += 1;
if effective_indents[ju] == min_indent && !options.include_siblings {
if j_counter_min_indent > 0 {
out.pop_back();
progressed -= 1;
j = collected.len();
}
j_counter_min_indent += 1;
}
} else {
j = collected.len();
}
}
if progressed == 0 {
break;
}
}
trim_empty_lines(&mut out);
Ok(out
.into_iter()
.map(|record| format!("{}: {}", record.number, record.display))
.collect())
}
async fn collect_file_lines(path: &Path) -> Result<Vec<LineRecord>> {
let file = File::open(path)
.await
.context(format!("failed to open file: {}", path.display()))?;
let mut reader = BufReader::new(file);
let mut buffer = Vec::new();
let mut lines = Vec::new();
let mut number = 0usize;
loop {
buffer.clear();
let bytes_read = reader.read_until(b'\n', &mut buffer).await.context("failed to read file")?;
if bytes_read == 0 {
break;
}
if buffer.last() == Some(&b'\n') {
buffer.pop();
if buffer.last() == Some(&b'\r') {
buffer.pop();
}
}
number += 1;
let raw = String::from_utf8_lossy(&buffer).into_owned();
let indent = measure_indent(&raw);
let display = format_line(&buffer);
lines.push(LineRecord { number, raw, display, indent });
}
Ok(lines)
}
fn compute_effective_indents(records: &[LineRecord]) -> Vec<usize> {
let mut effective = Vec::with_capacity(records.len());
let mut previous_indent = 0usize;
for record in records {
if record.is_blank() {
effective.push(previous_indent);
} else {
previous_indent = record.indent;
effective.push(previous_indent);
}
}
effective
}
fn measure_indent(line: &str) -> usize {
line.chars()
.take_while(|c| matches!(c, ' ' | '\t'))
.map(|c| if c == '\t' { TAB_WIDTH } else { 1 })
.sum()
}
}
fn format_line(bytes: &[u8]) -> String {
let decoded = String::from_utf8_lossy(bytes);
if decoded.len() > MAX_LINE_LENGTH {
take_bytes_at_char_boundary(&decoded, MAX_LINE_LENGTH).to_string()
} else {
decoded.into_owned()
}
}
fn take_bytes_at_char_boundary(s: &str, limit: usize) -> &str {
if limit >= s.len() {
return s;
}
let mut i = limit;
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
&s[..i]
}
fn trim_empty_lines(out: &mut VecDeque<&LineRecord>) {
while matches!(out.front(), Some(line) if line.raw.trim().is_empty()) {
out.pop_front();
}
while matches!(out.back(), Some(line) if line.raw.trim().is_empty()) {
out.pop_back();
}
}
fn condense_collected_lines(lines: &mut Vec<String>) {
if looks_like_diff_lines(lines) {
return;
}
const CONDENSED_THRESHOLD: usize = 200;
const HEAD_LINES: usize = 80;
const TAIL_LINES: usize = 40;
if lines.len() <= CONDENSED_THRESHOLD {
return;
}
let head_count = HEAD_LINES.min(lines.len());
let tail_count = TAIL_LINES.min(lines.len() - head_count);
let omitted_count = lines.len() - head_count - tail_count;
let total_lines = lines.len();
let mut condensed: Vec<String> = lines[..head_count].to_vec();
condensed.push(format!(
"… [+{} lines omitted ({} total). To read omitted ranges: file_operation offset={} limit={}]",
omitted_count,
total_lines,
head_count + 1,
omitted_count
));
let tail_start = lines.len() - tail_count;
condensed.extend_from_slice(&lines[tail_start..]);
*lines = condensed;
}
fn condense_for_batch(lines: &mut Vec<String>) -> (bool, usize) {
if looks_like_diff_lines(lines) {
return (false, 0);
}
const HEAD_LINES: usize = 15;
const TAIL_LINES: usize = 5;
if lines.len() <= BATCH_CONDENSED_THRESHOLD {
return (false, 0);
}
let head_count = HEAD_LINES.min(lines.len());
let tail_count = TAIL_LINES.min(lines.len() - head_count);
let omitted_count = lines.len() - head_count - tail_count;
let mut condensed: Vec<String> = lines[..head_count].to_vec();
condensed.push(format!(
"… [+{} lines omitted ({} total). To read omitted ranges: file_operation offset={} limit={}]",
omitted_count,
lines.len(),
head_count + 1,
omitted_count
));
let tail_start = lines.len() - tail_count;
condensed.extend_from_slice(&lines[tail_start..]);
*lines = condensed;
(true, omitted_count)
}
fn looks_like_diff_lines(lines: &[String]) -> bool {
let joined = lines.join("\n");
looks_like_diff_content(&joined)
}
mod defaults {
pub fn offset() -> usize {
1
}
pub fn limit() -> usize {
400
}
pub fn batch_limit() -> usize {
500
}
pub fn max_concurrency() -> usize {
super::DEFAULT_MAX_CONCURRENCY
}
pub fn ui_progress() -> bool {
true
}
pub fn max_levels() -> usize {
0
}
pub fn include_siblings() -> bool {
false
}
pub fn include_header() -> bool {
true
}
pub fn condense() -> bool {
true
}
}
#[cfg(test)]
mod tests {
#[expect(unused_imports)]
use super::indentation::*;
use super::slice::*;
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[tokio::test]
async fn reads_requested_range() -> Result<()> {
let mut temp = NamedTempFile::new()?;
writeln!(temp, "alpha")?;
writeln!(temp, "beta")?;
writeln!(temp, "gamma")?;
let lines = read(temp.path(), 2, 2).await?.lines;
assert_eq!(lines, vec!["beta".to_string(), "gamma".to_string()]);
Ok(())
}
#[test]
fn read_file_args_accepts_boolean_indentation() {
let args = json!({
"file_path": "/tmp/example.txt",
"mode": "slice",
"indentation": false
});
let parsed: ReadFileArgs = serde_json::from_value(args).unwrap();
assert!(matches!(parsed.mode, ReadMode::Slice));
assert!(parsed.indentation.is_none());
}
#[test]
fn read_file_args_accepts_true_indentation() {
let args = json!({
"file_path": "/tmp/example.txt",
"mode": "indentation",
"indentation": true
});
let parsed: ReadFileArgs = serde_json::from_value(args).unwrap();
assert!(matches!(parsed.mode, ReadMode::Indentation));
assert!(parsed.indentation.is_some());
}
#[test]
fn read_file_args_accepts_empty_mode() {
let args = json!({
"file_path": "/tmp/example.txt",
"mode": ""
});
let parsed: ReadFileArgs = serde_json::from_value(args).unwrap();
assert!(matches!(parsed.mode, ReadMode::Slice));
}
#[tokio::test]
async fn read_file_handler_skips_condense_when_disabled() -> Result<()> {
let mut temp = NamedTempFile::new()?;
for idx in 0..60 {
writeln!(temp, "line-{idx}")?;
}
let args = ReadFileArgs {
file_path: temp.path().to_string_lossy().to_string(),
offset: 1,
limit: 2000,
mode: ReadMode::Slice,
indentation: None,
max_tokens: None,
condense: false,
offset_bytes: None,
page_size_bytes: None,
};
let handler = ReadFileHandler;
let content = handler.handle(args).await?;
assert!(!content.contains("lines omitted"));
assert_eq!(content.lines().count(), 60);
Ok(())
}
#[tokio::test]
async fn read_file_handler_skips_condense_for_paginated_read() -> Result<()> {
let mut temp = NamedTempFile::new()?;
for idx in 1..=300 {
writeln!(temp, "line-{idx}")?;
}
let args = ReadFileArgs {
file_path: temp.path().to_string_lossy().to_string(),
offset: 81,
limit: 229,
mode: ReadMode::Slice,
indentation: None,
max_tokens: None,
condense: true,
offset_bytes: None,
page_size_bytes: None,
};
let handler = ReadFileHandler;
let content = handler.handle(args).await?;
assert!(!content.contains("lines omitted"), "paginated read must not be re-condensed; got: {content}");
assert!(
content.contains("line-81"),
"paginated read must include the requested offset; got first line: {:?}",
content.lines().next()
);
Ok(())
}
#[tokio::test]
async fn read_file_handler_still_condenses_default_overview_read() -> Result<()> {
let mut temp = NamedTempFile::new()?;
for idx in 1..=300 {
writeln!(temp, "line-{idx}")?;
}
let args = ReadFileArgs {
file_path: temp.path().to_string_lossy().to_string(),
offset: 1,
limit: 2000,
mode: ReadMode::Slice,
indentation: None,
max_tokens: None,
condense: true,
offset_bytes: None,
page_size_bytes: None,
};
let handler = ReadFileHandler;
let content = handler.handle(args).await?;
assert!(
content.contains("lines omitted"),
"default overview read must condense large output; got {} lines",
content.lines().count()
);
Ok(())
}
#[tokio::test]
async fn errors_when_offset_exceeds_length() {
let mut temp = NamedTempFile::new().unwrap();
writeln!(temp, "only").unwrap();
let err = read(temp.path(), 3, 1).await;
err.unwrap_err();
}
#[tokio::test]
async fn reads_non_utf8_lines() -> Result<()> {
let mut temp = NamedTempFile::new()?;
temp.as_file_mut().write_all(b"\xff\xfe\nplain\n")?;
let lines = read(temp.path(), 1, 2).await?.lines;
let expected_first = format!("{}{}", '\u{FFFD}', '\u{FFFD}');
assert_eq!(lines, vec![expected_first, "plain".to_string()]);
Ok(())
}
#[tokio::test]
async fn trims_crlf_endings() -> Result<()> {
let mut temp = NamedTempFile::new()?;
write!(temp, "one\r\ntwo\r\n")?;
let lines = read(temp.path(), 1, 2).await?.lines;
assert_eq!(lines, vec!["one".to_string(), "two".to_string()]);
Ok(())
}
#[tokio::test]
async fn respects_limit_even_with_more_lines() -> Result<()> {
let mut temp = NamedTempFile::new()?;
writeln!(temp, "first")?;
writeln!(temp, "second")?;
writeln!(temp, "third")?;
let result = read(temp.path(), 1, 2).await?;
assert_eq!(result.lines, vec!["first".to_string(), "second".to_string()]);
assert!(result.has_more);
Ok(())
}
#[tokio::test]
async fn reads_exact_limit_without_continuation_at_eof() -> Result<()> {
let mut temp = NamedTempFile::new()?;
writeln!(temp, "first")?;
writeln!(temp, "second")?;
let result = read(temp.path(), 1, 2).await?;
assert_eq!(result.lines, vec!["first".to_string(), "second".to_string()]);
assert!(!result.has_more);
Ok(())
}
#[test]
fn clamp_to_absolute_cap_marks_only_true_overages() {
assert_eq!(ReadFileHandler::clamp_to_absolute_cap(1000, 400), (400, true));
assert_eq!(ReadFileHandler::clamp_to_absolute_cap(400, 400), (400, false));
assert_eq!(ReadFileHandler::clamp_to_absolute_cap(50, 400), (50, false));
}
#[tokio::test]
async fn absolute_cap_clamps_large_request() -> Result<()> {
let mut temp = NamedTempFile::new()?;
for idx in 1..=1000 {
writeln!(temp, "line-{idx}")?;
}
let handler = ReadFileHandler;
let args = ReadFileArgs {
file_path: temp.path().to_string_lossy().to_string(),
offset: 1,
limit: 2000,
mode: ReadMode::Slice,
indentation: None,
max_tokens: None,
condense: false,
offset_bytes: None,
page_size_bytes: None,
};
let outcome = handler.handle_detailed(args).await?;
let cap = crate::tools::read_limits::read_limit_lines();
assert!(outcome.capped_by_limit, "request larger than the cap must be clamped");
assert_eq!(outcome.applied_limit, cap);
assert!(outcome.has_more);
assert!(outcome.lines_read <= cap);
Ok(())
}
#[tokio::test]
async fn explicit_limit_under_cap_is_preserved() -> Result<()> {
let mut temp = NamedTempFile::new()?;
for idx in 1..=100 {
writeln!(temp, "line-{idx}")?;
}
let handler = ReadFileHandler;
let args = ReadFileArgs {
file_path: temp.path().to_string_lossy().to_string(),
offset: 1,
limit: 50,
mode: ReadMode::Slice,
indentation: None,
max_tokens: None,
condense: false,
offset_bytes: None,
page_size_bytes: None,
};
let outcome = handler.handle_detailed(args).await?;
assert!(!outcome.capped_by_limit);
assert_eq!(outcome.lines_read, 100);
Ok(())
}
#[tokio::test]
async fn truncates_lines_longer_than_max_length() -> Result<()> {
let mut temp = NamedTempFile::new()?;
let long_line = "x".repeat(MAX_LINE_LENGTH + 50);
writeln!(temp, "{long_line}")?;
let lines = read(temp.path(), 1, 1).await?.lines;
let expected = "x".repeat(MAX_LINE_LENGTH);
assert_eq!(lines, vec![expected]);
Ok(())
}
#[tokio::test]
async fn batch_reads_multiple_files() -> Result<()> {
let mut temp1 = NamedTempFile::new()?;
writeln!(temp1, "file1_line1")?;
writeln!(temp1, "file1_line2")?;
let mut temp2 = NamedTempFile::new()?;
writeln!(temp2, "file2_line1")?;
writeln!(temp2, "file2_line2")?;
let handler = ReadFileHandler;
let args = BatchReadArgs {
reads: vec![
BatchReadRequest {
file_path: temp1.path().to_string_lossy().to_string(),
range: None,
ranges: None,
},
BatchReadRequest {
file_path: temp2.path().to_string_lossy().to_string(),
range: None,
ranges: None,
},
],
max_concurrency: 2,
ui_progress: false,
};
let result = handler.handle_batch(args).await?;
assert_eq!(result["success"], true);
assert_eq!(result["files_read"], 2);
assert_eq!(result["files_succeeded"], 2);
let content = result["content"].as_str().unwrap();
assert!(content.contains("file1_line1"));
assert!(content.contains("file2_line1"));
Ok(())
}
#[tokio::test]
async fn batch_reads_multiple_ranges_from_same_file() -> Result<()> {
let mut temp = NamedTempFile::new()?;
for i in 1..=20 {
writeln!(temp, "line{i}")?;
}
let handler = ReadFileHandler;
let args = BatchReadArgs {
reads: vec![BatchReadRequest {
file_path: temp.path().to_string_lossy().to_string(),
range: None,
ranges: Some(vec![
ReadRange {
offset: 1,
limit: 3,
mode: ReadMode::Slice,
indentation: None,
},
ReadRange {
offset: 10,
limit: 3,
mode: ReadMode::Slice,
indentation: None,
},
]),
}],
max_concurrency: 4,
ui_progress: false,
};
let result = handler.handle_batch(args).await?;
assert_eq!(result["success"], true);
let items = result["items"].as_array().unwrap();
assert_eq!(items.len(), 1);
let ranges = items[0]["ranges"].as_array().unwrap();
assert_eq!(ranges.len(), 2);
assert_eq!(ranges[0]["offset"], 1);
assert_eq!(ranges[1]["offset"], 10);
Ok(())
}
#[tokio::test]
async fn batch_handles_missing_file_gracefully() -> Result<()> {
let handler = ReadFileHandler;
let args = BatchReadArgs {
reads: vec![BatchReadRequest {
file_path: "/nonexistent/path/file.txt".to_string(),
range: None,
ranges: None,
}],
max_concurrency: 1,
ui_progress: false,
};
let result = handler.handle_batch(args).await?;
assert_eq!(result["success"], false);
let items = result["items"].as_array().unwrap();
assert!(items[0]["error"].as_str().is_some());
Ok(())
}
#[test]
fn condense_for_batch_preserves_small_outputs() {
let mut lines: Vec<String> = (1..=20).map(|i| format!("line{i}")).collect();
let (condensed, omitted) = condense_for_batch(&mut lines);
assert!(!condensed);
assert_eq!(omitted, 0);
assert_eq!(lines.len(), 20);
}
#[test]
fn condense_for_batch_condenses_large_outputs() {
let mut lines: Vec<String> = (1..=200).map(|i| format!("line{i}")).collect();
let (condensed, omitted) = condense_for_batch(&mut lines);
assert!(condensed);
assert!(omitted > 0);
assert!(lines.len() < 200);
assert!(lines.iter().any(|l| l.contains("omitted")));
}
#[test]
fn condense_for_batch_does_not_treat_plus_minus_text_as_diff() {
let mut lines: Vec<String> = (1..=150)
.map(|i| {
if i % 2 == 0 {
format!("+ normal status line {i}")
} else {
format!("- normal status line {i}")
}
})
.collect();
let (condensed, omitted) = condense_for_batch(&mut lines);
assert!(condensed);
assert!(omitted > 0);
}
#[test]
fn condense_for_batch_preserves_actual_diff_output() {
let mut lines = vec![
"diff --git a/src/main.rs b/src/main.rs".to_string(),
"index 1111111..2222222 100644".to_string(),
"--- a/src/main.rs".to_string(),
"+++ b/src/main.rs".to_string(),
"@@ -1 +1 @@".to_string(),
"-old".to_string(),
"+new".to_string(),
];
let (condensed, omitted) = condense_for_batch(&mut lines);
assert!(!condensed);
assert_eq!(omitted, 0);
}
#[tokio::test]
async fn byte_range_reads_first_bytes() -> Result<()> {
let mut temp = NamedTempFile::new()?;
temp.as_file_mut().write_all(b"Hello, World!")?;
let handler = ReadFileHandler;
let args = ReadFileArgs {
file_path: temp.path().to_string_lossy().to_string(),
offset: 1,
limit: 2000,
mode: ReadMode::Slice,
indentation: None,
max_tokens: None,
condense: false,
offset_bytes: Some(0),
page_size_bytes: Some(5),
};
let outcome = handler.handle_detailed(args).await?;
assert_eq!(outcome.content, "1: Hello");
assert!(outcome.has_more);
Ok(())
}
#[tokio::test]
async fn byte_range_reads_with_offset() -> Result<()> {
let mut temp = NamedTempFile::new()?;
writeln!(temp, "Hello, World!")?;
writeln!(temp, "Second line")?;
let handler = ReadFileHandler;
let args = ReadFileArgs {
file_path: temp.path().to_string_lossy().to_string(),
offset: 1,
limit: 2000,
mode: ReadMode::Slice,
indentation: None,
max_tokens: None,
condense: false,
offset_bytes: Some(14),
page_size_bytes: Some(50),
};
let outcome = handler.handle_detailed(args).await?;
assert!(outcome.content.contains("2: Second line"));
assert!(!outcome.has_more);
Ok(())
}
#[tokio::test]
async fn byte_range_beyond_eof_returns_empty() -> Result<()> {
let mut temp = NamedTempFile::new()?;
temp.as_file_mut().write_all(b"short")?;
let handler = ReadFileHandler;
let args = ReadFileArgs {
file_path: temp.path().to_string_lossy().to_string(),
offset: 1,
limit: 2000,
mode: ReadMode::Slice,
indentation: None,
max_tokens: None,
condense: false,
offset_bytes: Some(100),
page_size_bytes: Some(10),
};
let outcome = handler.handle_detailed(args).await?;
assert_eq!(outcome.content, "");
assert!(!outcome.has_more);
Ok(())
}
#[tokio::test]
async fn byte_range_clamps_to_file_size() -> Result<()> {
let mut temp = NamedTempFile::new()?;
writeln!(temp, "first line")?;
writeln!(temp, "second line")?;
writeln!(temp, "third line")?;
let handler = ReadFileHandler;
let args = ReadFileArgs {
file_path: temp.path().to_string_lossy().to_string(),
offset: 1,
limit: 2000,
mode: ReadMode::Slice,
indentation: None,
max_tokens: None,
condense: false,
offset_bytes: Some(0),
page_size_bytes: Some(100), };
let outcome = handler.handle_detailed(args).await?;
assert!(outcome.content.contains("1: first line"));
assert!(outcome.content.contains("2: second line"));
assert!(outcome.content.contains("3: third line"));
assert!(!outcome.has_more);
Ok(())
}
#[tokio::test]
async fn byte_range_reads_non_utf8_lossy() -> Result<()> {
let mut temp = NamedTempFile::new()?;
temp.as_file_mut().write_all(b"\xff\xfeplain")?;
let handler = ReadFileHandler;
let args = ReadFileArgs {
file_path: temp.path().to_string_lossy().to_string(),
offset: 1,
limit: 2000,
mode: ReadMode::Slice,
indentation: None,
max_tokens: None,
condense: false,
offset_bytes: Some(0),
page_size_bytes: Some(7), };
let outcome = handler.handle_detailed(args).await?;
assert!(outcome.content.contains('\u{FFFD}'));
assert!(outcome.content.contains("plain"));
Ok(())
}
#[tokio::test]
async fn byte_range_has_more_when_not_at_eof() -> Result<()> {
let mut temp = NamedTempFile::new()?;
writeln!(temp, "line one")?;
writeln!(temp, "line two")?;
writeln!(temp, "line three")?;
let handler = ReadFileHandler;
let args = ReadFileArgs {
file_path: temp.path().to_string_lossy().to_string(),
offset: 1,
limit: 2000,
mode: ReadMode::Slice,
indentation: None,
max_tokens: None,
condense: false,
offset_bytes: Some(0),
page_size_bytes: Some(10),
};
let outcome = handler.handle_detailed(args).await?;
assert!(outcome.content.contains("1: line one"));
assert!(outcome.has_more);
Ok(())
}
#[tokio::test]
async fn byte_range_defaults_to_8192_when_length_omitted() -> Result<()> {
let mut temp = NamedTempFile::new()?;
for i in 1..=500 {
writeln!(temp, "line-{i}: {}", "x".repeat(20))?;
}
let handler = ReadFileHandler;
let args = ReadFileArgs {
file_path: temp.path().to_string_lossy().to_string(),
offset: 1,
limit: 2000,
mode: ReadMode::Slice,
indentation: None,
max_tokens: None,
condense: false,
offset_bytes: Some(0),
page_size_bytes: None, };
let outcome = handler.handle_detailed(args).await?;
assert!(outcome.content.contains("1: line-1"));
assert!(outcome.has_more);
Ok(())
}
#[test]
fn byte_range_args_from_json() {
let args = json!({
"file_path": "/tmp/test.bin",
"offset_bytes": 1024,
"page_size_bytes": 4096
});
let parsed: ReadFileArgs = serde_json::from_value(args).unwrap();
assert_eq!(parsed.offset_bytes, Some(1024));
assert_eq!(parsed.page_size_bytes, Some(4096));
}
#[test]
fn byte_range_args_length_alias() {
let args = json!({
"file_path": "/tmp/test.bin",
"offset_bytes": 0,
"length": 2048
});
let parsed: ReadFileArgs = serde_json::from_value(args).unwrap();
assert_eq!(parsed.offset_bytes, Some(0));
assert_eq!(parsed.page_size_bytes, Some(2048));
}
}