use std::path::Path;
use crate::tools::{ShellMode, ShellTool, search::SearchTool};
use crate::{Tool, ToolOutputPhase, Workspace};
use async_trait::async_trait;
use serde_json::json;
use tree_sitter::{Language, Parser, Query, QueryCursor, StreamingIterator, Tree};
pub struct ReadTool;
#[must_use]
fn is_sensitive_file_path(path: &str) -> bool {
let Some(file_name) = std::path::Path::new(path)
.file_name()
.and_then(|s| s.to_str())
else {
return true;
};
let lower = file_name.to_ascii_lowercase();
match lower.rsplit_once('.') {
Some((name, ext)) => {
name == ".env"
|| ext == "env"
|| matches!(ext, "pem" | "key" | "p12" | "pfx" | "crt" | "cer")
}
None => false,
}
}
#[async_trait]
impl Tool for ReadTool {
fn name(&self) -> &'static str {
"read"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"path": {
"type": "string",
"description": "Path to the file. Relative paths resolve from workspace; outside paths require policy allowlist."
},
"mode": {
"type": "string",
"enum": ["content", "symbols", "zoom"],
"description": "Read mode. 'content' (default): line-numbered file read. 'symbols': list all top-level AST symbols with line ranges. 'zoom': extract a single symbol's source by name.",
"default": "content"
},
"symbol": {
"type": "string",
"description": "Symbol name for zoom mode. Required when mode is 'zoom'.",
"minLength": 1
},
"offset": {
"type": "integer",
"description": "Starting line number (1-based, default: 1)",
"default": 1,
"minimum": 1
},
"limit": {
"type": "integer",
"description": "Maximum number of lines to return (default: all)",
"minimum": 1
}
}),
&["path"],
)
}
async fn execute(&self, ws: &Workspace, args: serde_json::Value) -> anyhow::Result<String> {
let path = super::require_path_arg(&args)?;
if super::path_contains_wildcard(&path) {
return self.recover_wildcard_path(ws, &path).await;
}
let resolved_path = match super::path::resolve_read_target(ws.as_path(), &path).await {
Ok(p) => p,
Err(e) => {
let msg = e.to_string();
if msg.contains("File not found") {
return self.recover_missing_path(ws, &path, &args, &msg).await;
}
return Err(e);
}
};
self.read_resolved(ws, &resolved_path, None, &args).await
}
fn should_scrub_output(&self, args: &serde_json::Value) -> bool {
match super::find_path_arg(args) {
Some(path) => is_sensitive_file_path(path),
None => true, }
}
fn side_effects(&self, _args: &serde_json::Value) -> bool {
false }
fn format_output(&self, output: &str) -> String {
const MAX_CHARS: usize = 5_000;
if output.len() <= MAX_CHARS {
return output.to_string();
}
if let Some(nl) = output.find('\n') {
let header = &output[..nl];
let expected = parse_header_line_count(header);
if expected > 0 {
let body = &output[nl + 1..];
let marker_budget = format!("\n... ({expected} lines omitted)").len();
let body_budget = MAX_CHARS.saturating_sub(header.len() + marker_budget + 1);
let cut = body.floor_char_boundary(body_budget.min(body.len()));
let last_nl = body[..cut].rfind('\n').unwrap_or(cut);
let kept_body = &body[..last_nl];
let kept = if kept_body.is_empty() {
0
} else {
kept_body.bytes().filter(|&b| b == b'\n').count() + 1
};
let omitted = expected.saturating_sub(kept);
let marker = format!("\n... ({omitted} lines omitted)");
return format!("{header}\n{kept_body}{marker}");
}
}
crate::util::format_tool_output(output)
}
fn debug_output(
&self,
phase: ToolOutputPhase,
args: &serde_json::Value,
outcome: Option<&crate::tools::ToolExecutionOutcome>,
) -> Option<String> {
match phase {
ToolOutputPhase::Before => {
let path = super::find_path_arg(args).unwrap_or("?");
let range = Self::format_range(args);
if range.is_empty() {
Some(format!("👀 {path}"))
} else {
Some(format!("👀 {path} ({range})"))
}
}
ToolOutputPhase::After => {
let outcome = outcome?;
if outcome.success {
None
} else {
let path = super::find_path_arg(args).unwrap_or("?");
Some(format!("❌ Failed to read {path}"))
}
}
}
}
}
impl ReadTool {
async fn read_resolved(
&self,
ws: &Workspace,
resolved_path: &Path,
recovery_note: Option<&str>,
args: &serde_json::Value,
) -> anyhow::Result<String> {
match tokio::fs::metadata(resolved_path).await {
Ok(meta) => {
if meta.is_dir() {
return list_directory(resolved_path, ws).await;
}
super::check_file_size(&meta)?;
}
Err(e) => match e.kind() {
std::io::ErrorKind::NotFound => {
anyhow::bail!("File not found: {}", resolved_path.display());
}
std::io::ErrorKind::PermissionDenied => {
anyhow::bail!("Permission denied: {}", resolved_path.display());
}
_ => {
anyhow::bail!("Failed to read file metadata: {e}");
}
},
}
let mode = super::get_opt_str(args, "mode").unwrap_or("content");
let body = match mode {
"symbols" => self.execute_symbols(resolved_path).await?,
"zoom" => self.execute_zoom(resolved_path, args).await?,
_ => self.execute_content(resolved_path, args).await?,
};
Ok(match recovery_note {
Some(note) => format!("{note}\n{body}"),
None => body,
})
}
async fn recover_wildcard_path(&self, ws: &Workspace, path: &str) -> anyhow::Result<String> {
if !crate::search_engine::registry_initialized() {
anyhow::bail!(
"Wildcard path '{path}' requires the workspace search index, which is unavailable."
);
}
let matches = SearchTool::find_file_paths(ws, path, 20).await?;
if matches.is_empty() {
anyhow::bail!(
"No files matching wildcard path '{path}' found in workspace.\n\
Use the search tool with mode='files' to browse paths."
);
}
let mut output = format!("Wildcard path '{path}' matched:\n");
for m in &matches {
output.push_str(" ");
output.push_str(m);
output.push('\n');
}
Ok(output)
}
async fn recover_missing_path(
&self,
ws: &Workspace,
path: &str,
args: &serde_json::Value,
original_err: &str,
) -> anyhow::Result<String> {
let hint = std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.filter(|s| !s.is_empty())
.unwrap_or(path);
let matches = SearchTool::find_file_paths(ws, hint, 8)
.await
.unwrap_or_default();
if matches.is_empty() {
anyhow::bail!("{original_err}");
}
if matches.len() == 1 {
let recovered = &matches[0];
let resolved = super::path::resolve_read_target(ws.as_path(), recovered).await?;
let note = format!("[Recovered path: requested '{path}', using '{recovered}']");
return self.read_resolved(ws, &resolved, Some(¬e), args).await;
}
anyhow::bail!("{original_err}\nDid you mean:\n {}", matches.join("\n "))
}
fn format_range(args: &serde_json::Value) -> String {
match (
super::get_opt_u64(args, "offset"),
super::get_opt_u64(args, "limit"),
) {
(None, None) => String::new(),
(Some(o), None) => format!("{o}:"),
(None, Some(l)) => format!("1:{}", l.max(1)),
(Some(o), Some(l)) => format!("{o}:{}", o.saturating_add(l.max(1) - 1)),
}
}
async fn execute_content(
&self,
resolved_path: &Path,
args: &serde_json::Value,
) -> anyhow::Result<String> {
match tokio::fs::read_to_string(resolved_path).await {
Ok(contents) => {
let lines: Vec<&str> = contents.lines().collect();
let total = lines.len();
if total == 0 {
return Ok(String::new());
}
let offset = super::get_opt_u64(args, "offset").map_or(0, |v| {
usize::try_from(v.max(1))
.unwrap_or(usize::MAX)
.saturating_sub(1)
});
let start = offset.min(total);
let end = match super::get_opt_u64(args, "limit") {
Some(l) => {
let limit = usize::try_from(l).unwrap_or(usize::MAX);
(start.saturating_add(limit)).min(total)
}
None => total,
};
if start >= end {
return Ok(format!("[No lines in range, file has {total} lines]"));
}
let numbered: String = lines[start..end]
.iter()
.enumerate()
.map(|(i, line)| format!("{}: {}", start + i + 1, line))
.collect::<Vec<_>>()
.join("\n");
let partial = start > 0 || end < total;
let summary = if partial {
format!("[Lines {}-{} of {total}]", start + 1, end)
} else {
format!("[{total} lines total]")
};
Ok(format!("{summary}\n{numbered}"))
}
Err(e) => {
let bytes = tokio::fs::read(resolved_path).await.map_err(|ee| {
anyhow::anyhow!(
"Initial error: {e}\n\
Failed to read file: {ee}"
)
})?;
let lossy = String::from_utf8_lossy(&bytes).into_owned();
Ok(lossy)
}
}
}
async fn execute_symbols(&self, resolved_path: &Path) -> anyhow::Result<String> {
let ctx = prepare_symbol_query(resolved_path, "symbol extraction").await?;
let symbols = collect_symbols(&ctx.ps, &ctx.query);
let mut lines: Vec<String> = symbols
.iter()
.map(|s| {
let kind_label = symbol_kind_label(&s.kind);
format!(
" {kind_label} `{}` ({}-{})",
s.name, s.start_line, s.end_line
)
})
.collect();
lines.sort();
lines.dedup();
let filename = display_filename(resolved_path);
let output = if lines.is_empty() {
format!("[No symbols found in {filename}]")
} else {
format!("[Symbols in {filename}]\n{}", lines.join("\n"))
};
Ok(output)
}
async fn execute_zoom(
&self,
resolved_path: &Path,
args: &serde_json::Value,
) -> anyhow::Result<String> {
let symbol_name = match super::get_opt_str(args, "symbol") {
Some(s) if !s.is_empty() => s,
_ => {
anyhow::bail!("Missing 'symbol' parameter — required for zoom mode");
}
};
let ctx = prepare_symbol_query(resolved_path, "zoom").await?;
let root_node = ctx.ps.tree.root_node();
let mut qcursor = QueryCursor::new();
let mut qmatches = qcursor.matches(&ctx.query, root_node, ctx.ps.source.as_bytes());
let mut found_node = None;
qmatches.advance();
while let Some(m) = qmatches.get() {
for c in m.captures {
if let Ok(name) = c.node.utf8_text(ctx.ps.source.as_bytes())
&& name == symbol_name
{
found_node = c.node.parent();
break;
}
}
if found_node.is_some() {
break;
}
qmatches.advance();
}
let Some(node) = found_node else {
let suggestions = Self::symbol_suggestions(&ctx.ps, &ctx.query, symbol_name);
if suggestions.is_empty() {
anyhow::bail!(
"Symbol '{symbol_name}' not found in {}",
display_filename(resolved_path),
);
}
anyhow::bail!(
"Symbol '{symbol_name}' not found in {}. Did you mean: {}",
display_filename(resolved_path),
suggestions.join(", ")
);
};
let start = node.start_position().row + 1;
let end = node.end_position().row + 1;
let byte_range = node.byte_range();
let extracted = &ctx.ps.source[byte_range.start..byte_range.end];
let kind_label = symbol_kind_label(node.kind());
Ok(format!(
"[Symbol: {kind_label} `{symbol_name}` (lines {start}-{end})]\n{extracted}",
))
}
fn symbol_suggestions(ps: &ParsedSource, query: &Query, wanted: &str) -> Vec<String> {
let symbols = collect_symbols(ps, query);
let mut names: Vec<String> = symbols
.into_iter()
.map(|s| s.name)
.filter(|n| n != "?")
.collect();
names.sort();
names.dedup();
let wanted_lc = wanted.to_ascii_lowercase();
names.sort_by_cached_key(|name| {
let name_lc = name.to_ascii_lowercase();
let tier = if name_lc == wanted_lc {
0 } else if name_lc.starts_with(&wanted_lc) || wanted_lc.starts_with(&name_lc) {
1 } else {
2 };
(tier, name_lc)
});
names.truncate(8);
names
}
}
fn display_filename(path: &Path) -> &str {
path.file_name().and_then(|n| n.to_str()).unwrap_or("?")
}
#[derive(Debug)]
struct ParsedSource {
source: String,
ext: String,
language: Language,
tree: Tree,
}
async fn read_and_parse(resolved_path: &Path, mode_label: &str) -> anyhow::Result<ParsedSource> {
let source = match tokio::fs::read_to_string(resolved_path).await {
Ok(s) => s,
Err(e) => anyhow::bail!("Could not read file for {mode_label}: {e}"),
};
let ext = resolved_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_owned();
let Some(language) = language_support(&ext).map(|ls| ls.language) else {
anyhow::bail!(
"Unsupported file extension '.{ext}' for {mode_label}. \
Supported: .rs, .js, .jsx, .mjs, .cjs, .ts, .tsx, .py, .pyi, .pyx, .json, .toml, \
.sh, .bash, .zsh, .css, .html, .htm, .go, .rb, .c, .h, .sql, .md, .markdown"
);
};
let mut parser = Parser::new();
parser
.set_language(&language)
.map_err(|e| anyhow::anyhow!("Failed to set tree-sitter language: {e}"))?;
let Some(tree) = parser.parse(&source, None) else {
anyhow::bail!("Could not parse file for {mode_label}");
};
Ok(ParsedSource {
source,
ext,
language,
tree,
})
}
struct LanguageSupport {
language: Language,
symbol_query: &'static str,
}
#[allow(clippy::too_many_lines)]
fn language_support(ext: &str) -> Option<LanguageSupport> {
const TS_SYMBOL_QUERY: &str = r"(
[
(function_declaration name: (identifier) @name)
(class_declaration name: (type_identifier) @name)
(method_definition name: (property_identifier) @name)
(arrow_function name: (identifier) @name)
(variable_declarator name: (identifier) @name)
(interface_declaration name: (type_identifier) @name)
(enum_declaration name: (identifier) @name)
(type_alias_declaration name: (type_identifier) @name)
(export_statement (function_declaration name: (identifier) @name))
(export_statement (class_declaration name: (type_identifier) @name))
(export_statement (interface_declaration name: (type_identifier) @name))
(export_statement (enum_declaration name: (identifier) @name))
(export_statement (type_alias_declaration name: (type_identifier) @name))
]
)";
let language = crate::util::tree_sitter::tree_sitter_language_for_extension(ext)?;
let symbol_query = match ext {
"rs" => {
r"(
[
(function_item name: (identifier) @name)
(struct_item name: (type_identifier) @name)
(enum_item name: (type_identifier) @name)
(trait_item name: (type_identifier) @name)
(impl_item type: (_) @name)
(const_item name: (identifier) @name)
(static_item name: (identifier) @name)
(type_item name: (type_identifier) @name)
(macro_definition name: (identifier) @name)
(mod_item name: (identifier) @name)
]
)"
}
"js" | "jsx" | "mjs" | "cjs" => {
r"(
[
(function_declaration name: (identifier) @name)
(class_declaration name: (type_identifier) @name)
(method_definition name: (property_identifier) @name)
(arrow_function name: (identifier) @name)
(variable_declarator name: (identifier) @name)
(export_statement (function_declaration name: (identifier) @name))
(export_statement (class_declaration name: (type_identifier) @name))
]
)"
}
"ts" | "tsx" => TS_SYMBOL_QUERY,
"py" | "pyi" | "pyx" => {
r"(
[
(function_definition name: (identifier) @name)
(class_definition name: (identifier) @name)
]
)"
}
"sh" | "bash" | "zsh" => {
r"(
[
(function_definition name: (word) @name)
]
)"
}
"go" => {
r"(
[
(function_declaration name: (identifier) @name)
(method_declaration name: (field_identifier) @name)
(type_declaration (type_spec name: (type_identifier) @name))
(const_declaration (const_spec name: (identifier) @name))
(var_declaration (var_spec name: (identifier) @name))
]
)"
}
"rb" => {
r"(
[
(method name: (identifier) @name)
(singleton_method name: (identifier) @name)
(class name: (constant) @name)
(module name: (constant) @name)
]
)"
}
"c" | "h" => {
r"(
[
(function_definition declarator: (function_declarator declarator: (identifier) @name))
(struct_specifier name: (type_identifier) @name)
(enum_specifier name: (type_identifier) @name)
(union_specifier name: (type_identifier) @name)
(type_definition declarator: (type_identifier) @name)
]
)"
}
"sql" => {
r"(
[
(create_table (object_reference name: (identifier) @name))
(create_view (object_reference name: (identifier) @name))
(create_index (object_reference name: (identifier) @name))
(create_trigger (object_reference name: (identifier) @name))
]
)"
}
_ => "",
};
Some(LanguageSupport {
language,
symbol_query,
})
}
fn build_symbol_query(ps: &ParsedSource) -> anyhow::Result<Query> {
let query_str = language_support(&ps.ext).map_or("", |ls| ls.symbol_query);
Query::new(&ps.language, query_str)
.map_err(|e| anyhow::anyhow!("Failed to build symbol query: {e}"))
}
#[derive(Debug)]
struct SymbolMatch {
name: String,
start_line: usize,
end_line: usize,
kind: String,
}
#[derive(Debug)]
struct SymbolQueryContext {
ps: ParsedSource,
query: Query,
}
async fn prepare_symbol_query(
resolved_path: &Path,
mode: &str,
) -> anyhow::Result<SymbolQueryContext> {
let ps = read_and_parse(resolved_path, mode).await?;
let query = build_symbol_query(&ps)?;
Ok(SymbolQueryContext { ps, query })
}
fn collect_symbols(ps: &ParsedSource, query: &Query) -> Vec<SymbolMatch> {
let root_node = ps.tree.root_node();
let mut cursor = QueryCursor::new();
let mut matches_iter = cursor.matches(query, root_node, ps.source.as_bytes());
let mut symbols = Vec::new();
matches_iter.advance();
while let Some(m) = matches_iter.get() {
for capture in m.captures {
let node = capture.node;
let name = node
.utf8_text(ps.source.as_bytes())
.unwrap_or("?")
.to_string();
let start_line = node.start_position().row + 1;
let end_line = node.end_position().row + 1;
let kind = node.parent().map_or("?", |p| p.kind()).to_string();
symbols.push(SymbolMatch {
name,
start_line,
end_line,
kind,
});
}
matches_iter.advance();
}
symbols
}
fn symbol_kind_label(kind: &str) -> &'static str {
match kind {
"function_item" | "function_declaration" | "function_definition" => "fn",
"struct_item" | "struct_declaration" | "struct_specifier" => "struct",
"enum_item" | "enum_declaration" | "enum_specifier" => "enum",
"trait_item" | "trait_declaration" => "trait",
"impl_item" | "impl_declaration" => "impl",
"type_item"
| "type_declaration"
| "type_alias_declaration"
| "type_definition"
| "type_spec" => "type",
"const_item" | "const_declaration" | "static_item" | "static_declaration"
| "const_spec" => "const",
"macro_definition" | "macro_declaration" => "macro",
"mod_item" | "mod_declaration" => "mod",
"class_declaration" | "class_definition" | "class" => "class",
"method_definition" | "method_declaration" | "method" | "singleton_method" => "method",
"arrow_function" | "variable_declarator" | "var_spec" => "let",
"identifier" | "type_identifier" | "field_identifier" | "constant" | "word" => "name",
"interface_declaration" => "interface",
"union_specifier" => "union",
"module" => "module",
"create_table" => "table",
"create_view" => "view",
"create_index" => "index",
"create_trigger" => "trigger",
_ => "decl",
}
}
fn parse_header_line_count(header: &str) -> usize {
if let Some(rest) = header.strip_prefix('[') {
if let Some(n_str) = rest.strip_suffix(" lines total]") {
return n_str.parse().unwrap_or(0);
}
if let Some(inner) = rest.strip_suffix(']')
&& let Some(range) = inner.strip_prefix("Lines ")
&& let Some((start, end)) = range.split_once(" of ")
{
if let Some((lo, hi)) = start.split_once('-') {
let lo: usize = lo.parse().unwrap_or(0);
let hi: usize = hi.parse().unwrap_or(0);
return hi.saturating_sub(lo) + 1;
}
if let Ok(n) = start.parse::<usize>() {
let end_n: usize = end.parse().unwrap_or(0);
return end_n.saturating_sub(n) + 1;
}
}
}
0
}
fn shell_quote(s: &str) -> String {
let escaped = s.replace('\'', "'\\''");
format!("'{escaped}'")
}
async fn list_directory(resolved_path: &std::path::Path, ws: &Workspace) -> anyhow::Result<String> {
let quoted = shell_quote(&resolved_path.to_string_lossy());
let command = format!("ls -lA -- {quoted}");
let shell_tool = ShellTool::new(ShellMode::ReadOnly);
shell_tool.execute(ws, json!({"command": command})).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workspace::test_ws;
use std::path::PathBuf;
use tempfile::TempDir;
fn temp_workspace(files: &[(&str, &str)]) -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
for (rel_path, content) in files {
let full_path = dir.path().join(rel_path);
std::fs::create_dir_all(full_path.parent().unwrap()).unwrap();
std::fs::write(full_path, content).unwrap();
}
let path = dir.path().to_path_buf();
(dir, path)
}
#[test]
fn all_supported_extensions_have_language() {
let expected: &[&str] = &[
"rs", "js", "jsx", "mjs", "cjs", "ts", "tsx", "py", "pyi", "pyx", "json", "toml", "sh",
"bash", "zsh", "css", "html", "htm", "go", "rb", "c", "h", "sql",
];
for ext in expected {
assert!(
language_support(ext).is_some(),
"expected language support for .{ext}"
);
}
}
#[test]
fn unsupported_extensions_return_none() {
let unsupported: &[&str] = &[
"txt", "yml", "yaml", "xml", "svg", "config", "ini", "cfg", "log", "csv", "tsv", "pdf",
"png", "jpg", "gif", "woff", "ttf",
];
for ext in unsupported {
assert!(
language_support(ext).is_none(),
"expected no language support for .{ext}"
);
}
}
#[tokio::test]
async fn file_read_basic_scenarios() {
let (_dir, ws_path) = temp_workspace(&[("test.txt", "hello world")]);
let result = ReadTool
.execute(&Workspace::from_path(&ws_path), json!({"path": "test.txt"}))
.await;
assert!(result.is_ok(), "read should succeed: {result:?}");
let result = result.unwrap();
assert!(result.contains("1: hello world"));
assert!(result.contains("[1 lines total]"));
let result = ReadTool
.execute(&Workspace::from_path(&ws_path), json!({"path": "nope.txt"}))
.await;
assert!(
result.is_err(),
"read should fail for nonexistent file: {result:?}"
);
let err = format!("{}", result.unwrap_err());
assert!(err.contains("File not found"));
tokio::fs::write(ws_path.join("empty.txt"), "")
.await
.unwrap();
let result = ReadTool
.execute(
&Workspace::from_path(&ws_path),
json!({"path": "empty.txt"}),
)
.await;
assert!(result.is_ok(), "empty file read should succeed: {result:?}");
let result = result.unwrap();
assert_eq!(result, "");
}
#[tokio::test]
async fn read_wildcard_without_search_index_returns_helpful_error() {
let (_dir, ws_path) = temp_workspace(&[("alpha.rs", "fn alpha() {}")]);
let result = ReadTool
.execute(&test_ws(&ws_path), json!({"path": "*.rs"}))
.await;
assert!(
result.is_err(),
"wildcard without index should fail: {result:?}"
);
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("search index") || err.contains("Wildcard"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn file_read_blocks_unsafe_paths() {
let (dir1, ws_path1) = temp_workspace(&[]);
let result = ReadTool
.execute(
&Workspace::from_path(&ws_path1),
json!({"path": "../../../etc/passwd"}),
)
.await;
assert!(result.is_err(), "traversal should be blocked: {result:?}");
let err = format!("{}", result.unwrap_err());
assert!(err.contains("not allowed"));
let result = ReadTool
.execute(
&Workspace::from_path(&ws_path1),
json!({"path": "/etc/passwd"}),
)
.await;
assert!(
result.is_err(),
"absolute path should be blocked: {result:?}"
);
let err = format!("{}", result.unwrap_err());
assert!(err.contains("not allowed"));
drop(dir1);
let (_dir2, ws_path2) = temp_workspace(&[]);
let result = ReadTool
.execute(
&Workspace::from_path(&ws_path2),
json!({"path": "test\0evil.txt"}),
)
.await;
assert!(
result.is_err(),
"null byte path should be blocked: {result:?}"
);
let err = format!("{}", result.unwrap_err());
assert!(err.contains("not allowed"));
}
#[tokio::test]
async fn file_read_nested_path() {
let (_dir, ws_path) = temp_workspace(&[("sub/dir/deep.txt", "deep content")]);
let result = ReadTool
.execute(
&Workspace::from_path(&ws_path),
json!({"path": "sub/dir/deep.txt"}),
)
.await;
assert!(
result.is_ok(),
"nested path read should succeed: {result:?}"
);
let result = result.unwrap();
assert!(result.contains("1: deep content"));
}
#[cfg(unix)]
#[tokio::test]
async fn file_read_blocks_symlink_escape() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let workspace = root.path().join("workspace");
tokio::fs::create_dir_all(&workspace).await.unwrap();
symlink("/etc/passwd", workspace.join("escape.txt")).unwrap();
let result = ReadTool
.execute(
&Workspace::from_path(&workspace),
json!({"path": "escape.txt"}),
)
.await;
assert!(
result.is_err(),
"symlink escape should be blocked: {result:?}"
);
let err = format!("{}", result.unwrap_err());
assert!(err.contains("security policy"));
}
#[tokio::test]
async fn file_read_offset_handling() {
let (_dir, ws_path) = temp_workspace(&[("lines.txt", "aaa\nbbb\nccc\nddd\neee")]);
let result = ReadTool
.execute(
&Workspace::from_path(&ws_path),
json!({"path": "lines.txt", "offset": 2, "limit": 2}),
)
.await;
assert!(result.is_ok(), "offset read should succeed: {result:?}");
let result = result.unwrap();
assert!(result.contains("2: bbb") && result.contains("3: ccc"));
assert!(!result.contains("1: aaa") && !result.contains("4: ddd"));
let result = ReadTool
.execute(
&Workspace::from_path(&ws_path),
json!({"path": "lines.txt", "offset": 4}),
)
.await;
assert!(result.is_ok(), "offset to end should succeed: {result:?}");
let result = result.unwrap();
assert!(result.contains("4: ddd") && result.contains("5: eee"));
let result = ReadTool
.execute(
&Workspace::from_path(&ws_path),
json!({"path": "lines.txt", "limit": 2}),
)
.await;
assert!(result.is_ok(), "limit read should succeed: {result:?}");
let result = result.unwrap();
assert!(!result.contains("3: ccc"));
tokio::fs::write(ws_path.join("short.txt"), "one\ntwo")
.await
.unwrap();
let result = ReadTool
.execute(
&Workspace::from_path(&ws_path),
json!({"path": "short.txt", "offset": 100}),
)
.await;
assert!(
result.is_ok(),
"offset beyond end should succeed: {result:?}"
);
let result = result.unwrap();
assert!(result.contains("[No lines in range, file has 2 lines]"));
}
#[tokio::test]
async fn file_read_rejects_oversized_file() {
let dir = TempDir::new().unwrap();
let ws_path = dir.path().to_path_buf();
let big = vec![b'x'; 10 * 1024 * 1024 + 1];
tokio::fs::write(ws_path.join("huge.bin"), &big)
.await
.unwrap();
let result = ReadTool
.execute(&Workspace::from_path(&ws_path), json!({"path": "huge.bin"}))
.await;
assert!(
result.is_err(),
"oversized file should be rejected: {result:?}"
);
let err = format!("{}", result.unwrap_err());
assert!(err.contains("File too large"));
}
#[tokio::test]
async fn file_read_lossy_reads_binary_file() {
let dir = TempDir::new().unwrap();
let ws_path = dir.path().to_path_buf();
let binary_data: Vec<u8> = vec![0x00, 0x80, 0xFF, 0xFE, b'h', b'i', 0x80];
tokio::fs::write(ws_path.join("data.bin"), &binary_data)
.await
.unwrap();
let result = ReadTool
.execute(&Workspace::from_path(&ws_path), json!({"path": "data.bin"}))
.await;
assert!(
result.is_ok(),
"lossy read must succeed, error: {:?}",
result.as_ref().unwrap_err()
);
let result = result.unwrap();
assert!(
result.contains('\u{FFFD}'),
"lossy output must contain replacement character, got: {result:?}",
);
assert!(
result.contains("hi"),
"lossy output must preserve valid ASCII, got: {result:?}",
);
}
#[test]
fn format_output_short_passthrough() {
let input = "[3 lines total]\n1: a\n2: b\n3: c";
let result = ReadTool.format_output(input);
assert_eq!(result, input);
}
#[test]
fn format_output_truncates_at_line_boundary() {
let header = "[500 lines total]";
let body_lines: String = (1..=500)
.map(|i| format!("{}: {}", i, "x".repeat(200)))
.collect::<Vec<_>>()
.join("\n");
let input = format!("{header}\n{body_lines}");
let result = ReadTool.format_output(&input);
assert!(result.starts_with(header), "header must be first");
assert!(
result.contains("lines omitted)"),
"must contain omitted count, got: {result}"
);
assert!(
!result.contains("more bytes"),
"must not contain head+tail marker"
);
let omitted: usize = result
.lines()
.last()
.and_then(|l| l.strip_prefix("... ("))
.and_then(|l| l.strip_suffix(" lines omitted)"))
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let kept = result.lines().count() - 2; assert_eq!(kept + omitted, 500, "kept + omitted must equal 500");
}
#[test]
fn format_output_fallback_for_unstructured_output() {
let input = "a".repeat(6000);
let result = ReadTool.format_output(&input);
assert!(result.contains("bytes omitted at tool output truncation"));
}
#[tokio::test]
async fn symbols_mode_lists_rust_symbols() {
let code = r"
fn hello() {}
struct Point { x: i32, y: i32 }
enum Color { Red, Blue }
trait Draw { fn draw(&self); }
impl Point { fn new() -> Self { Point { x: 0, y: 0 } } }
const MAX: usize = 100;
type MyInt = i32;
macro_rules! my_macro { () => {} }
mod utils;
";
let (_dir, ws_path) = temp_workspace(&[("lib.rs", code)]);
let result = ReadTool
.execute(
&Workspace::from_path(&ws_path),
json!({"path": "lib.rs", "mode": "symbols"}),
)
.await;
assert!(
result.is_ok(),
"symbols failed: {:?}",
result.as_ref().unwrap_err()
);
let result = result.unwrap();
assert!(result.contains("[Symbols in lib.rs]"), "missing header");
assert!(result.contains("fn `hello`"), "missing fn hello");
assert!(result.contains("struct `Point`"), "missing struct Point");
assert!(result.contains("enum `Color`"), "missing enum Color");
assert!(result.contains("trait `Draw`"), "missing trait Draw");
assert!(result.contains("impl `Point`"), "missing impl Point");
assert!(result.contains("const `MAX`"), "missing const MAX");
assert!(result.contains("type `MyInt`"), "missing type MyInt");
assert!(result.contains("mod `utils`"), "missing mod utils");
}
#[tokio::test]
async fn symbols_mode_unsupported_extension() {
let (_dir, ws_path) = temp_workspace(&[("data.yaml", "{}")]);
let result = ReadTool
.execute(
&Workspace::from_path(&ws_path),
json!({"path": "data.yaml", "mode": "symbols"}),
)
.await;
assert!(
result.is_err(),
"unsupported extension should fail: {result:?}"
);
let err = format!("{}", result.unwrap_err());
assert!(err.contains("Unsupported"));
}
#[tokio::test]
async fn zoom_mode_extracts_rust_function() {
let code =
"fn greet(name: &str) -> String {\n format!(\"Hi, {name}!\")\n}\n\nfn main() {}";
let (_dir, ws_path) = temp_workspace(&[("main.rs", code)]);
let result = ReadTool
.execute(
&test_ws(&ws_path),
json!({"path": "main.rs", "mode": "zoom", "symbol": "greet"}),
)
.await;
assert!(
result.is_ok(),
"zoom failed: {:?}",
result.as_ref().unwrap_err()
);
let result = result.unwrap();
assert!(result.contains("fn `greet`"), "missing fn greet label");
assert!(
result.contains("format!(\"Hi, {name}!\")"),
"missing function body"
);
}
#[tokio::test]
async fn zoom_mode_symbol_not_found() {
let (_dir, ws_path) = temp_workspace(&[("lib.rs", "fn existing() {}")]);
let result = ReadTool
.execute(
&test_ws(&ws_path),
json!({"path": "lib.rs", "mode": "zoom", "symbol": "nope"}),
)
.await;
assert!(result.is_err(), "missing symbol should fail: {result:?}");
let err = format!("{}", result.unwrap_err());
assert!(err.contains("'nope'"), "missing symbol name in error");
assert!(
err.contains("Did you mean"),
"should suggest available symbols: {err}"
);
assert!(
err.contains("existing"),
"should list existing symbol: {err}"
);
}
#[tokio::test]
async fn zoom_mode_missing_symbol_param() {
let (_dir, ws_path) = temp_workspace(&[("lib.rs", "fn f() {}")]);
let result = ReadTool
.execute(
&Workspace::from_path(&ws_path),
json!({"path": "lib.rs", "mode": "zoom"}),
)
.await;
assert!(
result.is_err(),
"missing symbol param should fail: {result:?}"
);
let err = format!("{}", result.unwrap_err());
assert!(err.contains("Missing 'symbol' parameter"));
}
#[tokio::test]
async fn directory_listing_returns_contents() {
let (_dir, ws_path) = temp_workspace(&[("a.txt", "alpha"), ("b.rs", "beta")]);
tokio::fs::create_dir(ws_path.join("sub")).await.unwrap();
let result = ReadTool
.execute(&Workspace::from_path(&ws_path), json!({"path": "."}))
.await;
assert!(result.is_ok(), "dir listing should succeed: {result:?}");
let output = result.unwrap();
assert!(output.contains("a.txt"), "should list a.txt: {output}");
assert!(output.contains("b.rs"), "should list b.rs: {output}");
assert!(output.contains("sub/"), "should list sub/: {output}");
assert!(!output.contains("Path is a directory"), "should not error");
}
#[tokio::test]
async fn directory_listing_subdir_without_trailing_slash() {
let (_dir, ws_path) = temp_workspace(&[("sub/inside.txt", "nested")]);
let result = ReadTool
.execute(&Workspace::from_path(&ws_path), json!({"path": "sub"}))
.await;
assert!(
result.is_ok(),
"subdir without trailing slash should list: {result:?}"
);
let output = result.unwrap();
assert!(
output.contains("inside.txt"),
"should list inside.txt: {output}"
);
assert!(
!output.contains("File not found"),
"should not report missing file: {output}"
);
}
#[tokio::test]
async fn directory_listing_empty() {
let (_dir, ws_path) = temp_workspace(&[]);
let result = ReadTool
.execute(&Workspace::from_path(&ws_path), json!({"path": "."}))
.await;
assert!(
result.is_ok(),
"empty dir listing should succeed: {result:?}"
);
let output = result.unwrap();
assert!(
output.contains("total 0") || output.contains("(empty)"),
"empty dir should indicate emptiness: {output}"
);
}
#[tokio::test]
async fn directory_listing_spaces_in_path() {
let dir = TempDir::new().unwrap();
let ws_path = dir.path().join("my workspace");
tokio::fs::create_dir_all(&ws_path).await.unwrap();
tokio::fs::write(ws_path.join("my file.txt"), "content")
.await
.unwrap();
let result = ReadTool
.execute(&Workspace::from_path(&ws_path), json!({"path": "."}))
.await;
assert!(result.is_ok(), "dir with spaces should succeed: {result:?}");
let output = result.unwrap();
assert!(output.contains("my file.txt"), "should list file: {output}");
}
#[tokio::test]
async fn directory_listing_symlink() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let ws_path = dir.path().to_path_buf();
let real_dir = ws_path.join("real");
tokio::fs::create_dir_all(&real_dir).await.unwrap();
tokio::fs::write(real_dir.join("nested.txt"), "data")
.await
.unwrap();
let link = ws_path.join("link_to_real");
symlink(&real_dir, &link).unwrap();
let result = ReadTool
.execute(
&Workspace::from_path(&ws_path),
json!({"path": "link_to_real"}),
)
.await;
assert!(
result.is_ok(),
"symlinked dir listing should succeed: {result:?}"
);
let output = result.unwrap();
assert!(
output.contains("nested.txt"),
"should list nested file: {output}"
);
}
#[test]
fn shell_quoting_edge_cases() {
assert_eq!(shell_quote("/tmp/dir"), "'/tmp/dir'");
assert_eq!(shell_quote("/my dir/file"), "'/my dir/file'");
assert_eq!(shell_quote("/it's dir"), "'/it'\\''s dir'");
assert_eq!(shell_quote("/$dir"), "'/$dir'");
assert_eq!(shell_quote("/`dir`"), "'/`dir`'");
assert_eq!(shell_quote("/dir\\name"), "'/dir\\name'");
assert_eq!(shell_quote(""), "''");
assert_eq!(shell_quote("normal"), "'normal'");
}
#[test]
fn is_sensitive_file_path_env_and_certs() {
assert!(is_sensitive_file_path(".env"));
assert!(is_sensitive_file_path("proj/.env"));
assert!(is_sensitive_file_path(".env.local"));
assert!(is_sensitive_file_path("/abs/path/.env.production"));
assert!(is_sensitive_file_path("secrets/local.env"));
assert!(is_sensitive_file_path("tls/cert.pem"));
assert!(is_sensitive_file_path("C:\\keys\\id_rsa.key"));
assert!(!is_sensitive_file_path("src/main.rs"));
assert!(!is_sensitive_file_path("crates/foo/lib.rs"));
assert!(!is_sensitive_file_path("README.md"));
}
#[tokio::test]
async fn prepare_symbol_query_valid_file() {
let (_dir, ws_path) = temp_workspace(&[("lib.rs", "fn hello() {}\nstruct World;\n")]);
let file_path = ws_path.join("lib.rs");
let result = prepare_symbol_query(&file_path, "test").await;
assert!(
result.is_ok(),
"prepare_symbol_query should succeed for .rs: {result:?}"
);
let ctx = result.unwrap();
assert_eq!(ctx.ps.ext, "rs");
let symbols = collect_symbols(&ctx.ps, &ctx.query);
assert_eq!(symbols.len(), 2, "expected 2 symbols, got {symbols:?}");
assert!(symbols.iter().any(|s| s.name == "hello"));
assert!(symbols.iter().any(|s| s.name == "World"));
}
#[tokio::test]
async fn prepare_symbol_query_unsupported_extension() {
let (_dir, ws_path) = temp_workspace(&[("data.txt", "hello world")]);
let file_path = ws_path.join("data.txt");
let result = prepare_symbol_query(&file_path, "test").await;
assert!(result.is_err(), "expected error for unsupported extension");
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("Unsupported"),
"error should mention unsupported: {err}"
);
}
#[tokio::test]
async fn collect_symbols_empty_file() {
let (_dir, ws_path) = temp_workspace(&[("empty.rs", "")]);
let file_path = ws_path.join("empty.rs");
let ctx = prepare_symbol_query(&file_path, "test").await.unwrap();
let symbols = collect_symbols(&ctx.ps, &ctx.query);
assert!(
symbols.is_empty(),
"expected no symbols in empty file, got {symbols:?}"
);
}
#[tokio::test]
async fn collect_symbols_multiple_captures() {
let code = r"
fn foo() {}
fn bar() {}
struct Baz;
enum Qux {}
impl Baz {}
";
let (_dir, ws_path) = temp_workspace(&[("main.rs", code)]);
let file_path = ws_path.join("main.rs");
let ctx = prepare_symbol_query(&file_path, "test").await.unwrap();
let symbols = collect_symbols(&ctx.ps, &ctx.query);
assert_eq!(symbols.len(), 5, "expected 5 symbols, got {symbols:?}");
let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"foo"));
assert!(names.contains(&"bar"));
assert!(names.contains(&"Baz"));
assert!(names.contains(&"Qux"));
}
#[tokio::test]
async fn execute_symbols_integration() {
let (_dir, ws_path) = temp_workspace(&[("app.rs", "fn greet() {}\nstruct Person;\n")]);
let result = ReadTool
.execute(
&crate::workspace::test_ws(&ws_path),
json!({"path": "app.rs", "mode": "symbols"}),
)
.await;
assert!(result.is_ok(), "execute_symbols should succeed: {result:?}");
let output = result.unwrap();
assert!(
output.contains("`greet`"),
"output should contain greet: {output}"
);
assert!(
output.contains("`Person`"),
"output should contain Person: {output}"
);
assert!(
output.contains("fn"),
"output should have 'fn' kind label: {output}"
);
assert!(
output.contains("struct"),
"output should have 'struct' kind label: {output}"
);
}
#[tokio::test]
async fn collect_symbols_preserves_line_numbers() {
let code = "fn hello() {}\n\n\nfn world() {}\n";
let (_dir, ws_path) = temp_workspace(&[("lib.rs", code)]);
let file_path = ws_path.join("lib.rs");
let ctx = prepare_symbol_query(&file_path, "test").await.unwrap();
let symbols = collect_symbols(&ctx.ps, &ctx.query);
let hello = symbols.iter().find(|s| s.name == "hello").unwrap();
let world = symbols.iter().find(|s| s.name == "world").unwrap();
assert_eq!(hello.start_line, 1, "hello starts at line 1");
assert_eq!(world.start_line, 4, "world starts at line 4");
}
}