use std::collections::VecDeque;
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::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 = 30;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ReadFileOutcome {
pub content: String,
pub lines_read: usize,
pub has_more: bool,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct ReadFileArgs {
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,
}
#[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 {
let _permit = sem.acquire().await.ok();
prog.file_started(&req.file_path).await;
let result = self.read_single_batch_request(&req).await;
prog.file_completed();
result
}
})
.buffer_unordered(args.max_concurrency)
.collect()
.await;
let mut content_parts = Vec::new();
for result in &results {
if let Some(ref error) = result.error {
content_parts.push(format!("== {} (ERROR)\n{}", result.file_path, error));
} else {
for range in &result.ranges {
let end_line = range.offset + range.lines_read.saturating_sub(1);
content_parts.push(format!(
"== {} (L{}..L{})\n{}",
result.file_path, range.offset, end_line, range.content
));
}
}
}
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 (condensed, omitted) = condense_for_batch(&mut collected);
Ok(RangeResult {
offset,
lines_read: original_len,
condensed,
omitted_lines: if omitted > 0 { Some(omitted) } else { None },
content: collected.join("\n"),
})
}
pub(crate) async fn handle_detailed(&self, args: ReadFileArgs) -> Result<ReadFileOutcome> {
let ReadFileArgs {
file_path,
offset,
limit,
mode,
indentation,
max_tokens,
condense,
} = args;
anyhow::ensure!(offset > 0, "offset must be a 1-indexed line number");
anyhow::ensure!(limit > 0, "limit must be greater than zero");
let path = PathBuf::from(&file_path);
anyhow::ensure!(path.is_absolute(), "file_path must be an absolute path");
let effective_limit =
if matches!(mode, ReadMode::Slice) && max_tokens.is_none() && limit < MIN_BATCH_LIMIT {
MIN_BATCH_LIMIT
} else {
limit
};
let (mut collected, has_more) = match mode {
ReadMode::Slice => {
let result = slice::read(&path, offset, effective_limit).await?;
(result.lines, result.has_more)
}
ReadMode::Indentation => {
let indentation = indentation.unwrap_or_default();
(
indentation::read_block(&path, offset, limit, indentation).await?,
false,
)
}
};
let lines_read = collected.len();
if condense {
condense_collected_lines(&mut collected);
}
Ok(ReadFileOutcome {
content: collected.join("\n"),
lines_read,
has_more,
})
}
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 =
serde_json::from_value(args).context("failed to parse batch read arguments")?;
return self.handle_batch(batch_args).await;
}
let args: ReadFileArgs =
serde_json::from_value(args).context("failed to parse read_file arguments")?;
let file_path = args.file_path.clone();
let content = self.handle_detailed(args).await?.content;
Ok(json!({
"content": content,
"file_path": file_path,
"path": file_path,
"success": true,
"no_spool": true
}))
}
fn name(&self) -> &'static str {
"read_file"
}
fn description(&self) -> &'static 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 (default: 2000)",
"default": 2000,
"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
},
"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 {
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 = 50;
const HEAD_LINES: usize = 20;
const TAIL_LINES: usize = 10;
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 mut condensed: Vec<String> = lines[..head_count].to_vec();
condensed.push(format!(
"… [+{} lines omitted; use read_file with offset/limit (1-indexed line numbers) for full content]",
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; use read_file with offset/limit for full content]",
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 {
2000
}
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 {
#[allow(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,
};
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 errors_when_offset_exceeds_length() {
let mut temp = NamedTempFile::new().unwrap();
writeln!(temp, "only").unwrap();
let err = read(temp.path(), 3, 1).await;
assert!(err.is_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(())
}
#[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..=100).map(|i| format!("line{i}")).collect();
let (condensed, omitted) = condense_for_batch(&mut lines);
assert!(condensed);
assert!(omitted > 0);
assert!(lines.len() < 100);
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..=60)
.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);
}
}