use crate::agent_cx::AgentCx;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::extensions::strip_unc_prefix;
use crate::model::{ContentBlock, ImageContent, TextContent};
use asupersync::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, ReadBuf, SeekFrom};
use asupersync::time::{sleep, wall_now};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::fmt::Write as _;
use std::io::{BufRead, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{OnceLock, mpsc};
use std::thread;
use std::time::{Duration, Instant};
use unicode_normalization::UnicodeNormalization;
use uuid::Uuid;
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn label(&self) -> &str;
fn description(&self) -> &str;
fn parameters(&self) -> serde_json::Value;
async fn execute(
&self,
tool_call_id: &str,
input: serde_json::Value,
on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
) -> Result<ToolOutput>;
fn is_read_only(&self) -> bool {
false
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolOutput {
pub content: Vec<ContentBlock>,
pub details: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "is_false")]
pub is_error: bool,
}
#[allow(clippy::trivially_copy_pass_by_ref)] const fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolUpdate {
pub content: Vec<ContentBlock>,
pub details: Option<serde_json::Value>,
}
pub const DEFAULT_MAX_LINES: usize = 2000;
pub const DEFAULT_MAX_BYTES: usize = 50 * 1024;
pub const GREP_MAX_LINE_LENGTH: usize = 500;
pub const DEFAULT_GREP_LIMIT: usize = 100;
pub const DEFAULT_FIND_LIMIT: usize = 1000;
pub const DEFAULT_LS_LIMIT: usize = 500;
pub const LS_SCAN_HARD_LIMIT: usize = 20_000;
pub const READ_TOOL_MAX_BYTES: u64 = 100 * 1024 * 1024;
pub const WRITE_TOOL_MAX_BYTES: usize = 100 * 1024 * 1024;
pub const IMAGE_MAX_BYTES: usize = 4_718_592;
pub const DEFAULT_BASH_TIMEOUT_SECS: u64 = 120;
const BASH_TERMINATE_GRACE_SECS: u64 = 5;
pub(crate) const BASH_FILE_LIMIT_BYTES: usize = 100 * 1024 * 1024;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TruncationResult {
pub content: String,
pub truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub truncated_by: Option<TruncatedBy>,
pub total_lines: usize,
pub total_bytes: usize,
pub output_lines: usize,
pub output_bytes: usize,
pub last_line_partial: bool,
pub first_line_exceeds_limit: bool,
pub max_lines: usize,
pub max_bytes: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum TruncatedBy {
Lines,
Bytes,
}
#[allow(clippy::too_many_lines)]
pub fn truncate_head(
content: impl Into<String>,
max_lines: usize,
max_bytes: usize,
) -> TruncationResult {
let mut content = content.into();
let total_bytes = content.len();
let total_lines = {
let nl = memchr::memchr_iter(b'\n', content.as_bytes()).count();
if content.is_empty() {
0
} else if content.ends_with('\n') {
nl
} else {
nl + 1
}
};
if max_lines == 0 {
let truncated = !content.is_empty();
return TruncationResult {
content: String::new(),
truncated,
truncated_by: if truncated {
Some(TruncatedBy::Lines)
} else {
None
},
total_lines,
total_bytes,
output_lines: 0,
output_bytes: 0,
last_line_partial: false,
first_line_exceeds_limit: false,
max_lines,
max_bytes,
};
}
if total_lines <= max_lines && total_bytes <= max_bytes {
return TruncationResult {
content,
truncated: false,
truncated_by: None,
total_lines,
total_bytes,
output_lines: total_lines,
output_bytes: total_bytes,
last_line_partial: false,
first_line_exceeds_limit: false,
max_lines,
max_bytes,
};
}
let first_newline = memchr::memchr(b'\n', content.as_bytes());
let first_line_bytes = first_newline.unwrap_or(content.len());
if first_line_bytes > max_bytes {
let mut limit = max_bytes;
while limit > 0 && !content.is_char_boundary(limit) {
limit -= 1;
}
content.truncate(limit);
return TruncationResult {
content,
truncated: true,
truncated_by: Some(TruncatedBy::Bytes),
total_lines,
total_bytes,
output_lines: 1,
output_bytes: limit,
last_line_partial: true,
first_line_exceeds_limit: true,
max_lines,
max_bytes,
};
}
let mut line_count = 0;
let mut byte_count: usize = 0;
let mut truncated_by = None;
let mut iter = content.split('\n').peekable();
let mut i = 0;
while let Some(line) = iter.next() {
if i >= max_lines {
truncated_by = Some(TruncatedBy::Lines);
break;
}
let has_newline = iter.peek().is_some();
let line_len = line.len() + usize::from(has_newline);
if byte_count + line_len > max_bytes {
truncated_by = Some(TruncatedBy::Bytes);
break;
}
line_count += 1;
byte_count += line_len;
i += 1;
}
content.truncate(byte_count);
TruncationResult {
truncated: truncated_by.is_some(),
truncated_by,
total_lines,
total_bytes,
output_lines: line_count,
output_bytes: byte_count,
last_line_partial: false,
first_line_exceeds_limit: false,
max_lines,
max_bytes,
content,
}
}
#[allow(clippy::too_many_lines)]
pub fn truncate_tail(
content: impl Into<String>,
max_lines: usize,
max_bytes: usize,
) -> TruncationResult {
let mut content = content.into();
let total_bytes = content.len();
let mut total_lines = memchr::memchr_iter(b'\n', content.as_bytes()).count();
if !content.ends_with('\n') && !content.is_empty() {
total_lines += 1;
}
if content.is_empty() {
total_lines = 0;
}
if max_lines == 0 {
let truncated = !content.is_empty();
return TruncationResult {
content: String::new(),
truncated,
truncated_by: if truncated {
Some(TruncatedBy::Lines)
} else {
None
},
total_lines,
total_bytes,
output_lines: 0,
output_bytes: 0,
last_line_partial: false,
first_line_exceeds_limit: false,
max_lines,
max_bytes,
};
}
if total_lines <= max_lines && total_bytes <= max_bytes {
return TruncationResult {
content,
truncated: false,
truncated_by: None,
total_lines,
total_bytes,
output_lines: total_lines,
output_bytes: total_bytes,
last_line_partial: false,
first_line_exceeds_limit: false,
max_lines,
max_bytes,
};
}
let mut line_count = 0usize;
let mut byte_count = 0usize;
let mut start_idx = content.len();
let mut partial_output: Option<String> = None;
let mut truncated_by = None;
let mut last_line_partial = false;
{
let bytes = content.as_bytes();
let mut search_limit = bytes.len();
if search_limit > 0 && bytes[search_limit - 1] == b'\n' {
search_limit -= 1;
}
loop {
let prev_newline = memchr::memrchr(b'\n', &bytes[..search_limit]);
let line_start = prev_newline.map_or(0, |idx| idx + 1);
let added_bytes = start_idx - line_start;
if byte_count + added_bytes > max_bytes {
let remaining = max_bytes.saturating_sub(byte_count);
if remaining > 0 && line_count == 0 {
let chunk = &content[line_start..start_idx];
let truncated_chunk = truncate_string_to_bytes_from_end(chunk, remaining);
if !truncated_chunk.is_empty() {
partial_output = Some(truncated_chunk);
last_line_partial = true;
}
}
truncated_by = Some(TruncatedBy::Bytes);
break;
}
line_count += 1;
byte_count += added_bytes;
start_idx = line_start;
if line_count >= max_lines {
truncated_by = Some(TruncatedBy::Lines);
break;
}
if line_start == 0 {
break;
}
search_limit = line_start - 1;
}
}
let partial_suffix = if last_line_partial {
Some(content[start_idx..].to_string())
} else {
None
};
let mut output = partial_output.unwrap_or_else(|| {
drop(content.drain(..start_idx));
content
});
if let Some(suffix) = partial_suffix {
output.push_str(&suffix);
let mut count = memchr::memchr_iter(b'\n', output.as_bytes()).count();
if !output.ends_with('\n') && !output.is_empty() {
count += 1;
}
if output.is_empty() {
count = 0;
}
line_count = count;
}
let output_bytes = output.len();
TruncationResult {
content: output,
truncated: truncated_by.is_some(),
truncated_by,
total_lines,
total_bytes,
output_lines: line_count,
output_bytes,
last_line_partial,
first_line_exceeds_limit: false,
max_lines,
max_bytes,
}
}
fn truncate_string_to_bytes_from_end(s: &str, max_bytes: usize) -> String {
let bytes = s.as_bytes();
if bytes.len() <= max_bytes {
return s.to_string();
}
let mut start = bytes.len().saturating_sub(max_bytes);
while start < bytes.len() && (bytes[start] & 0b1100_0000) == 0b1000_0000 {
start += 1;
}
std::str::from_utf8(&bytes[start..])
.map(str::to_string)
.unwrap_or_default()
}
#[allow(clippy::cast_precision_loss)]
fn format_size(bytes: usize) -> String {
const KB: usize = 1024;
const MB: usize = 1024 * 1024;
if bytes >= MB {
format!("{:.1}MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1}KB", bytes as f64 / KB as f64)
} else {
format!("{bytes}B")
}
}
fn js_string_length(s: &str) -> usize {
s.encode_utf16().count()
}
fn is_special_unicode_space(c: char) -> bool {
matches!(c, '\u{00A0}' | '\u{202F}' | '\u{205F}' | '\u{3000}')
|| ('\u{2000}'..='\u{200A}').contains(&c)
}
fn normalize_unicode_spaces(s: &str) -> String {
s.chars()
.map(|c| if is_special_unicode_space(c) { ' ' } else { c })
.collect()
}
fn normalize_quotes(s: &str) -> String {
s.replace(['\u{2018}', '\u{2019}'], "'")
.replace(['\u{201C}', '\u{201D}', '\u{201E}', '\u{201F}'], "\"")
}
fn normalize_dashes(s: &str) -> String {
s.replace(
[
'\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2015}', '\u{2212}',
],
"-",
)
}
fn normalize_for_match(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
c if is_special_unicode_space(c) => out.push(' '),
'\u{2018}' | '\u{2019}' => out.push('\''),
'\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => out.push('"'),
'\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
| '\u{2212}' => out.push('-'),
c => out.push(c),
}
}
out
}
fn normalize_line_for_match(line: &str) -> String {
normalize_for_match(line.trim_end())
}
fn expand_path(file_path: &str) -> String {
let normalized = normalize_unicode_spaces(file_path);
if normalized == "~" {
return dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.to_string_lossy()
.to_string();
}
if let Some(rest) = normalized.strip_prefix("~/") {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
return home.join(rest).to_string_lossy().to_string();
}
normalized
}
fn resolve_to_cwd(file_path: &str, cwd: &Path) -> PathBuf {
let expanded = expand_path(file_path);
let expanded_path = PathBuf::from(expanded);
if expanded_path.is_absolute() {
expanded_path
} else {
cwd.join(expanded_path)
}
}
fn try_mac_os_screenshot_path(file_path: &str) -> String {
file_path
.replace(" AM.", "\u{202F}AM.")
.replace(" PM.", "\u{202F}PM.")
}
fn try_curly_quote_variant(file_path: &str) -> String {
file_path.replace('\'', "\u{2019}")
}
fn try_nfd_variant(file_path: &str) -> String {
use unicode_normalization::UnicodeNormalization;
file_path.nfd().collect::<String>()
}
fn file_exists(path: &Path) -> bool {
std::fs::metadata(path).is_ok()
}
pub(crate) fn resolve_read_path(file_path: &str, cwd: &Path) -> PathBuf {
let resolved = resolve_to_cwd(file_path, cwd);
if file_exists(&resolved) {
return resolved;
}
let Some(resolved_str) = resolved.to_str() else {
return resolved;
};
let am_pm_variant = try_mac_os_screenshot_path(resolved_str);
if am_pm_variant != resolved_str && file_exists(Path::new(&am_pm_variant)) {
return PathBuf::from(am_pm_variant);
}
let nfd_variant = try_nfd_variant(resolved_str);
if nfd_variant != resolved_str && file_exists(Path::new(&nfd_variant)) {
return PathBuf::from(nfd_variant);
}
let curly_variant = try_curly_quote_variant(resolved_str);
if curly_variant != resolved_str && file_exists(Path::new(&curly_variant)) {
return PathBuf::from(curly_variant);
}
let nfd_curly_variant = try_curly_quote_variant(&nfd_variant);
if nfd_curly_variant != resolved_str && file_exists(Path::new(&nfd_curly_variant)) {
return PathBuf::from(nfd_curly_variant);
}
resolved
}
#[derive(Debug, Clone, Default)]
pub struct ProcessedFiles {
pub text: String,
pub images: Vec<ImageContent>,
}
fn normalize_dot_segments(path: &Path) -> PathBuf {
use std::ffi::{OsStr, OsString};
use std::path::Component;
let mut out = PathBuf::new();
let mut normals: Vec<OsString> = Vec::new();
let mut has_prefix = false;
let mut has_root = false;
for component in path.components() {
match component {
Component::Prefix(prefix) => {
out.push(prefix.as_os_str());
has_prefix = true;
}
Component::RootDir => {
out.push(component.as_os_str());
has_root = true;
}
Component::CurDir => {}
Component::ParentDir => match normals.last() {
Some(last) if last.as_os_str() != OsStr::new("..") => {
normals.pop();
}
_ => {
if !has_root && !has_prefix {
normals.push(OsString::from(".."));
}
}
},
Component::Normal(part) => normals.push(part.to_os_string()),
}
}
for part in normals {
out.push(part);
}
out
}
#[cfg(feature = "fuzzing")]
pub fn fuzz_normalize_dot_segments(path: &Path) -> PathBuf {
normalize_dot_segments(path)
}
fn escape_file_tag_attribute(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => escaped.push_str("&"),
'"' => escaped.push_str("""),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'\n' => escaped.push_str(" "),
'\r' => escaped.push_str(" "),
'\t' => escaped.push_str("	"),
_ => escaped.push(ch),
}
}
escaped
}
fn escaped_file_tag_name(path: &Path) -> String {
escape_file_tag_attribute(&path.display().to_string())
}
fn append_file_notice_block(out: &mut String, path: &Path, notice: &str) {
let path_str = escaped_file_tag_name(path);
let _ = writeln!(out, "<file name=\"{path_str}\">\n{notice}\n</file>");
}
fn append_image_file_ref(out: &mut String, path: &Path, note: Option<&str>) {
let path_str = escaped_file_tag_name(path);
match note {
Some(text) => {
let _ = writeln!(out, "<file name=\"{path_str}\">{text}</file>");
}
None => {
let _ = writeln!(out, "<file name=\"{path_str}\"></file>");
}
}
}
fn append_text_file_block(out: &mut String, path: &Path, bytes: &[u8]) {
let content = String::from_utf8_lossy(bytes);
let path_str = escaped_file_tag_name(path);
let _ = writeln!(out, "<file name=\"{path_str}\">");
let truncation = truncate_head(content.into_owned(), DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
let needs_trailing_newline = !truncation.truncated && !truncation.content.ends_with('\n');
out.push_str(&truncation.content);
if truncation.truncated {
let _ = write!(
out,
"\n... [Truncated: showing {}/{} lines, {}/{} bytes]",
truncation.output_lines,
truncation.total_lines,
format_size(truncation.output_bytes),
format_size(truncation.total_bytes)
);
} else if needs_trailing_newline {
out.push('\n');
}
let _ = writeln!(out, "</file>");
}
fn maybe_append_image_argument(
out: &mut ProcessedFiles,
absolute_path: &Path,
bytes: &[u8],
auto_resize_images: bool,
) -> Result<bool> {
let Some(mime_type) = detect_supported_image_mime_type_from_bytes(bytes) else {
return Ok(false);
};
let resized = if auto_resize_images {
resize_image_if_needed(bytes, mime_type)?
} else {
ResizedImage::original(bytes.to_vec(), mime_type)
};
if resized.bytes.len() > IMAGE_MAX_BYTES {
let msg = if resized.resized {
format!(
"[Image is too large ({} bytes) after resizing. Max allowed is {} bytes.]",
resized.bytes.len(),
IMAGE_MAX_BYTES
)
} else {
format!(
"[Image is too large ({} bytes). Max allowed is {} bytes.]",
resized.bytes.len(),
IMAGE_MAX_BYTES
)
};
append_file_notice_block(&mut out.text, absolute_path, &msg);
return Ok(true);
}
let base64_data =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &resized.bytes);
out.images.push(ImageContent {
data: base64_data,
mime_type: resized.mime_type.to_string(),
});
let note = if resized.resized {
if let (Some(ow), Some(oh), Some(w), Some(h)) = (
resized.original_width,
resized.original_height,
resized.width,
resized.height,
) {
let scale = f64::from(ow) / f64::from(w);
Some(format!(
"[Image: original {ow}x{oh}, displayed at {w}x{h}. Multiply coordinates by {scale:.2} to map to original image.]"
))
} else {
None
}
} else {
None
};
append_image_file_ref(&mut out.text, absolute_path, note.as_deref());
Ok(true)
}
pub fn process_file_arguments(
file_args: &[String],
cwd: &Path,
auto_resize_images: bool,
) -> Result<ProcessedFiles> {
let mut out = ProcessedFiles::default();
for file_arg in file_args {
let resolved = resolve_read_path(file_arg, cwd);
let absolute_path = normalize_dot_segments(&resolved);
let meta = std::fs::metadata(&absolute_path).map_err(|e| {
Error::tool(
"read",
format!("Cannot access file {}: {e}", absolute_path.display()),
)
})?;
if meta.len() == 0 {
continue;
}
if meta.len() > READ_TOOL_MAX_BYTES {
append_file_notice_block(
&mut out.text,
&absolute_path,
&format!(
"[File is too large ({} bytes). Max allowed is {} bytes.]",
meta.len(),
READ_TOOL_MAX_BYTES
),
);
continue;
}
let bytes = std::fs::read(&absolute_path).map_err(|e| {
Error::tool(
"read",
format!("Could not read file {}: {e}", absolute_path.display()),
)
})?;
if maybe_append_image_argument(&mut out, &absolute_path, &bytes, auto_resize_images)? {
continue;
}
append_text_file_block(&mut out.text, &absolute_path, &bytes);
}
Ok(out)
}
fn resolve_path(file_path: &str, cwd: &Path) -> PathBuf {
resolve_to_cwd(file_path, cwd)
}
#[cfg(feature = "fuzzing")]
pub fn fuzz_resolve_path(file_path: &str, cwd: &Path) -> PathBuf {
resolve_path(file_path, cwd)
}
pub(crate) fn detect_supported_image_mime_type_from_bytes(bytes: &[u8]) -> Option<&'static str> {
if bytes.len() >= 8 && bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
return Some("image/png");
}
if bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF {
return Some("image/jpeg");
}
if bytes.len() >= 6 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")) {
return Some("image/gif");
}
if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
return Some("image/webp");
}
None
}
#[derive(Debug, Clone)]
pub(crate) struct ResizedImage {
pub(crate) bytes: Vec<u8>,
pub(crate) mime_type: &'static str,
pub(crate) resized: bool,
pub(crate) width: Option<u32>,
pub(crate) height: Option<u32>,
pub(crate) original_width: Option<u32>,
pub(crate) original_height: Option<u32>,
}
impl ResizedImage {
pub(crate) const fn original(bytes: Vec<u8>, mime_type: &'static str) -> Self {
Self {
bytes,
mime_type,
resized: false,
width: None,
height: None,
original_width: None,
original_height: None,
}
}
}
#[cfg(feature = "image-resize")]
#[allow(clippy::too_many_lines)]
pub(crate) fn resize_image_if_needed(
bytes: &[u8],
mime_type: &'static str,
) -> Result<ResizedImage> {
use image::codecs::jpeg::JpegEncoder;
use image::codecs::png::PngEncoder;
use image::imageops::FilterType;
use image::{GenericImageView, ImageEncoder, ImageReader, Limits};
use std::io::Cursor;
const MAX_WIDTH: u32 = 2000;
const MAX_HEIGHT: u32 = 2000;
const DEFAULT_JPEG_QUALITY: u8 = 80;
const QUALITY_STEPS: [u8; 4] = [85, 70, 55, 40];
const SCALE_STEPS: [f64; 5] = [1.0, 0.75, 0.5, 0.35, 0.25];
fn scale_u32(value: u32, numerator: u32, denominator: u32) -> u32 {
let den = u64::from(denominator).max(1);
let num = u64::from(value) * u64::from(numerator);
let rounded = (num + den / 2) / den;
u32::try_from(rounded).unwrap_or(u32::MAX)
}
fn encode_png(img: &image::DynamicImage) -> Result<Vec<u8>> {
let rgba = img.to_rgba8();
let mut out = Vec::new();
PngEncoder::new(&mut out)
.write_image(
rgba.as_raw(),
rgba.width(),
rgba.height(),
image::ExtendedColorType::Rgba8,
)
.map_err(|e| Error::tool("read", format!("Failed to encode PNG: {e}")))?;
Ok(out)
}
fn encode_jpeg(img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
let rgb = img.to_rgb8();
let mut out = Vec::new();
JpegEncoder::new_with_quality(&mut out, quality)
.write_image(
rgb.as_raw(),
rgb.width(),
rgb.height(),
image::ExtendedColorType::Rgb8,
)
.map_err(|e| Error::tool("read", format!("Failed to encode JPEG: {e}")))?;
Ok(out)
}
fn try_both_formats(
img: &image::DynamicImage,
width: u32,
height: u32,
jpeg_quality: u8,
) -> Result<(Vec<u8>, &'static str)> {
let resized = img.resize_exact(width, height, FilterType::Lanczos3);
let png = encode_png(&resized)?;
let jpeg = encode_jpeg(&resized, jpeg_quality)?;
if png.len() <= jpeg.len() {
Ok((png, "image/png"))
} else {
Ok((jpeg, "image/jpeg"))
}
}
let mut limits = Limits::default();
limits.max_alloc = Some(128 * 1024 * 1024);
let reader = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|e| Error::tool("read", format!("Failed to detect image format: {e}")))?;
let mut reader = reader;
reader.limits(limits);
let Ok(img) = reader.decode() else {
return Ok(ResizedImage::original(bytes.to_vec(), mime_type));
};
let (original_width, original_height) = img.dimensions();
let original_size = bytes.len();
if original_width <= MAX_WIDTH
&& original_height <= MAX_HEIGHT
&& original_size <= IMAGE_MAX_BYTES
{
return Ok(ResizedImage {
bytes: bytes.to_vec(),
mime_type,
resized: false,
width: Some(original_width),
height: Some(original_height),
original_width: Some(original_width),
original_height: Some(original_height),
});
}
let mut target_width = original_width;
let mut target_height = original_height;
if target_width > MAX_WIDTH {
target_height = scale_u32(target_height, MAX_WIDTH, target_width);
target_width = MAX_WIDTH;
}
if target_height > MAX_HEIGHT {
target_width = scale_u32(target_width, MAX_HEIGHT, target_height);
target_height = MAX_HEIGHT;
}
let mut best = try_both_formats(&img, target_width, target_height, DEFAULT_JPEG_QUALITY)?;
let mut final_width = target_width;
let mut final_height = target_height;
if best.0.len() <= IMAGE_MAX_BYTES {
return Ok(ResizedImage {
bytes: best.0,
mime_type: best.1,
resized: true,
width: Some(final_width),
height: Some(final_height),
original_width: Some(original_width),
original_height: Some(original_height),
});
}
for quality in QUALITY_STEPS {
best = try_both_formats(&img, target_width, target_height, quality)?;
if best.0.len() <= IMAGE_MAX_BYTES {
return Ok(ResizedImage {
bytes: best.0,
mime_type: best.1,
resized: true,
width: Some(final_width),
height: Some(final_height),
original_width: Some(original_width),
original_height: Some(original_height),
});
}
}
for scale in SCALE_STEPS {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
{
final_width = (f64::from(target_width) * scale).round() as u32;
final_height = (f64::from(target_height) * scale).round() as u32;
}
if final_width < 100 || final_height < 100 {
break;
}
for quality in QUALITY_STEPS {
best = try_both_formats(&img, final_width, final_height, quality)?;
if best.0.len() <= IMAGE_MAX_BYTES {
return Ok(ResizedImage {
bytes: best.0,
mime_type: best.1,
resized: true,
width: Some(final_width),
height: Some(final_height),
original_width: Some(original_width),
original_height: Some(original_height),
});
}
}
}
Ok(ResizedImage {
bytes: best.0,
mime_type: best.1,
resized: true,
width: Some(final_width),
height: Some(final_height),
original_width: Some(original_width),
original_height: Some(original_height),
})
}
#[cfg(not(feature = "image-resize"))]
pub(crate) fn resize_image_if_needed(
bytes: &[u8],
mime_type: &'static str,
) -> Result<ResizedImage> {
Ok(ResizedImage::original(bytes.to_vec(), mime_type))
}
pub struct ToolRegistry {
tools: Vec<Box<dyn Tool>>,
}
impl ToolRegistry {
pub fn new(enabled: &[&str], cwd: &Path, config: Option<&Config>) -> Self {
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
let shell_path = config.and_then(|c| c.shell_path.clone());
let shell_command_prefix = config.and_then(|c| c.shell_command_prefix.clone());
let image_auto_resize = config.is_none_or(Config::image_auto_resize);
let block_images = config
.and_then(|c| c.images.as_ref().and_then(|i| i.block_images))
.unwrap_or(false);
for name in enabled {
match *name {
"read" => tools.push(Box::new(ReadTool::with_settings(
cwd,
image_auto_resize,
block_images,
))),
"bash" => tools.push(Box::new(BashTool::with_shell(
cwd,
shell_path.clone(),
shell_command_prefix.clone(),
))),
"edit" => tools.push(Box::new(EditTool::new(cwd))),
"write" => tools.push(Box::new(WriteTool::new(cwd))),
"grep" => tools.push(Box::new(GrepTool::new(cwd))),
"find" => tools.push(Box::new(FindTool::new(cwd))),
"ls" => tools.push(Box::new(LsTool::new(cwd))),
_ => {}
}
}
Self { tools }
}
pub fn from_tools(tools: Vec<Box<dyn Tool>>) -> Self {
Self { tools }
}
pub fn into_tools(self) -> Vec<Box<dyn Tool>> {
self.tools
}
pub fn push(&mut self, tool: Box<dyn Tool>) {
self.tools.push(tool);
}
pub fn extend<I>(&mut self, tools: I)
where
I: IntoIterator<Item = Box<dyn Tool>>,
{
self.tools.extend(tools);
}
pub fn tools(&self) -> &[Box<dyn Tool>] {
&self.tools
}
pub fn get(&self, name: &str) -> Option<&dyn Tool> {
self.tools
.iter()
.find(|t| t.name() == name)
.map(std::convert::AsRef::as_ref)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReadInput {
path: String,
offset: Option<i64>,
limit: Option<i64>,
}
pub struct ReadTool {
cwd: PathBuf,
auto_resize: bool,
block_images: bool,
}
impl ReadTool {
pub fn new(cwd: &Path) -> Self {
Self {
cwd: cwd.to_path_buf(),
auto_resize: true,
block_images: false,
}
}
pub fn with_settings(cwd: &Path, auto_resize: bool, block_images: bool) -> Self {
Self {
cwd: cwd.to_path_buf(),
auto_resize,
block_images,
}
}
}
async fn read_some<R>(reader: &mut R, dst: &mut [u8]) -> std::io::Result<usize>
where
R: AsyncRead + Unpin,
{
if dst.is_empty() {
return Ok(0);
}
futures::future::poll_fn(|cx| {
let mut read_buf = ReadBuf::new(dst);
match std::pin::Pin::new(&mut *reader).poll_read(cx, &mut read_buf) {
std::task::Poll::Ready(Ok(())) => std::task::Poll::Ready(Ok(read_buf.filled().len())),
std::task::Poll::Ready(Err(err)) => std::task::Poll::Ready(Err(err)),
std::task::Poll::Pending => std::task::Poll::Pending,
}
})
.await
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Tool for ReadTool {
fn name(&self) -> &str {
"read"
}
fn label(&self) -> &str {
"read"
}
fn description(&self) -> &str {
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete."
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to read (relative or absolute)"
},
"offset": {
"type": "integer",
"description": "Line number to start reading from (1-indexed)"
},
"limit": {
"type": "integer",
"description": "Maximum number of lines to read"
}
},
"required": ["path"]
})
}
fn is_read_only(&self) -> bool {
true
}
#[allow(clippy::too_many_lines)]
async fn execute(
&self,
_tool_call_id: &str,
input: serde_json::Value,
_on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
) -> Result<ToolOutput> {
let input: ReadInput =
serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
if matches!(input.limit, Some(limit) if limit <= 0) {
return Err(Error::validation(
"`limit` must be greater than 0".to_string(),
));
}
if matches!(input.offset, Some(offset) if offset < 0) {
return Err(Error::validation(
"`offset` must be non-negative".to_string(),
));
}
let path = resolve_read_path(&input.path, &self.cwd);
if let Ok(meta) = asupersync::fs::metadata(&path).await {
if meta.len() > READ_TOOL_MAX_BYTES {
return Err(Error::tool(
"read",
format!(
"File is too large ({} bytes). Max allowed is {} bytes. For large files, use `bash` with `grep`, `head`, `tail`, or `sed`.",
meta.len(),
READ_TOOL_MAX_BYTES
),
));
}
}
let mut file = asupersync::fs::File::open(&path)
.await
.map_err(|e| Error::tool("read", e.to_string()))?;
let mut buffer = [0u8; 8192];
let mut initial_read = 0;
loop {
let n = read_some(&mut file, &mut buffer[initial_read..])
.await
.map_err(|e| Error::tool("read", format!("Failed to read file: {e}")))?;
if n == 0 {
break;
}
initial_read += n;
if initial_read == buffer.len() {
break;
}
}
let initial_bytes = &buffer[..initial_read];
if let Some(mime_type) = detect_supported_image_mime_type_from_bytes(initial_bytes) {
if self.block_images {
return Err(Error::tool(
"read",
"Images are blocked by configuration".to_string(),
));
}
let mut all_bytes = Vec::with_capacity(initial_read);
all_bytes.extend_from_slice(initial_bytes);
let remaining_limit = IMAGE_MAX_BYTES.saturating_sub(initial_read);
let mut limiter = file.take((remaining_limit as u64).saturating_add(1));
limiter
.read_to_end(&mut all_bytes)
.await
.map_err(|e| Error::tool("read", format!("Failed to read image: {e}")))?;
if all_bytes.len() > IMAGE_MAX_BYTES {
return Err(Error::tool(
"read",
format!(
"Image is too large ({} bytes). Max allowed is {} bytes.",
all_bytes.len(),
IMAGE_MAX_BYTES
),
));
}
let resized = if self.auto_resize {
resize_image_if_needed(&all_bytes, mime_type)?
} else {
ResizedImage::original(all_bytes, mime_type)
};
let base64_data =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &resized.bytes);
let mut note = format!("Read image file [{}]", resized.mime_type);
if resized.resized {
if let (Some(ow), Some(oh), Some(w), Some(h)) = (
resized.original_width,
resized.original_height,
resized.width,
resized.height,
) {
let scale = f64::from(ow) / f64::from(w);
let _ = write!(
note,
"\n[Image: original {ow}x{oh}, displayed at {w}x{h}. Multiply coordinates by {scale:.2} to map to original image.]"
);
}
}
return Ok(ToolOutput {
content: vec![
ContentBlock::Text(TextContent::new(note)),
ContentBlock::Image(ImageContent {
data: base64_data,
mime_type: resized.mime_type.to_string(),
}),
],
details: None,
is_error: false,
});
}
if initial_read > 0 {
file.seek(SeekFrom::Start(0))
.await
.map_err(|e| Error::tool("read", format!("Failed to seek: {e}")))?;
}
let mut raw_content = Vec::new();
let mut newlines_seen = 0usize;
let start_line_idx = match input.offset {
Some(n) if n > 0 => n.saturating_sub(1).try_into().unwrap_or(usize::MAX),
_ => 0,
};
let limit_lines = input
.limit
.map_or(usize::MAX, |l| l.try_into().unwrap_or(usize::MAX));
let end_line_idx = start_line_idx.saturating_add(limit_lines);
let mut collecting = start_line_idx == 0;
let mut buf = vec![0u8; 64 * 1024].into_boxed_slice(); let mut last_byte_was_newline = false;
let mut total_bytes_read = 0u64;
loop {
let n = read_some(&mut file, &mut buf)
.await
.map_err(|e| Error::tool("read", e.to_string()))?;
if n == 0 {
break;
}
total_bytes_read = total_bytes_read.saturating_add(n as u64);
if total_bytes_read > READ_TOOL_MAX_BYTES {
return Err(Error::tool(
"read",
format!(
"File grew beyond limit during read ({total_bytes_read} bytes). Max allowed is {READ_TOOL_MAX_BYTES} bytes."
),
));
}
let chunk = &buf[..n];
last_byte_was_newline = chunk[n - 1] == b'\n';
let mut chunk_cursor = 0;
for pos in memchr::memchr_iter(b'\n', chunk) {
if collecting {
if newlines_seen + 1 == end_line_idx {
if raw_content.len() < DEFAULT_MAX_BYTES {
let remaining = DEFAULT_MAX_BYTES - raw_content.len();
let slice_len = (pos + 1 - chunk_cursor).min(remaining);
raw_content
.extend_from_slice(&chunk[chunk_cursor..chunk_cursor + slice_len]);
}
collecting = false;
chunk_cursor = pos + 1;
}
}
newlines_seen += 1;
if !collecting && newlines_seen == start_line_idx {
collecting = true;
chunk_cursor = pos + 1;
}
}
if collecting && chunk_cursor < chunk.len() && raw_content.len() < DEFAULT_MAX_BYTES {
let remaining = DEFAULT_MAX_BYTES - raw_content.len();
let slice_len = (chunk.len() - chunk_cursor).min(remaining);
raw_content.extend_from_slice(&chunk[chunk_cursor..chunk_cursor + slice_len]);
}
}
let total_lines = if total_bytes_read == 0 {
0
} else if last_byte_was_newline {
newlines_seen
} else {
newlines_seen + 1
};
let text_content = String::from_utf8_lossy(&raw_content).into_owned();
if total_lines == 0 {
if input.offset.unwrap_or(0) > 0 {
let offset_display = input.offset.unwrap_or(0);
return Err(Error::tool(
"read",
format!(
"Offset {offset_display} is beyond end of file ({total_lines} lines total)"
),
));
}
return Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(""))],
details: None,
is_error: false,
});
}
let start_line = start_line_idx;
let start_line_display = start_line.saturating_add(1);
if start_line >= total_lines {
let offset_display = input.offset.unwrap_or(0);
return Err(Error::tool(
"read",
format!(
"Offset {offset_display} is beyond end of file ({total_lines} lines total)"
),
));
}
let max_lines_for_truncation = input
.limit
.and_then(|l| usize::try_from(l).ok())
.unwrap_or(DEFAULT_MAX_LINES);
let display_limit = max_lines_for_truncation.saturating_add(1);
let lines_to_take = limit_lines.min(display_limit);
let mut selected_content = String::new();
let line_iter = text_content.split('\n');
let effective_iter = if text_content.ends_with('\n') {
line_iter.take(lines_to_take)
} else {
line_iter.take(usize::MAX)
};
let max_line_num = start_line.saturating_add(lines_to_take).min(total_lines);
let line_num_width = max_line_num.to_string().len().max(5);
for (i, line) in effective_iter.enumerate() {
if i >= lines_to_take || start_line + i >= total_lines {
break;
}
if i > 0 {
selected_content.push('\n');
}
let line_num = start_line + i + 1;
let line = line.strip_suffix('\r').unwrap_or(line);
let _ = write!(selected_content, "{line_num:>line_num_width$}→{line}");
if selected_content.len() > DEFAULT_MAX_BYTES * 2 {
break;
}
}
let mut truncation = truncate_head(
selected_content,
max_lines_for_truncation,
DEFAULT_MAX_BYTES,
);
truncation.total_lines = total_lines;
let mut output_text = std::mem::take(&mut truncation.content);
let mut details: Option<serde_json::Value> = None;
if truncation.first_line_exceeds_limit {
let first_line = text_content.split('\n').next().unwrap_or("");
let first_line = first_line.strip_suffix('\r').unwrap_or(first_line);
let first_line_size = format_size(first_line.len());
output_text = format!(
"[Line {start_line_display} is {first_line_size}, exceeds {} limit. Use bash: sed -n '{start_line_display}p' \"{}\" | head -c {DEFAULT_MAX_BYTES}]",
format_size(DEFAULT_MAX_BYTES),
input.path.replace('"', "\\\"")
);
details = Some(serde_json::json!({ "truncation": truncation }));
} else if truncation.truncated {
let end_line_display = start_line_display
.saturating_add(truncation.output_lines)
.saturating_sub(1);
let next_offset = end_line_display.saturating_add(1);
if truncation.truncated_by == Some(TruncatedBy::Lines) {
let _ = write!(
output_text,
"\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines}. Use offset={next_offset} to continue.]"
);
} else {
let _ = write!(
output_text,
"\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines} ({} limit). Use offset={next_offset} to continue.]",
format_size(DEFAULT_MAX_BYTES)
);
}
details = Some(serde_json::json!({ "truncation": truncation }));
} else {
let displayed_lines = text_content
.split('\n')
.count()
.saturating_sub(usize::from(text_content.ends_with('\n')));
let end_line_display = start_line_display
.saturating_add(displayed_lines)
.saturating_sub(1);
if end_line_display < total_lines {
let remaining = total_lines.saturating_sub(end_line_display);
let next_offset = end_line_display.saturating_add(1);
let _ = write!(
output_text,
"\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]"
);
}
}
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(output_text))],
details,
is_error: false,
})
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BashInput {
command: String,
timeout: Option<u64>,
}
pub struct BashTool {
cwd: PathBuf,
shell_path: Option<String>,
command_prefix: Option<String>,
}
#[derive(Debug, Clone)]
pub struct BashRunResult {
pub output: String,
pub exit_code: i32,
pub cancelled: bool,
pub truncated: bool,
pub full_output_path: Option<String>,
pub truncation: Option<TruncationResult>,
}
#[allow(clippy::unnecessary_lazy_evaluations)] fn exit_status_code(status: std::process::ExitStatus) -> i32 {
status.code().unwrap_or_else(|| {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt as _;
status.signal().map_or(-1, |signal| -signal)
}
#[cfg(not(unix))]
{
-1
}
})
}
#[allow(clippy::too_many_lines)]
pub(crate) async fn run_bash_command(
cwd: &Path,
shell_path: Option<&str>,
command_prefix: Option<&str>,
command: &str,
timeout_secs: Option<u64>,
on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
) -> Result<BashRunResult> {
let timeout_secs = match timeout_secs {
None => Some(DEFAULT_BASH_TIMEOUT_SECS),
Some(0) => None,
Some(value) => Some(value),
};
let command = command_prefix.filter(|p| !p.trim().is_empty()).map_or_else(
|| command.to_string(),
|prefix| format!("{prefix}\n{command}"),
);
let command = format!("trap 'code=$?; wait; exit $code' EXIT\n{command}");
if !cwd.exists() {
return Err(Error::tool(
"bash",
format!(
"Working directory does not exist: {}\nCannot execute bash commands.",
cwd.display()
),
));
}
let shell = shell_path.unwrap_or_else(|| {
for path in ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"] {
if Path::new(path).exists() {
return path;
}
}
"sh"
});
let mut child = Command::new(shell)
.arg("-c")
.arg(&command)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| Error::tool("bash", format!("Failed to spawn shell: {e}")))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| Error::tool("bash", "Missing stdout".to_string()))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| Error::tool("bash", "Missing stderr".to_string()))?;
let mut guard = ProcessGuard::new(child, true);
let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(128);
let tx_stdout = tx.clone();
thread::spawn(move || pump_stream(stdout, &tx_stdout));
thread::spawn(move || pump_stream(stderr, &tx));
let max_chunks_bytes = DEFAULT_MAX_BYTES.saturating_mul(2);
let mut bash_output = BashOutputState::new(max_chunks_bytes);
bash_output.timeout_ms = timeout_secs.map(|s| s.saturating_mul(1000));
let mut timed_out = false;
let mut exit_code: Option<i32> = None;
let start = Instant::now();
let timeout = timeout_secs.map(Duration::from_secs);
let mut terminate_deadline: Option<Instant> = None;
let tick = Duration::from_millis(10);
loop {
let mut updated = false;
while let Ok(chunk) = rx.try_recv() {
ingest_bash_chunk(chunk, &mut bash_output).await?;
updated = true;
}
if updated {
emit_bash_update(&bash_output, on_update)?;
}
match guard.try_wait_child() {
Ok(Some(status)) => {
exit_code = Some(exit_status_code(status));
break;
}
Ok(None) => {}
Err(err) => return Err(Error::tool("bash", err.to_string())),
}
if let Some(deadline) = terminate_deadline {
if Instant::now() >= deadline {
if let Some(status) = guard
.kill()
.map_err(|err| Error::tool("bash", format!("Failed to kill process: {err}")))?
{
exit_code = Some(exit_status_code(status));
}
break; }
} else if let Some(timeout) = timeout {
if start.elapsed() >= timeout {
timed_out = true;
let pid = guard.child.as_ref().map(std::process::Child::id);
terminate_process_tree(pid);
terminate_deadline =
Some(Instant::now() + Duration::from_secs(BASH_TERMINATE_GRACE_SECS));
}
}
let now = AgentCx::for_current_or_request()
.cx()
.timer_driver()
.map_or_else(wall_now, |timer| timer.now());
sleep(now, tick).await;
}
let drain_deadline = Instant::now() + Duration::from_secs(2);
loop {
match rx.try_recv() {
Ok(chunk) => ingest_bash_chunk(chunk, &mut bash_output).await?,
Err(mpsc::TryRecvError::Empty) => {
if Instant::now() >= drain_deadline {
break;
}
let now = AgentCx::for_current_or_request()
.cx()
.timer_driver()
.map_or_else(wall_now, |timer| timer.now());
sleep(now, tick).await;
}
Err(mpsc::TryRecvError::Disconnected) => break,
}
}
drop(bash_output.temp_file.take());
let raw_output = concat_chunks(&bash_output.chunks);
let full_output = String::from_utf8_lossy(&raw_output).into_owned();
let full_output_last_line_len = full_output.split('\n').next_back().map_or(0, str::len);
let mut truncation = truncate_tail(full_output, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
if bash_output.total_bytes > bash_output.chunks_bytes {
truncation.truncated = true;
truncation.truncated_by = Some(TruncatedBy::Bytes);
truncation.total_bytes = bash_output.total_bytes;
truncation.total_lines = line_count_from_newline_count(
bash_output.total_bytes,
bash_output.line_count,
bash_output.last_byte_was_newline,
);
}
let mut output_text = if truncation.content.is_empty() {
"(no output)".to_string()
} else {
std::mem::take(&mut truncation.content)
};
let mut full_output_path = None;
if truncation.truncated {
if let Some(path) = bash_output.temp_file_path.as_ref() {
full_output_path = Some(path.display().to_string());
}
let start_line = truncation
.total_lines
.saturating_sub(truncation.output_lines)
.saturating_add(1);
let end_line = truncation.total_lines;
let display_path = full_output_path.as_deref().unwrap_or("undefined");
if truncation.last_line_partial {
let last_line_size = format_size(full_output_last_line_len);
let _ = write!(
output_text,
"\n\n[Showing last {} of line {end_line} (line is {last_line_size}). Full output: {display_path}]",
format_size(truncation.output_bytes)
);
} else if truncation.truncated_by == Some(TruncatedBy::Lines) {
let _ = write!(
output_text,
"\n\n[Showing lines {start_line}-{end_line} of {}. Full output: {display_path}]",
truncation.total_lines
);
} else {
let _ = write!(
output_text,
"\n\n[Showing lines {start_line}-{end_line} of {} ({} limit). Full output: {display_path}]",
truncation.total_lines,
format_size(DEFAULT_MAX_BYTES)
);
}
}
let mut cancelled = false;
if timed_out {
cancelled = true;
if !output_text.is_empty() {
output_text.push_str("\n\n");
}
let timeout_display = timeout_secs.unwrap_or(0);
let _ = write!(
output_text,
"Command timed out after {timeout_display} seconds"
);
}
let exit_code = exit_code.unwrap_or(-1);
if !cancelled && exit_code != 0 {
let _ = write!(output_text, "\n\nCommand exited with code {exit_code}");
}
Ok(BashRunResult {
output: output_text,
exit_code,
cancelled,
truncated: truncation.truncated,
full_output_path,
truncation: if truncation.truncated {
Some(truncation)
} else {
None
},
})
}
impl BashTool {
pub fn new(cwd: &Path) -> Self {
Self {
cwd: cwd.to_path_buf(),
shell_path: None,
command_prefix: None,
}
}
pub fn with_shell(
cwd: &Path,
shell_path: Option<String>,
command_prefix: Option<String>,
) -> Self {
Self {
cwd: cwd.to_path_buf(),
shell_path,
command_prefix,
}
}
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Tool for BashTool {
fn name(&self) -> &str {
"bash"
}
fn label(&self) -> &str {
"bash"
}
fn description(&self) -> &str {
"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. `timeout` defaults to 120 seconds; set `timeout: 0` to disable."
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Bash command to execute"
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (default 120; set 0 to disable)"
}
},
"required": ["command"]
})
}
#[allow(clippy::too_many_lines)]
async fn execute(
&self,
_tool_call_id: &str,
input: serde_json::Value,
on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
) -> Result<ToolOutput> {
let input: BashInput =
serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
let result = run_bash_command(
&self.cwd,
self.shell_path.as_deref(),
self.command_prefix.as_deref(),
&input.command,
input.timeout,
on_update.as_deref(),
)
.await?;
let mut details_map = serde_json::Map::new();
if let Some(truncation) = result.truncation.as_ref() {
details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
}
if let Some(path) = result.full_output_path.as_ref() {
details_map.insert(
"fullOutputPath".to_string(),
serde_json::Value::String(path.clone()),
);
}
let details = if details_map.is_empty() {
None
} else {
Some(serde_json::Value::Object(details_map))
};
let is_error = result.cancelled || result.exit_code != 0;
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(result.output))],
details,
is_error,
})
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EditInput {
path: String,
old_text: String,
new_text: String,
}
pub struct EditTool {
cwd: PathBuf,
}
impl EditTool {
pub fn new(cwd: &Path) -> Self {
Self {
cwd: cwd.to_path_buf(),
}
}
}
fn strip_bom(s: &str) -> (&str, bool) {
s.strip_prefix('\u{FEFF}')
.map_or_else(|| (s, false), |stripped| (stripped, true))
}
fn detect_line_ending(content: &str) -> &'static str {
let crlf_idx = content.find("\r\n");
let lf_idx = content.find('\n');
if lf_idx.is_none() {
return "\n";
}
let Some(crlf_idx) = crlf_idx else {
return "\n";
};
let lf_idx = lf_idx.unwrap_or(usize::MAX);
if crlf_idx < lf_idx { "\r\n" } else { "\n" }
}
fn normalize_to_lf(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
fn restore_line_endings(text: &str, ending: &str) -> String {
if ending == "\r\n" {
text.replace('\n', "\r\n")
} else {
text.to_string()
}
}
#[derive(Debug, Clone)]
struct FuzzyMatchResult {
found: bool,
index: usize,
match_length: usize,
}
fn map_normalized_range_to_original(
content: &str,
norm_match_start: usize,
norm_match_len: usize,
) -> (usize, usize) {
let mut norm_idx = 0;
let mut orig_idx = 0;
let mut match_start = None;
let mut match_end = None;
let norm_match_end = norm_match_start + norm_match_len;
for line in content.split_inclusive('\n') {
let line_content = line.strip_suffix('\n').unwrap_or(line);
let has_newline = line.ends_with('\n');
let trimmed_len = line_content.trim_end().len();
for (char_offset, c) in line_content.char_indices() {
if norm_idx == norm_match_end && match_end.is_none() {
match_end = Some(orig_idx + char_offset);
}
if char_offset >= trimmed_len {
continue;
}
if norm_idx == norm_match_start && match_start.is_none() {
match_start = Some(orig_idx + char_offset);
}
if match_start.is_some() && match_end.is_some() {
break;
}
let normalized_char = if is_special_unicode_space(c) {
' '
} else if matches!(c, '\u{2018}' | '\u{2019}') {
'\''
} else if matches!(c, '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}') {
'"'
} else if matches!(
c,
'\u{2010}'
| '\u{2011}'
| '\u{2012}'
| '\u{2013}'
| '\u{2014}'
| '\u{2015}'
| '\u{2212}'
) {
'-'
} else {
c
};
norm_idx += normalized_char.len_utf8();
}
orig_idx += line_content.len();
if has_newline {
if norm_idx == norm_match_start && match_start.is_none() {
match_start = Some(orig_idx);
}
if norm_idx == norm_match_end && match_end.is_none() {
match_end = Some(orig_idx);
}
norm_idx += 1;
orig_idx += 1;
}
if match_start.is_some() && match_end.is_some() {
break;
}
}
if norm_idx == norm_match_end && match_end.is_none() {
match_end = Some(orig_idx);
}
let start = match_start.unwrap_or(0);
let end = match_end.unwrap_or(content.len());
(start, end.saturating_sub(start))
}
fn build_normalized_content(content: &str) -> String {
let mut normalized = String::with_capacity(content.len());
let mut lines = content.split('\n').peekable();
while let Some(line) = lines.next() {
let trimmed_len = line.trim_end().len();
for (char_offset, c) in line.char_indices() {
if char_offset >= trimmed_len {
continue;
}
let normalized_char = if is_special_unicode_space(c) {
' '
} else if matches!(c, '\u{2018}' | '\u{2019}') {
'\''
} else if matches!(c, '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}') {
'"'
} else if matches!(
c,
'\u{2010}'
| '\u{2011}'
| '\u{2012}'
| '\u{2013}'
| '\u{2014}'
| '\u{2015}'
| '\u{2212}'
) {
'-'
} else {
c
};
normalized.push(normalized_char);
}
if lines.peek().is_some() {
normalized.push('\n');
}
}
normalized
}
fn fuzzy_find_text(content: &str, old_text: &str) -> FuzzyMatchResult {
fuzzy_find_text_with_normalized(content, old_text, None, None)
}
fn fuzzy_find_text_with_normalized(
content: &str,
old_text: &str,
precomputed_content: Option<&str>,
precomputed_old: Option<&str>,
) -> FuzzyMatchResult {
use std::borrow::Cow;
if let Some(index) = content.find(old_text) {
return FuzzyMatchResult {
found: true,
index,
match_length: old_text.len(),
};
}
let normalized_content = precomputed_content.map_or_else(
|| Cow::Owned(build_normalized_content(content)),
Cow::Borrowed,
);
let normalized_old_text = precomputed_old.map_or_else(
|| Cow::Owned(build_normalized_content(old_text)),
Cow::Borrowed,
);
if let Some(normalized_index) = normalized_content.find(normalized_old_text.as_ref()) {
let (original_start, original_match_len) =
map_normalized_range_to_original(content, normalized_index, normalized_old_text.len());
return FuzzyMatchResult {
found: true,
index: original_start,
match_length: original_match_len,
};
}
FuzzyMatchResult {
found: false,
index: 0,
match_length: 0,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DiffTag {
Equal,
Added,
Removed,
}
#[derive(Debug, Clone)]
struct DiffPart {
tag: DiffTag,
value: String,
}
fn diff_parts(old_content: &str, new_content: &str) -> Vec<DiffPart> {
use similar::ChangeTag;
let diff = similar::TextDiff::from_lines(old_content, new_content);
let mut parts: Vec<DiffPart> = Vec::new();
let mut current_tag: Option<DiffTag> = None;
let mut current_value = String::new();
for change in diff.iter_all_changes() {
let tag = match change.tag() {
ChangeTag::Equal => DiffTag::Equal,
ChangeTag::Insert => DiffTag::Added,
ChangeTag::Delete => DiffTag::Removed,
};
let mut line = change.value();
if let Some(stripped) = line.strip_suffix('\n') {
line = stripped;
}
if current_tag == Some(tag) {
if !current_value.is_empty() {
current_value.push('\n');
}
current_value.push_str(line);
} else {
if let Some(prev_tag) = current_tag {
parts.push(DiffPart {
tag: prev_tag,
value: current_value,
});
}
current_tag = Some(tag);
current_value = line.to_string();
}
}
if let Some(tag) = current_tag {
parts.push(DiffPart {
tag,
value: current_value,
});
}
parts
}
fn generate_diff_string(old_content: &str, new_content: &str) -> (String, Option<usize>) {
let parts = diff_parts(old_content, new_content);
let old_line_count = memchr::memchr_iter(b'\n', old_content.as_bytes()).count() + 1;
let new_line_count = memchr::memchr_iter(b'\n', new_content.as_bytes()).count() + 1;
let max_line_num = old_line_count.max(new_line_count).max(1);
let line_num_width = max_line_num.ilog10() as usize + 1;
let mut output = String::new();
let mut old_line_num: usize = 1;
let mut new_line_num: usize = 1;
let mut last_was_change = false;
let mut first_changed_line: Option<usize> = None;
let context_lines: usize = 4;
for (i, part) in parts.iter().enumerate() {
let collected: Vec<&str> = part.value.split('\n').collect();
let raw = if collected.last().is_some_and(|l| l.is_empty()) {
&collected[..collected.len() - 1]
} else {
&collected[..]
};
match part.tag {
DiffTag::Added | DiffTag::Removed => {
if first_changed_line.is_none() {
first_changed_line = Some(new_line_num);
}
for line in raw {
if !output.is_empty() {
output.push('\n');
}
match part.tag {
DiffTag::Added => {
let _ = write!(output, "+{new_line_num:>line_num_width$} {line}");
new_line_num = new_line_num.saturating_add(1);
}
DiffTag::Removed => {
let _ = write!(output, "-{old_line_num:>line_num_width$} {line}");
old_line_num = old_line_num.saturating_add(1);
}
DiffTag::Equal => {}
}
}
last_was_change = true;
}
DiffTag::Equal => {
let next_part_is_change = i < parts.len().saturating_sub(1)
&& matches!(parts[i + 1].tag, DiffTag::Added | DiffTag::Removed);
if last_was_change || next_part_is_change {
let start = if last_was_change {
0
} else {
raw.len().saturating_sub(context_lines)
};
let lines_after_start = raw.len() - start;
let (end, skip_end) =
if !next_part_is_change && lines_after_start > context_lines {
(start + context_lines, lines_after_start - context_lines)
} else {
(raw.len(), 0)
};
let skip_start = start;
if skip_start > 0 {
if !output.is_empty() {
output.push('\n');
}
let _ = write!(output, " {:>line_num_width$} ...", " ");
old_line_num = old_line_num.saturating_add(skip_start);
new_line_num = new_line_num.saturating_add(skip_start);
}
for line in &raw[start..end] {
if !output.is_empty() {
output.push('\n');
}
let _ = write!(output, " {old_line_num:>line_num_width$} {line}");
old_line_num = old_line_num.saturating_add(1);
new_line_num = new_line_num.saturating_add(1);
}
if skip_end > 0 {
if !output.is_empty() {
output.push('\n');
}
let _ = write!(output, " {:>line_num_width$} ...", " ");
old_line_num = old_line_num.saturating_add(skip_end);
new_line_num = new_line_num.saturating_add(skip_end);
}
} else {
old_line_num = old_line_num.saturating_add(raw.len());
new_line_num = new_line_num.saturating_add(raw.len());
}
last_was_change = false;
}
}
}
(output, first_changed_line)
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Tool for EditTool {
fn name(&self) -> &str {
"edit"
}
fn label(&self) -> &str {
"edit"
}
fn description(&self) -> &str {
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits."
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to edit (relative or absolute)"
},
"oldText": {
"type": "string",
"minLength": 1,
"description": "Exact text to find and replace (must match exactly)"
},
"newText": {
"type": "string",
"description": "New text to replace the old text with"
}
},
"required": ["path", "oldText", "newText"]
})
}
#[allow(clippy::too_many_lines)]
async fn execute(
&self,
_tool_call_id: &str,
input: serde_json::Value,
_on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
) -> Result<ToolOutput> {
let input: EditInput =
serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
if input.new_text.len() > WRITE_TOOL_MAX_BYTES {
return Err(Error::validation(format!(
"New text size exceeds maximum allowed ({} > {} bytes)",
input.new_text.len(),
WRITE_TOOL_MAX_BYTES
)));
}
let absolute_path = resolve_read_path(&input.path, &self.cwd);
if asupersync::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&absolute_path)
.await
.is_err()
{
return Err(Error::tool(
"edit",
format!("File not found: {}", input.path),
));
}
if let Ok(meta) = asupersync::fs::metadata(&absolute_path).await {
if meta.len() > READ_TOOL_MAX_BYTES {
return Err(Error::tool(
"edit",
format!(
"File is too large ({} bytes). Max allowed for editing is {} bytes.",
meta.len(),
READ_TOOL_MAX_BYTES
),
));
}
}
let raw = asupersync::fs::read(&absolute_path)
.await
.map_err(|e| Error::tool("edit", format!("Failed to read file: {e}")))?;
let raw_content = String::from_utf8(raw).map_err(|_| {
Error::tool(
"edit",
"File contains invalid UTF-8 characters and cannot be safely edited as text."
.to_string(),
)
})?;
let (content_no_bom, had_bom) = strip_bom(&raw_content);
let original_ending = detect_line_ending(content_no_bom);
let normalized_content = normalize_to_lf(content_no_bom);
let normalized_old_text = normalize_to_lf(&input.old_text);
if normalized_old_text.is_empty() {
return Err(Error::tool(
"edit",
"The old text cannot be empty. To prepend text, include the first line's content in oldText and newText.".to_string(),
));
}
let mut variants = Vec::with_capacity(3);
variants.push(normalized_old_text.clone());
let nfc = normalized_old_text.nfc().collect::<String>();
if nfc != normalized_old_text {
variants.push(nfc);
}
let nfd = normalized_old_text.nfd().collect::<String>();
if nfd != normalized_old_text {
variants.push(nfd);
}
let precomputed_content = build_normalized_content(content_no_bom);
let mut best_match: Option<(FuzzyMatchResult, String)> = None;
for variant in variants {
let precomputed_variant = build_normalized_content(&variant);
let match_result = fuzzy_find_text_with_normalized(
content_no_bom,
&variant,
Some(precomputed_content.as_str()),
Some(precomputed_variant.as_str()),
);
if match_result.found {
best_match = Some((match_result, precomputed_variant));
break;
}
}
let Some((match_result, normalized_old_text)) = best_match else {
return Err(Error::tool(
"edit",
format!(
"Could not find the exact text in {}. The old text must match exactly including all whitespace and newlines.",
input.path
),
));
};
let occurrences = if normalized_old_text.is_empty() {
0
} else {
precomputed_content
.split(&normalized_old_text)
.count()
.saturating_sub(1)
};
if occurrences > 1 {
return Err(Error::tool(
"edit",
format!(
"Found {occurrences} occurrences of the text in {}. The text must be unique. Please provide more context to make it unique.",
input.path
),
));
}
let idx = match_result.index;
let match_len = match_result.match_length;
let adapted_new_text =
restore_line_endings(&normalize_to_lf(&input.new_text), original_ending);
let new_len = content_no_bom.len() - match_len + adapted_new_text.len();
let mut new_content = String::with_capacity(new_len);
new_content.push_str(&content_no_bom[..idx]);
new_content.push_str(&adapted_new_text);
new_content.push_str(&content_no_bom[idx + match_len..]);
if content_no_bom == new_content {
return Err(Error::tool(
"edit",
format!(
"No changes made to {}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.",
input.path
),
));
}
let new_content_for_diff = normalize_to_lf(&new_content);
let mut final_content = new_content;
if had_bom {
final_content = format!("\u{FEFF}{final_content}");
}
let original_perms = std::fs::metadata(&absolute_path)
.ok()
.map(|m| m.permissions());
let parent = absolute_path.parent().unwrap_or_else(|| Path::new("."));
let mut temp_file = tempfile::NamedTempFile::new_in(parent)
.map_err(|e| Error::tool("edit", format!("Failed to create temp file: {e}")))?;
temp_file
.as_file_mut()
.write_all(final_content.as_bytes())
.map_err(|e| Error::tool("edit", format!("Failed to write temp file: {e}")))?;
if let Some(perms) = original_perms {
let _ = temp_file.as_file().set_permissions(perms);
} else {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = temp_file
.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o644));
}
}
temp_file
.persist(&absolute_path)
.map_err(|e| Error::tool("edit", format!("Failed to persist file: {e}")))?;
let (diff, first_changed_line) =
generate_diff_string(&normalized_content, &new_content_for_diff);
let mut details = serde_json::Map::new();
details.insert("diff".to_string(), serde_json::Value::String(diff));
if let Some(line) = first_changed_line {
details.insert(
"firstChangedLine".to_string(),
serde_json::Value::Number(serde_json::Number::from(line)),
);
}
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(format!(
"Successfully replaced text in {}.",
input.path
)))],
details: Some(serde_json::Value::Object(details)),
is_error: false,
})
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WriteInput {
path: String,
content: String,
}
pub struct WriteTool {
cwd: PathBuf,
}
impl WriteTool {
pub fn new(cwd: &Path) -> Self {
Self {
cwd: cwd.to_path_buf(),
}
}
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Tool for WriteTool {
fn name(&self) -> &str {
"write"
}
fn label(&self) -> &str {
"write"
}
fn description(&self) -> &str {
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories."
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to write (relative or absolute)"
},
"content": {
"type": "string",
"description": "Content to write to the file"
}
},
"required": ["path", "content"]
})
}
#[allow(clippy::too_many_lines)]
async fn execute(
&self,
_tool_call_id: &str,
input: serde_json::Value,
_on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
) -> Result<ToolOutput> {
let input: WriteInput =
serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
if input.content.len() > WRITE_TOOL_MAX_BYTES {
return Err(Error::validation(format!(
"Content size exceeds maximum allowed ({} > {} bytes)",
input.content.len(),
WRITE_TOOL_MAX_BYTES
)));
}
let path = resolve_path(&input.path, &self.cwd);
if let Some(parent) = path.parent() {
asupersync::fs::create_dir_all(parent)
.await
.map_err(|e| Error::tool("write", format!("Failed to create directories: {e}")))?;
}
let bytes_written = input.content.encode_utf16().count();
let original_perms = std::fs::metadata(&path).ok().map(|m| m.permissions());
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let mut temp_file = tempfile::NamedTempFile::new_in(parent)
.map_err(|e| Error::tool("write", format!("Failed to create temp file: {e}")))?;
temp_file
.as_file_mut()
.write_all(input.content.as_bytes())
.map_err(|e| Error::tool("write", format!("Failed to write temp file: {e}")))?;
if let Some(perms) = original_perms {
let _ = temp_file.as_file().set_permissions(perms);
} else {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = temp_file
.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o644));
}
}
temp_file
.persist(&path)
.map_err(|e| Error::tool("write", format!("Failed to persist file: {e}")))?;
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(format!(
"Successfully wrote {} bytes to {}",
bytes_written, input.path
)))],
details: None,
is_error: false,
})
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GrepInput {
pattern: String,
path: Option<String>,
glob: Option<String>,
ignore_case: Option<bool>,
literal: Option<bool>,
context: Option<usize>,
limit: Option<usize>,
}
pub struct GrepTool {
cwd: PathBuf,
}
impl GrepTool {
pub fn new(cwd: &Path) -> Self {
Self {
cwd: cwd.to_path_buf(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TruncateLineResult {
text: String,
was_truncated: bool,
}
fn truncate_line(line: &str, max_chars: usize) -> TruncateLineResult {
let mut chars = line.chars();
let prefix: String = chars.by_ref().take(max_chars).collect();
if chars.next().is_none() {
return TruncateLineResult {
text: line.to_string(),
was_truncated: false,
};
}
TruncateLineResult {
text: format!("{prefix}... [truncated]"),
was_truncated: true,
}
}
fn process_rg_json_match_line(
line_res: std::io::Result<String>,
matches: &mut Vec<(PathBuf, usize)>,
match_count: &mut usize,
match_limit_reached: &mut bool,
effective_limit: usize,
) -> Result<()> {
if *match_limit_reached {
return Ok(());
}
let line = line_res.map_err(|e| Error::tool("grep", e.to_string()))?;
if line.trim().is_empty() {
return Ok(());
}
let Ok(event) = serde_json::from_str::<serde_json::Value>(&line) else {
return Ok(());
};
if event.get("type").and_then(serde_json::Value::as_str) != Some("match") {
return Ok(());
}
*match_count += 1;
let file_path = event
.pointer("/data/path/text")
.and_then(serde_json::Value::as_str)
.map(PathBuf::from);
let line_number = event
.pointer("/data/line_number")
.and_then(serde_json::Value::as_u64)
.and_then(|n| usize::try_from(n).ok());
if let (Some(fp), Some(ln)) = (file_path, line_number) {
matches.push((fp, ln));
}
if *match_count >= effective_limit {
*match_limit_reached = true;
}
Ok(())
}
fn drain_rg_stdout(
stdout_rx: &std::sync::mpsc::Receiver<std::io::Result<String>>,
matches: &mut Vec<(PathBuf, usize)>,
match_count: &mut usize,
match_limit_reached: &mut bool,
effective_limit: usize,
) -> Result<()> {
while let Ok(line_res) = stdout_rx.try_recv() {
process_rg_json_match_line(
line_res,
matches,
match_count,
match_limit_reached,
effective_limit,
)?;
if *match_limit_reached {
break;
}
}
Ok(())
}
fn drain_rg_stderr(
stderr_rx: &std::sync::mpsc::Receiver<std::result::Result<Vec<u8>, String>>,
stderr_bytes: &mut Vec<u8>,
) -> Result<()> {
while let Ok(chunk_result) = stderr_rx.try_recv() {
let chunk = chunk_result
.map_err(|err| Error::tool("grep", format!("Failed to read stderr: {err}")))?;
stderr_bytes.extend_from_slice(&chunk);
}
Ok(())
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Tool for GrepTool {
fn name(&self) -> &str {
"grep"
}
fn label(&self) -> &str {
"grep"
}
fn description(&self) -> &str {
"Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to 100 matches or 50KB (whichever is hit first). Long lines are truncated to 500 chars."
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Search pattern (regex or literal string)"
},
"path": {
"type": "string",
"description": "Directory or file to search (default: current directory)"
},
"glob": {
"type": "string",
"description": "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'"
},
"ignoreCase": {
"type": "boolean",
"description": "Case-insensitive search (default: false)"
},
"literal": {
"type": "boolean",
"description": "Treat pattern as literal string instead of regex (default: false)"
},
"context": {
"type": "integer",
"description": "Number of lines to show before and after each match (default: 0)"
},
"limit": {
"type": "integer",
"description": "Maximum number of matches to return (default: 100)"
}
},
"required": ["pattern"]
})
}
fn is_read_only(&self) -> bool {
true
}
#[allow(clippy::too_many_lines)]
async fn execute(
&self,
_tool_call_id: &str,
input: serde_json::Value,
_on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
) -> Result<ToolOutput> {
let input: GrepInput =
serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
if !rg_available() {
return Err(Error::tool(
"grep",
"ripgrep (rg) is not available (please install ripgrep)".to_string(),
));
}
let search_dir = input.path.as_deref().unwrap_or(".");
let search_path = resolve_read_path(search_dir, &self.cwd);
let is_directory = std::fs::metadata(&search_path)
.map_err(|e| {
Error::tool(
"grep",
format!("Cannot access path {}: {e}", search_path.display()),
)
})?
.is_dir();
let context_value = input.context.unwrap_or(0);
let effective_limit = input.limit.unwrap_or(DEFAULT_GREP_LIMIT).max(1);
let mut args: Vec<String> = vec![
"--json".to_string(),
"--line-number".to_string(),
"--color=never".to_string(),
"--hidden".to_string(),
"--max-columns=10000".to_string(),
];
if input.ignore_case.unwrap_or(false) {
args.push("--ignore-case".to_string());
}
if input.literal.unwrap_or(false) {
args.push("--fixed-strings".to_string());
}
if let Some(glob) = &input.glob {
args.push("--glob".to_string());
args.push(glob.clone());
}
let ignore_root = if is_directory {
search_path.clone()
} else {
search_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
};
let root_gitignore = ignore_root.join(".gitignore");
if root_gitignore.exists() {
args.push("--ignore-file".to_string());
args.push(root_gitignore.display().to_string());
}
args.push("--".to_string());
args.push(input.pattern.clone());
args.push(search_path.display().to_string());
let mut child = Command::new("rg")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| Error::tool("grep", format!("Failed to run ripgrep: {e}")))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| Error::tool("grep", "Missing stdout".to_string()))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| Error::tool("grep", "Missing stderr".to_string()))?;
let mut guard = ProcessGuard::new(child, false);
let (stdout_tx, stdout_rx) = std::sync::mpsc::sync_channel(1024);
let (stderr_tx, stderr_rx) =
std::sync::mpsc::sync_channel::<std::result::Result<Vec<u8>, String>>(1024);
let stdout_thread = std::thread::spawn(move || {
let reader = std::io::BufReader::new(stdout);
for line in reader.lines() {
if stdout_tx.send(line).is_err() {
break;
}
}
});
let stderr_thread = std::thread::spawn(move || {
let mut reader = std::io::BufReader::new(stderr);
let mut buf = Vec::new();
let _ = stderr_tx.send(
reader
.read_to_end(&mut buf)
.map(|_| buf)
.map_err(|err| err.to_string()),
);
});
let mut matches: Vec<(PathBuf, usize)> = Vec::new();
let mut match_count: usize = 0;
let mut match_limit_reached = false;
let mut stderr_bytes = Vec::new();
let tick = Duration::from_millis(10);
loop {
drain_rg_stdout(
&stdout_rx,
&mut matches,
&mut match_count,
&mut match_limit_reached,
effective_limit,
)?;
drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
if match_limit_reached {
break;
}
match guard.try_wait_child() {
Ok(Some(_)) => break,
Ok(None) => {
let now = AgentCx::for_current_or_request()
.cx()
.timer_driver()
.map_or_else(wall_now, |timer| timer.now());
sleep(now, tick).await;
}
Err(e) => return Err(Error::tool("grep", e.to_string())),
}
}
drain_rg_stdout(
&stdout_rx,
&mut matches,
&mut match_count,
&mut match_limit_reached,
effective_limit,
)?;
let code = if match_limit_reached {
let _ = guard
.kill()
.map_err(|e| Error::tool("grep", format!("Failed to terminate ripgrep: {e}")))?;
while stdout_rx.try_recv().is_ok() {}
while stderr_rx.try_recv().is_ok() {}
0
} else {
guard
.wait()
.map_err(|e| Error::tool("grep", e.to_string()))?
.code()
.unwrap_or(0)
};
while !stdout_thread.is_finished() || !stderr_thread.is_finished() {
if match_limit_reached {
while stdout_rx.try_recv().is_ok() {}
} else {
drain_rg_stdout(
&stdout_rx,
&mut matches,
&mut match_count,
&mut match_limit_reached,
effective_limit,
)?;
}
drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
std::thread::sleep(Duration::from_millis(1));
}
stdout_thread
.join()
.map_err(|_| Error::tool("grep", "ripgrep stdout reader thread panicked"))?;
stderr_thread
.join()
.map_err(|_| Error::tool("grep", "ripgrep stderr reader thread panicked"))?;
if match_limit_reached {
while stdout_rx.try_recv().is_ok() {}
} else {
drain_rg_stdout(
&stdout_rx,
&mut matches,
&mut match_count,
&mut match_limit_reached,
effective_limit,
)?;
}
drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
let stderr_text = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
if !match_limit_reached && code != 0 && code != 1 {
let msg = if stderr_text.is_empty() {
format!("ripgrep exited with code {code}")
} else {
stderr_text
};
return Err(Error::tool("grep", msg));
}
if match_count == 0 {
return Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent::new("No matches found"))],
details: None,
is_error: false,
});
}
let mut file_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
let mut output_lines: Vec<String> = Vec::new();
let mut lines_truncated = false;
for (file_path, line_number) in &matches {
let relative_path = format_grep_path(file_path, &self.cwd);
let lines = get_file_lines_async(file_path, &mut file_cache).await;
if lines.is_empty() {
output_lines.push(format!(
"{relative_path}:{line_number}: (unable to read file or too large)"
));
continue;
}
let start = if context_value > 0 {
line_number.saturating_sub(context_value).max(1)
} else {
*line_number
};
let end = if context_value > 0 {
line_number.saturating_add(context_value).min(lines.len())
} else {
*line_number
};
for current in start..=end {
let line_text = lines.get(current - 1).map_or("", String::as_str);
let sanitized = line_text.replace('\r', "");
let truncated = truncate_line(&sanitized, GREP_MAX_LINE_LENGTH);
if truncated.was_truncated {
lines_truncated = true;
}
if current == *line_number {
output_lines.push(format!("{relative_path}:{current}: {}", truncated.text));
} else {
output_lines.push(format!("{relative_path}-{current}- {}", truncated.text));
}
}
}
let raw_output = output_lines.join("\n");
let mut truncation = truncate_head(raw_output, usize::MAX, DEFAULT_MAX_BYTES);
let mut output = std::mem::take(&mut truncation.content);
let mut notices: Vec<String> = Vec::new();
let mut details_map = serde_json::Map::new();
if match_limit_reached {
notices.push(format!(
"{effective_limit} matches limit reached. Use limit={} for more, or refine pattern",
effective_limit * 2
));
details_map.insert(
"matchLimitReached".to_string(),
serde_json::Value::Number(serde_json::Number::from(effective_limit)),
);
}
if truncation.truncated {
notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
}
if lines_truncated {
notices.push(format!(
"Some lines truncated to {GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines"
));
details_map.insert("linesTruncated".to_string(), serde_json::Value::Bool(true));
}
if !notices.is_empty() {
let _ = write!(output, "\n\n[{}]", notices.join(". "));
}
let details = if details_map.is_empty() {
None
} else {
Some(serde_json::Value::Object(details_map))
};
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(output))],
details,
is_error: false,
})
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FindInput {
pattern: String,
path: Option<String>,
limit: Option<usize>,
}
pub struct FindTool {
cwd: PathBuf,
}
impl FindTool {
pub fn new(cwd: &Path) -> Self {
Self {
cwd: cwd.to_path_buf(),
}
}
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Tool for FindTool {
fn name(&self) -> &str {
"find"
}
fn label(&self) -> &str {
"find"
}
fn description(&self) -> &str {
"Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to 1000 results or 50KB (whichever is hit first)."
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'"
},
"path": {
"type": "string",
"description": "Directory to search in (default: current directory)"
},
"limit": {
"type": "integer",
"description": "Maximum number of results (default: 1000)"
}
},
"required": ["pattern"]
})
}
fn is_read_only(&self) -> bool {
true
}
#[allow(clippy::too_many_lines)]
async fn execute(
&self,
_tool_call_id: &str,
input: serde_json::Value,
_on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
) -> Result<ToolOutput> {
let input: FindInput =
serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
let search_dir = input.path.as_deref().unwrap_or(".");
let search_path = strip_unc_prefix(resolve_read_path(search_dir, &self.cwd));
let effective_limit = input.limit.unwrap_or(DEFAULT_FIND_LIMIT);
if !search_path.exists() {
return Err(Error::tool(
"find",
format!("Path not found: {}", search_path.display()),
));
}
let fd_cmd = find_fd_binary().ok_or_else(|| {
Error::tool(
"find",
"fd is not available (please install fd-find or fd)".to_string(),
)
})?;
let mut args: Vec<String> = vec![
"--glob".to_string(),
"--color=never".to_string(),
"--hidden".to_string(),
"--max-results".to_string(),
effective_limit.to_string(),
];
let root_gitignore = search_path.join(".gitignore");
if root_gitignore.exists() {
args.push("--ignore-file".to_string());
args.push(root_gitignore.display().to_string());
}
args.push("--".to_string());
args.push(input.pattern.clone());
args.push(search_path.display().to_string());
let mut child = Command::new(fd_cmd)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| Error::tool("find", format!("Failed to run fd: {e}")))?;
let mut stdout_pipe = child
.stdout
.take()
.ok_or_else(|| Error::tool("find", "Missing stdout"))?;
let mut stderr_pipe = child
.stderr
.take()
.ok_or_else(|| Error::tool("find", "Missing stderr"))?;
let mut guard = ProcessGuard::new(child, false);
let stdout_handle = std::thread::spawn(move || -> std::result::Result<Vec<u8>, String> {
let mut buf = Vec::new();
stdout_pipe
.read_to_end(&mut buf)
.map_err(|err| err.to_string())?;
Ok(buf)
});
let stderr_handle = std::thread::spawn(move || -> std::result::Result<Vec<u8>, String> {
let mut buf = Vec::new();
stderr_pipe
.read_to_end(&mut buf)
.map_err(|err| err.to_string())?;
Ok(buf)
});
let tick = Duration::from_millis(10);
loop {
match guard.try_wait_child() {
Ok(Some(_)) => break,
Ok(None) => {
let now = AgentCx::for_current_or_request()
.cx()
.timer_driver()
.map_or_else(wall_now, |timer| timer.now());
sleep(now, tick).await;
}
Err(e) => return Err(Error::tool("find", e.to_string())),
}
}
let status = guard
.wait()
.map_err(|e| Error::tool("find", e.to_string()))?;
let stdout_bytes = stdout_handle
.join()
.map_err(|_| Error::tool("find", "fd stdout reader thread panicked"))?
.map_err(|err| Error::tool("find", format!("Failed to read fd stdout: {err}")))?;
let stderr_bytes = stderr_handle
.join()
.map_err(|_| Error::tool("find", "fd stderr reader thread panicked"))?
.map_err(|err| Error::tool("find", format!("Failed to read fd stderr: {err}")))?;
let stdout = String::from_utf8_lossy(&stdout_bytes).trim().to_string();
let stderr = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
if !status.success() && stdout.is_empty() {
let code = status.code().unwrap_or(1);
let msg = if stderr.is_empty() {
format!("fd exited with code {code}")
} else {
stderr
};
return Err(Error::tool("find", msg));
}
if stdout.is_empty() {
return Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(
"No files found matching pattern",
))],
details: None,
is_error: false,
});
}
let mut relativized: Vec<String> = Vec::new();
for raw_line in stdout.lines() {
let line = raw_line.trim_end_matches('\r').trim();
if line.is_empty() {
continue;
}
let clean = strip_unc_prefix(PathBuf::from(line));
let line_path = clean.as_path();
let mut rel = if line_path.is_absolute() {
line_path.strip_prefix(&search_path).map_or_else(
|_| line_path.to_string_lossy().to_string(),
|stripped| stripped.to_string_lossy().to_string(),
)
} else {
line_path.to_string_lossy().to_string()
};
let full_path = if line_path.is_absolute() {
line_path.to_path_buf()
} else {
search_path.join(line_path)
};
if full_path.is_dir() && !rel.ends_with('/') {
rel.push('/');
}
relativized.push(rel);
}
if relativized.is_empty() {
return Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(
"No files found matching pattern",
))],
details: None,
is_error: false,
});
}
let result_limit_reached = relativized.len() >= effective_limit;
let raw_output = relativized.join("\n");
let mut truncation = truncate_head(raw_output, usize::MAX, DEFAULT_MAX_BYTES);
let mut result_output = std::mem::take(&mut truncation.content);
let mut notices: Vec<String> = Vec::new();
let mut details_map = serde_json::Map::new();
if !status.success() {
let code = status.code().unwrap_or(1);
notices.push(format!("fd exited with code {code}"));
}
if result_limit_reached {
notices.push(format!(
"{effective_limit} results limit reached. Use limit={} for more, or refine pattern",
effective_limit * 2
));
details_map.insert(
"resultLimitReached".to_string(),
serde_json::Value::Number(serde_json::Number::from(effective_limit)),
);
}
if truncation.truncated {
notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
}
if !notices.is_empty() {
let _ = write!(result_output, "\n\n[{}]", notices.join(". "));
}
let details = if details_map.is_empty() {
None
} else {
Some(serde_json::Value::Object(details_map))
};
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(result_output))],
details,
is_error: false,
})
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LsInput {
path: Option<String>,
limit: Option<usize>,
}
pub struct LsTool {
cwd: PathBuf,
}
impl LsTool {
pub fn new(cwd: &Path) -> Self {
Self {
cwd: cwd.to_path_buf(),
}
}
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound, clippy::too_many_lines)]
impl Tool for LsTool {
fn name(&self) -> &str {
"ls"
}
fn label(&self) -> &str {
"ls"
}
fn description(&self) -> &str {
"List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to 500 entries or 50KB (whichever is hit first)."
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory to list (default: current directory)"
},
"limit": {
"type": "integer",
"description": "Maximum number of entries to return (default: 500)"
}
}
})
}
fn is_read_only(&self) -> bool {
true
}
async fn execute(
&self,
_tool_call_id: &str,
input: serde_json::Value,
_on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
) -> Result<ToolOutput> {
let input: LsInput =
serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
let dir_path = input
.path
.as_ref()
.map_or_else(|| self.cwd.clone(), |p| resolve_read_path(p, &self.cwd));
let effective_limit = input.limit.unwrap_or(DEFAULT_LS_LIMIT);
if !dir_path.exists() {
return Err(Error::tool(
"ls",
format!("Path not found: {}", dir_path.display()),
));
}
if !dir_path.is_dir() {
return Err(Error::tool(
"ls",
format!("Not a directory: {}", dir_path.display()),
));
}
let mut entries = Vec::new();
let mut read_dir = asupersync::fs::read_dir(&dir_path)
.await
.map_err(|e| Error::tool("ls", format!("Cannot read directory: {e}")))?;
let mut scan_limit_reached = false;
while let Some(entry) = read_dir
.next_entry()
.await
.map_err(|e| Error::tool("ls", format!("Cannot read directory entry: {e}")))?
{
if entries.len() >= LS_SCAN_HARD_LIMIT {
scan_limit_reached = true;
break;
}
let name = entry.file_name().to_string_lossy().to_string();
let is_dir = match entry.file_type().await {
Ok(ft) => {
if ft.is_dir() {
true
} else if ft.is_symlink() {
entry.metadata().await.is_ok_and(|meta| meta.is_dir())
} else {
false
}
}
Err(_) => entry.metadata().await.is_ok_and(|meta| meta.is_dir()),
};
entries.push((name, is_dir));
}
entries.sort_by_key(|(a, _)| a.to_lowercase());
let mut results: Vec<String> = Vec::new();
let mut entry_limit_reached = false;
for (entry, is_dir) in entries {
if results.len() >= effective_limit {
entry_limit_reached = true;
break;
}
if is_dir {
results.push(format!("{entry}/"));
} else {
results.push(entry);
}
}
if results.is_empty() {
return Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent::new("(empty directory)"))],
details: None,
is_error: false,
});
}
let raw_output = results.join("\n");
let mut truncation = truncate_head(raw_output, usize::MAX, DEFAULT_MAX_BYTES);
let mut output = std::mem::take(&mut truncation.content);
let mut details_map = serde_json::Map::new();
let mut notices: Vec<String> = Vec::new();
if entry_limit_reached {
notices.push(format!(
"{effective_limit} entries limit reached. Use limit={} for more",
effective_limit * 2
));
details_map.insert(
"entryLimitReached".to_string(),
serde_json::Value::Number(serde_json::Number::from(effective_limit)),
);
}
if scan_limit_reached {
notices.push(format!(
"Directory scan limited to {LS_SCAN_HARD_LIMIT} entries to prevent system overload"
));
details_map.insert(
"scanLimitReached".to_string(),
serde_json::Value::Number(serde_json::Number::from(LS_SCAN_HARD_LIMIT)),
);
}
if truncation.truncated {
notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
}
if !notices.is_empty() {
let _ = write!(output, "\n\n[{}]", notices.join(". "));
}
let details = if details_map.is_empty() {
None
} else {
Some(serde_json::Value::Object(details_map))
};
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(output))],
details,
is_error: false,
})
}
}
pub fn cleanup_temp_files() {
std::thread::spawn(|| {
let temp_dir = std::env::temp_dir();
let Ok(entries) = std::fs::read_dir(&temp_dir) else {
return;
};
let now = std::time::SystemTime::now();
let threshold = now
.checked_sub(Duration::from_secs(24 * 60 * 60))
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if (file_name.starts_with("pi-bash-") || file_name.starts_with("pi-rpc-bash-"))
&& std::path::Path::new(file_name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
{
if let Ok(metadata) = entry.metadata() {
if let Ok(modified) = metadata.modified() {
if modified < threshold {
if let Err(e) = std::fs::remove_file(&path) {
tracing::debug!(
"Failed to remove temp file {}: {}",
path.display(),
e
);
}
}
}
}
}
}
});
}
fn rg_available() -> bool {
static AVAILABLE: OnceLock<bool> = OnceLock::new();
*AVAILABLE.get_or_init(|| {
std::process::Command::new("rg")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok()
})
}
fn pump_stream<R: Read + Send + 'static>(mut reader: R, tx: &mpsc::SyncSender<Vec<u8>>) {
let mut buf = vec![0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
}
fn concat_chunks(chunks: &VecDeque<Vec<u8>>) -> Vec<u8> {
let total: usize = chunks.iter().map(Vec::len).sum();
let mut out = Vec::with_capacity(total);
for chunk in chunks {
out.extend_from_slice(chunk);
}
out
}
struct BashOutputState {
total_bytes: usize,
line_count: usize,
last_byte_was_newline: bool,
start_time: std::time::Instant,
timeout_ms: Option<u64>,
temp_file_path: Option<PathBuf>,
temp_file: Option<asupersync::fs::File>,
chunks: VecDeque<Vec<u8>>,
chunks_bytes: usize,
max_chunks_bytes: usize,
spill_failed: bool,
}
impl BashOutputState {
fn new(max_chunks_bytes: usize) -> Self {
Self {
total_bytes: 0,
line_count: 0,
last_byte_was_newline: false,
start_time: std::time::Instant::now(),
timeout_ms: None,
temp_file_path: None,
temp_file: None,
chunks: VecDeque::new(),
chunks_bytes: 0,
max_chunks_bytes,
spill_failed: false,
}
}
}
async fn ingest_bash_chunk(chunk: Vec<u8>, state: &mut BashOutputState) -> Result<()> {
state.last_byte_was_newline = chunk.last().is_some_and(|byte| *byte == b'\n');
state.total_bytes = state.total_bytes.saturating_add(chunk.len());
state.line_count = state
.line_count
.saturating_add(memchr::memchr_iter(b'\n', &chunk).count());
if state.total_bytes > DEFAULT_MAX_BYTES && state.temp_file.is_none() && !state.spill_failed {
let id_full = Uuid::new_v4().simple().to_string();
let id = &id_full[..16];
let path = std::env::temp_dir().join(format!("pi-bash-{id}.log"));
let expected_inode: Option<u64> = {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options
.open(&path)
.map_err(|e| Error::tool("bash", format!("Failed to create temp file: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
file.metadata().ok().map(|m| m.ino())
}
#[cfg(not(unix))]
{
None
}
};
let mut file = asupersync::fs::OpenOptions::new()
.append(true)
.open(&path)
.await
.map_err(|e| Error::tool("bash", format!("Failed to open temp file: {e}")))?;
#[cfg(unix)]
if let Some(expected) = expected_inode {
use std::os::unix::fs::MetadataExt;
let meta = file
.metadata()
.await
.map_err(|e| Error::tool("bash", format!("Failed to stat temp file: {e}")))?;
if meta.ino() != expected {
return Err(Error::tool(
"bash",
"Temp file identity mismatch (possible TOCTOU attack)".to_string(),
));
}
}
let mut failed_flush = false;
for existing in &state.chunks {
if let Err(e) = file.write_all(existing).await {
tracing::warn!("Failed to flush bash chunk to temp file: {e}");
failed_flush = true;
break;
}
}
if failed_flush {
state.spill_failed = true;
let _ = std::fs::remove_file(&path);
} else {
state.temp_file_path = Some(path);
state.temp_file = Some(file);
}
}
if let Some(file) = state.temp_file.as_mut() {
if state.total_bytes <= BASH_FILE_LIMIT_BYTES {
if let Err(e) = file.write_all(&chunk).await {
tracing::warn!("Failed to write bash chunk to temp file: {e}");
state.spill_failed = true;
state.temp_file = None;
}
} else {
if !state.spill_failed {
tracing::warn!("Bash output exceeded hard limit; stopping file log");
state.spill_failed = true;
state.temp_file = None;
}
}
}
state.chunks_bytes = state.chunks_bytes.saturating_add(chunk.len());
state.chunks.push_back(chunk);
while state.chunks_bytes > state.max_chunks_bytes && state.chunks.len() > 1 {
if let Some(front) = state.chunks.pop_front() {
state.chunks_bytes = state.chunks_bytes.saturating_sub(front.len());
}
}
Ok(())
}
const fn line_count_from_newline_count(
total_bytes: usize,
newline_count: usize,
last_byte_was_newline: bool,
) -> usize {
if total_bytes == 0 {
0
} else if last_byte_was_newline {
newline_count
} else {
newline_count.saturating_add(1)
}
}
fn emit_bash_update(
state: &BashOutputState,
on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
) -> Result<()> {
if let Some(callback) = on_update {
let raw = concat_chunks(&state.chunks);
let full_text = String::from_utf8_lossy(&raw);
let truncation =
truncate_tail(full_text.into_owned(), DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
let elapsed_ms = state.start_time.elapsed().as_millis();
let line_count = line_count_from_newline_count(
state.total_bytes,
state.line_count,
state.last_byte_was_newline,
);
let mut details = serde_json::json!({
"progress": {
"elapsedMs": elapsed_ms,
"lineCount": line_count,
"byteCount": state.total_bytes
}
});
let details_map = details.as_object_mut().expect("just built");
if let Some(timeout) = state.timeout_ms {
details_map["progress"]
.as_object_mut()
.expect("just built")
.insert("timeoutMs".into(), serde_json::json!(timeout));
}
if truncation.truncated {
details_map.insert("truncation".into(), serde_json::to_value(&truncation)?);
}
if let Some(path) = state.temp_file_path.as_ref() {
details_map.insert(
"fullOutputPath".into(),
serde_json::Value::String(path.display().to_string()),
);
}
callback(ToolUpdate {
content: vec![ContentBlock::Text(TextContent::new(truncation.content))],
details: Some(details),
});
}
Ok(())
}
#[allow(dead_code)]
async fn process_bash_chunk(
chunk: Vec<u8>,
state: &mut BashOutputState,
on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
) -> Result<()> {
ingest_bash_chunk(chunk, state).await?;
emit_bash_update(state, on_update)
}
pub(crate) struct ProcessGuard {
child: Option<std::process::Child>,
kill_tree: bool,
}
impl ProcessGuard {
pub(crate) const fn new(child: std::process::Child, kill_tree: bool) -> Self {
Self {
child: Some(child),
kill_tree,
}
}
pub(crate) fn try_wait_child(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
self.child
.as_mut()
.map_or(Ok(None), std::process::Child::try_wait)
}
pub(crate) fn kill(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
if let Some(mut child) = self.child.take() {
if self.kill_tree {
let pid = child.id();
kill_process_tree(Some(pid));
}
let _ = child.kill();
let status = child.wait()?;
return Ok(Some(status));
}
Ok(None)
}
pub(crate) fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
if let Some(mut child) = self.child.take() {
return child.wait();
}
Err(std::io::Error::other("Already waited"))
}
}
impl Drop for ProcessGuard {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
match child.try_wait() {
Ok(None) => {}
Ok(Some(_)) | Err(_) => return,
}
if self.kill_tree {
let pid = child.id();
kill_process_tree(Some(pid));
}
let _ = child.kill();
let _ = child.wait();
}
}
}
fn terminate_process_tree(pid: Option<u32>) {
kill_process_tree_with(pid, sysinfo::Signal::Term);
}
pub fn kill_process_tree(pid: Option<u32>) {
kill_process_tree_with(pid, sysinfo::Signal::Kill);
}
fn kill_process_tree_with(pid: Option<u32>, signal: sysinfo::Signal) {
let Some(pid) = pid else {
return;
};
let root = sysinfo::Pid::from_u32(pid);
let mut sys = sysinfo::System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let mut children_map: HashMap<sysinfo::Pid, Vec<sysinfo::Pid>> = HashMap::new();
for (p, proc_) in sys.processes() {
if let Some(parent) = proc_.parent() {
children_map.entry(parent).or_default().push(*p);
}
}
let mut to_kill = Vec::new();
collect_process_tree(root, &children_map, &mut to_kill);
for pid in to_kill.into_iter().rev() {
if let Some(proc_) = sys.process(pid) {
match proc_.kill_with(signal) {
Some(true) => {}
Some(false) | None => {
let _ = proc_.kill();
}
}
}
}
}
fn collect_process_tree(
pid: sysinfo::Pid,
children_map: &HashMap<sysinfo::Pid, Vec<sysinfo::Pid>>,
out: &mut Vec<sysinfo::Pid>,
) {
out.push(pid);
if let Some(children) = children_map.get(&pid) {
for child in children {
collect_process_tree(*child, children_map, out);
}
}
}
fn format_grep_path(file_path: &Path, cwd: &Path) -> String {
if let Ok(rel) = file_path.strip_prefix(cwd) {
let rel_str = rel.display().to_string().replace('\\', "/");
if !rel_str.is_empty() {
return rel_str;
}
}
file_path.display().to_string().replace('\\', "/")
}
async fn get_file_lines_async<'a>(
path: &Path,
cache: &'a mut HashMap<PathBuf, Vec<String>>,
) -> &'a [String] {
if !cache.contains_key(path) {
if let Ok(meta) = asupersync::fs::metadata(path).await {
if meta.len() > 10 * 1024 * 1024 {
cache.insert(path.to_path_buf(), Vec::new());
return &[];
}
}
let bytes = asupersync::fs::read(path).await.unwrap_or_default();
let content = String::from_utf8_lossy(&bytes).to_string();
let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
let lines: Vec<String> = normalized.split('\n').map(str::to_string).collect();
cache.insert(path.to_path_buf(), lines);
}
cache.get(path).unwrap().as_slice()
}
fn find_fd_binary() -> Option<&'static str> {
static BINARY: OnceLock<Option<&'static str>> = OnceLock::new();
*BINARY.get_or_init(|| {
if std::process::Command::new("fd")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok()
{
return Some("fd");
}
if std::process::Command::new("fdfind")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok()
{
return Some("fdfind");
}
None
})
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[cfg(target_os = "linux")]
use std::time::Duration;
#[test]
fn test_truncate_head() {
let content = "line1\nline2\nline3\nline4\nline5".to_string();
let result = truncate_head(content, 3, 1000);
assert_eq!(result.content, "line1\nline2\nline3\n");
assert!(result.truncated);
assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
assert_eq!(result.total_lines, 5);
assert_eq!(result.output_lines, 3);
}
#[test]
fn test_truncate_tail() {
let content = "line1\nline2\nline3\nline4\nline5".to_string();
let result = truncate_tail(content, 3, 1000);
assert_eq!(result.content, "line3\nline4\nline5");
assert!(result.truncated);
assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
assert_eq!(result.total_lines, 5);
assert_eq!(result.output_lines, 3);
}
#[test]
fn test_truncate_tail_zero_lines_returns_empty_output() {
let result = truncate_tail("line1\nline2".to_string(), 0, 1000);
assert!(result.truncated);
assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
assert_eq!(result.output_lines, 0);
assert_eq!(result.output_bytes, 0);
assert!(result.content.is_empty());
}
#[test]
fn test_line_count_from_newline_count_matches_trailing_newline_semantics() {
assert_eq!(line_count_from_newline_count(0, 0, false), 0);
assert_eq!(line_count_from_newline_count(2, 1, true), 1);
assert_eq!(line_count_from_newline_count(1, 0, false), 1);
assert_eq!(line_count_from_newline_count(3, 1, false), 2);
}
#[test]
fn test_truncate_by_bytes() {
let content = "short\nthis is a longer line\nanother".to_string();
let result = truncate_head(content, 100, 15);
assert!(result.truncated);
assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
}
#[test]
fn test_resolve_path_absolute() {
let cwd = PathBuf::from("/home/user/project");
let result = resolve_path("/absolute/path", &cwd);
assert_eq!(result, PathBuf::from("/absolute/path"));
}
#[test]
fn test_resolve_path_relative() {
let cwd = PathBuf::from("/home/user/project");
let result = resolve_path("src/main.rs", &cwd);
assert_eq!(result, PathBuf::from("/home/user/project/src/main.rs"));
}
#[test]
fn test_normalize_dot_segments_preserves_root() {
let result = normalize_dot_segments(std::path::Path::new("/../etc/passwd"));
assert_eq!(result, PathBuf::from("/etc/passwd"));
}
#[test]
fn test_normalize_dot_segments_preserves_leading_parent_for_relative() {
let result = normalize_dot_segments(std::path::Path::new("../a/../b"));
assert_eq!(result, PathBuf::from("../b"));
}
#[test]
fn test_detect_supported_image_mime_type_from_bytes() {
assert_eq!(
detect_supported_image_mime_type_from_bytes(b"\x89PNG\r\n\x1A\n"),
Some("image/png")
);
assert_eq!(
detect_supported_image_mime_type_from_bytes(b"\xFF\xD8\xFF"),
Some("image/jpeg")
);
assert_eq!(
detect_supported_image_mime_type_from_bytes(b"GIF89a"),
Some("image/gif")
);
assert_eq!(
detect_supported_image_mime_type_from_bytes(b"RIFF1234WEBP"),
Some("image/webp")
);
assert_eq!(
detect_supported_image_mime_type_from_bytes(b"not an image"),
None
);
}
#[test]
fn test_format_size() {
assert_eq!(format_size(500), "500B");
assert_eq!(format_size(1024), "1.0KB");
assert_eq!(format_size(1536), "1.5KB");
assert_eq!(format_size(1_048_576), "1.0MB");
assert_eq!(format_size(1_073_741_824), "1024.0MB");
}
#[test]
fn test_js_string_length() {
assert_eq!(js_string_length("hello"), 5);
assert_eq!(js_string_length("😀"), 2);
}
#[test]
fn test_truncate_line() {
let short = "short line";
let result = truncate_line(short, 100);
assert_eq!(result.text, "short line");
assert!(!result.was_truncated);
let long = "a".repeat(600);
let result = truncate_line(&long, 500);
assert!(result.was_truncated);
assert!(result.text.ends_with("... [truncated]"));
}
fn get_text(content: &[ContentBlock]) -> String {
content
.iter()
.filter_map(|block| {
if let ContentBlock::Text(text) = block {
Some(text.text.clone())
} else {
None
}
})
.collect::<String>()
}
#[test]
fn test_read_valid_file() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("hello.txt"), "alpha\nbeta\ngamma").unwrap();
let tool = ReadTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({ "path": tmp.path().join("hello.txt").to_string_lossy() }),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(text.contains("alpha"));
assert!(text.contains("beta"));
assert!(text.contains("gamma"));
assert!(!out.is_error);
});
}
#[test]
fn test_read_nonexistent_file() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let tool = ReadTool::new(tmp.path());
let err = tool
.execute(
"t",
serde_json::json!({ "path": tmp.path().join("nope.txt").to_string_lossy() }),
None,
)
.await;
assert!(err.is_err());
});
}
#[test]
fn test_read_empty_file() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
let tool = ReadTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({ "path": tmp.path().join("empty.txt").to_string_lossy() }),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert_eq!(text, "");
assert!(!out.is_error);
});
}
#[test]
fn test_read_empty_file_positive_offset_errors() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
let tool = ReadTool::new(tmp.path());
let err = tool
.execute(
"t",
serde_json::json!({
"path": tmp.path().join("empty.txt").to_string_lossy(),
"offset": 1
}),
None,
)
.await;
assert!(err.is_err());
let msg = err.unwrap_err().to_string();
assert!(msg.contains("beyond end of file"));
});
}
#[test]
fn test_read_rejects_zero_limit() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("lines.txt"), "a\nb\nc\n").unwrap();
let tool = ReadTool::new(tmp.path());
let err = tool
.execute(
"t",
serde_json::json!({
"path": tmp.path().join("lines.txt").to_string_lossy(),
"limit": 0
}),
None,
)
.await;
assert!(err.is_err());
assert!(
err.unwrap_err()
.to_string()
.contains("`limit` must be greater than 0")
);
});
}
#[test]
fn test_read_offset_and_limit() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("lines.txt"),
"L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10",
)
.unwrap();
let tool = ReadTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"path": tmp.path().join("lines.txt").to_string_lossy(),
"offset": 3,
"limit": 2
}),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(text.contains("L3"));
assert!(text.contains("L4"));
assert!(!text.contains("L2"));
assert!(!text.contains("L5"));
});
}
#[test]
fn test_read_offset_beyond_eof() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("short.txt"), "a\nb").unwrap();
let tool = ReadTool::new(tmp.path());
let err = tool
.execute(
"t",
serde_json::json!({
"path": tmp.path().join("short.txt").to_string_lossy(),
"offset": 100
}),
None,
)
.await;
assert!(err.is_err());
let msg = err.unwrap_err().to_string();
assert!(msg.contains("beyond end of file"));
});
}
#[test]
fn test_map_normalized_with_trailing_whitespace() {
let content = "A \nB";
let (start, len) = map_normalized_range_to_original(content, 0, 1);
assert_eq!(start, 0);
assert_eq!(len, 1);
assert_eq!(&content[start..start + len], "A");
let (start, len) = map_normalized_range_to_original(content, 1, 1);
assert_eq!(start, 4);
assert_eq!(len, 1);
assert_eq!(&content[start..start + len], "\n");
let (start, len) = map_normalized_range_to_original(content, 2, 1);
assert_eq!(start, 5);
assert_eq!(len, 1);
assert_eq!(&content[start..start + len], "B");
}
#[test]
fn test_read_binary_file_lossy() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let binary_data: Vec<u8> = (0..=255).collect();
std::fs::write(tmp.path().join("binary.bin"), &binary_data).unwrap();
let tool = ReadTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({ "path": tmp.path().join("binary.bin").to_string_lossy() }),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(!text.is_empty());
assert!(!out.is_error);
});
}
#[test]
fn test_read_image_detection() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let png_header: Vec<u8> = vec![
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
let tool = ReadTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
None,
)
.await
.unwrap();
let has_image = out
.content
.iter()
.any(|b| matches!(b, ContentBlock::Image(_)));
assert!(has_image, "expected image content block for PNG file");
});
}
#[test]
fn test_read_blocked_images() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let png_header: Vec<u8> =
vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00];
std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
let tool = ReadTool::with_settings(tmp.path(), false, true);
let err = tool
.execute(
"t",
serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
None,
)
.await;
assert!(err.is_err());
assert!(err.unwrap_err().to_string().contains("blocked"));
});
}
#[test]
fn test_read_truncation_at_max_lines() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let content: String = (0..DEFAULT_MAX_LINES + 500)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(tmp.path().join("big.txt"), &content).unwrap();
let tool = ReadTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({ "path": tmp.path().join("big.txt").to_string_lossy() }),
None,
)
.await
.unwrap();
assert!(out.details.is_some(), "expected truncation details");
let text = get_text(&out.content);
assert!(text.contains("offset="));
});
}
#[test]
fn test_read_first_line_exceeds_max_bytes() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let long_line = "a".repeat(DEFAULT_MAX_BYTES + 128);
std::fs::write(tmp.path().join("too_long.txt"), long_line).unwrap();
let tool = ReadTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({ "path": tmp.path().join("too_long.txt").to_string_lossy() }),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(text.contains("exceeds 50.0KB limit"));
let details = out.details.expect("expected truncation details");
assert_eq!(
details
.get("truncation")
.and_then(|v| v.get("firstLineExceedsLimit"))
.and_then(serde_json::Value::as_bool),
Some(true)
);
});
}
#[test]
fn test_read_unicode_content() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("uni.txt"), "Hello 你好 🌍\nLine 2 café").unwrap();
let tool = ReadTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({ "path": tmp.path().join("uni.txt").to_string_lossy() }),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(text.contains("你好"));
assert!(text.contains("🌍"));
assert!(text.contains("café"));
});
}
#[test]
fn test_write_new_file() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let tool = WriteTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"path": tmp.path().join("new.txt").to_string_lossy(),
"content": "hello world"
}),
None,
)
.await
.unwrap();
assert!(!out.is_error);
let contents = std::fs::read_to_string(tmp.path().join("new.txt")).unwrap();
assert_eq!(contents, "hello world");
});
}
#[test]
fn test_write_overwrite_existing() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("exist.txt"), "old content").unwrap();
let tool = WriteTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"path": tmp.path().join("exist.txt").to_string_lossy(),
"content": "new content"
}),
None,
)
.await
.unwrap();
assert!(!out.is_error);
let contents = std::fs::read_to_string(tmp.path().join("exist.txt")).unwrap();
assert_eq!(contents, "new content");
});
}
#[test]
fn test_write_creates_parent_dirs() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let tool = WriteTool::new(tmp.path());
let deep_path = tmp.path().join("a/b/c/deep.txt");
let out = tool
.execute(
"t",
serde_json::json!({
"path": deep_path.to_string_lossy(),
"content": "deep file"
}),
None,
)
.await
.unwrap();
assert!(!out.is_error);
assert!(deep_path.exists());
assert_eq!(std::fs::read_to_string(&deep_path).unwrap(), "deep file");
});
}
#[test]
fn test_write_empty_file() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let tool = WriteTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"path": tmp.path().join("empty.txt").to_string_lossy(),
"content": ""
}),
None,
)
.await
.unwrap();
assert!(!out.is_error);
let contents = std::fs::read_to_string(tmp.path().join("empty.txt")).unwrap();
assert_eq!(contents, "");
let text = get_text(&out.content);
assert!(text.contains("Successfully wrote 0 bytes"));
});
}
#[test]
fn test_write_unicode_content() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let tool = WriteTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"path": tmp.path().join("unicode.txt").to_string_lossy(),
"content": "日本語 🎉 Ñoño"
}),
None,
)
.await
.unwrap();
assert!(!out.is_error);
let contents = std::fs::read_to_string(tmp.path().join("unicode.txt")).unwrap();
assert_eq!(contents, "日本語 🎉 Ñoño");
});
}
#[test]
#[cfg(unix)]
fn test_write_file_permissions_unix() {
use std::os::unix::fs::PermissionsExt;
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let tool = WriteTool::new(tmp.path());
let path = tmp.path().join("perms.txt");
let out = tool
.execute(
"t",
serde_json::json!({
"path": path.to_string_lossy(),
"content": "check perms"
}),
None,
)
.await
.unwrap();
assert!(!out.is_error);
let meta = std::fs::metadata(&path).unwrap();
let mode = meta.permissions().mode();
assert_eq!(mode & 0o777, 0o644, "Expected 0o644 permissions");
});
}
#[test]
fn test_edit_exact_match_replace() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("code.rs"), "fn foo() { bar() }").unwrap();
let tool = EditTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"path": tmp.path().join("code.rs").to_string_lossy(),
"oldText": "bar()",
"newText": "baz()"
}),
None,
)
.await
.unwrap();
assert!(!out.is_error);
let contents = std::fs::read_to_string(tmp.path().join("code.rs")).unwrap();
assert_eq!(contents, "fn foo() { baz() }");
});
}
#[test]
fn test_edit_no_match_error() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("code.rs"), "fn foo() {}").unwrap();
let tool = EditTool::new(tmp.path());
let err = tool
.execute(
"t",
serde_json::json!({
"path": tmp.path().join("code.rs").to_string_lossy(),
"oldText": "NONEXISTENT TEXT",
"newText": "replacement"
}),
None,
)
.await;
assert!(err.is_err());
});
}
#[test]
fn test_edit_empty_old_text_error() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("code.rs");
std::fs::write(&path, "fn foo() {}").unwrap();
let tool = EditTool::new(tmp.path());
let err = tool
.execute(
"t",
serde_json::json!({
"path": path.to_string_lossy(),
"oldText": "",
"newText": "prefix"
}),
None,
)
.await
.expect_err("empty oldText should be rejected");
let msg = err.to_string();
assert!(
msg.contains("old text cannot be empty"),
"unexpected error: {msg}"
);
let after = std::fs::read_to_string(path).unwrap();
assert_eq!(after, "fn foo() {}");
});
}
#[test]
fn test_edit_ambiguous_match_error() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("dup.txt"), "hello hello hello").unwrap();
let tool = EditTool::new(tmp.path());
let err = tool
.execute(
"t",
serde_json::json!({
"path": tmp.path().join("dup.txt").to_string_lossy(),
"oldText": "hello",
"newText": "world"
}),
None,
)
.await;
assert!(err.is_err(), "expected error for ambiguous match");
});
}
#[test]
fn test_edit_multi_line_replacement() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("multi.txt"),
"line 1\nline 2\nline 3\nline 4",
)
.unwrap();
let tool = EditTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"path": tmp.path().join("multi.txt").to_string_lossy(),
"oldText": "line 2\nline 3",
"newText": "replaced 2\nreplaced 3\nextra line"
}),
None,
)
.await
.unwrap();
assert!(!out.is_error);
let contents = std::fs::read_to_string(tmp.path().join("multi.txt")).unwrap();
assert_eq!(
contents,
"line 1\nreplaced 2\nreplaced 3\nextra line\nline 4"
);
});
}
#[test]
fn test_edit_unicode_content() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("uni.txt"), "Héllo wörld 🌍").unwrap();
let tool = EditTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"path": tmp.path().join("uni.txt").to_string_lossy(),
"oldText": "wörld 🌍",
"newText": "Welt 🌎"
}),
None,
)
.await
.unwrap();
assert!(!out.is_error);
let contents = std::fs::read_to_string(tmp.path().join("uni.txt")).unwrap();
assert_eq!(contents, "Héllo Welt 🌎");
});
}
#[test]
fn test_edit_missing_file() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let tool = EditTool::new(tmp.path());
let err = tool
.execute(
"t",
serde_json::json!({
"path": tmp.path().join("nope.txt").to_string_lossy(),
"oldText": "foo",
"newText": "bar"
}),
None,
)
.await;
assert!(err.is_err());
});
}
#[test]
fn test_bash_simple_command() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let tool = BashTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({ "command": "echo hello_from_bash" }),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(text.contains("hello_from_bash"));
assert!(!out.is_error);
});
}
#[test]
fn test_bash_exit_code_nonzero() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let tool = BashTool::new(tmp.path());
let out = tool
.execute("t", serde_json::json!({ "command": "exit 42" }), None)
.await
.expect("non-zero exit should return Ok with is_error=true");
assert!(out.is_error, "non-zero exit must set is_error");
let msg = get_text(&out.content);
assert!(
msg.contains("42"),
"expected exit code 42 in output, got: {msg}"
);
});
}
#[cfg(unix)]
#[test]
fn test_bash_signal_termination_is_error() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let tool = BashTool::new(tmp.path());
let out = tool
.execute("t", serde_json::json!({ "command": "kill -KILL $$" }), None)
.await
.expect("signal-terminated shell should return Ok with is_error=true");
assert!(
out.is_error,
"signal-terminated shell must be reported as error"
);
let msg = get_text(&out.content);
assert!(
msg.contains("Command exited with code"),
"expected explicit exit-code report, got: {msg}"
);
assert!(
!msg.contains("Command exited with code 0"),
"signal-terminated shell must not appear successful: {msg}"
);
});
}
#[test]
fn test_bash_stderr_capture() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let tool = BashTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({ "command": "echo stderr_msg >&2" }),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(
text.contains("stderr_msg"),
"expected stderr output in result, got: {text}"
);
});
}
#[test]
fn test_bash_timeout() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let tool = BashTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({ "command": "sleep 60", "timeout": 2 }),
None,
)
.await
.expect("timeout should return Ok with is_error=true");
assert!(out.is_error, "timeout must set is_error");
let msg = get_text(&out.content);
assert!(
msg.to_lowercase().contains("timeout") || msg.to_lowercase().contains("timed out"),
"expected timeout indication, got: {msg}"
);
});
}
#[cfg(target_os = "linux")]
#[test]
fn test_bash_timeout_kills_process_tree() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("leaked_child.txt");
let tool = BashTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"command": "(sleep 3; echo leaked > leaked_child.txt) & sleep 10",
"timeout": 1
}),
None,
)
.await
.expect("timeout should return Ok with is_error=true");
assert!(out.is_error, "timeout must set is_error");
let msg = get_text(&out.content);
assert!(msg.contains("Command timed out"));
std::thread::sleep(Duration::from_secs(4));
assert!(
!marker.exists(),
"background child was not terminated on timeout"
);
});
}
#[test]
#[cfg(unix)]
fn test_bash_working_directory() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let tool = BashTool::new(tmp.path());
let out = tool
.execute("t", serde_json::json!({ "command": "pwd" }), None)
.await
.unwrap();
let text = get_text(&out.content);
let canonical = tmp.path().canonicalize().unwrap();
assert!(
text.contains(&canonical.to_string_lossy().to_string()),
"expected cwd in output, got: {text}"
);
});
}
#[test]
fn test_bash_multiline_output() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let tool = BashTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({ "command": "echo line1; echo line2; echo line3" }),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(text.contains("line1"));
assert!(text.contains("line2"));
assert!(text.contains("line3"));
});
}
#[test]
fn test_grep_basic_pattern() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("search.txt"),
"apple\nbanana\napricot\ncherry",
)
.unwrap();
let tool = GrepTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"pattern": "ap",
"path": tmp.path().join("search.txt").to_string_lossy()
}),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(text.contains("apple"));
assert!(text.contains("apricot"));
assert!(!text.contains("banana"));
assert!(!text.contains("cherry"));
});
}
#[test]
fn test_grep_regex_pattern() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("regex.txt"),
"foo123\nbar456\nbaz789\nfoo000",
)
.unwrap();
let tool = GrepTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"pattern": "foo\\d+",
"path": tmp.path().join("regex.txt").to_string_lossy()
}),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(text.contains("foo123"));
assert!(text.contains("foo000"));
assert!(!text.contains("bar456"));
});
}
#[test]
fn test_grep_case_insensitive() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("case.txt"), "Hello\nhello\nHELLO").unwrap();
let tool = GrepTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"pattern": "hello",
"path": tmp.path().join("case.txt").to_string_lossy(),
"ignoreCase": true
}),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(text.contains("Hello"));
assert!(text.contains("hello"));
assert!(text.contains("HELLO"));
});
}
#[test]
fn test_grep_case_sensitive_by_default() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("case_sensitive.txt"), "Hello\nHELLO").unwrap();
let tool = GrepTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"pattern": "hello",
"path": tmp.path().join("case_sensitive.txt").to_string_lossy()
}),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(
text.contains("No matches found"),
"expected case-sensitive search to find no matches, got: {text}"
);
});
}
#[test]
fn test_grep_no_matches() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("nothing.txt"), "alpha\nbeta\ngamma").unwrap();
let tool = GrepTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"pattern": "ZZZZZ_NOMATCH",
"path": tmp.path().join("nothing.txt").to_string_lossy()
}),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(
text.to_lowercase().contains("no match")
|| text.is_empty()
|| text.to_lowercase().contains("no results"),
"expected no-match indication, got: {text}"
);
});
}
#[test]
fn test_grep_context_lines() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("ctx.txt"),
"aaa\nbbb\nccc\ntarget\nddd\neee\nfff",
)
.unwrap();
let tool = GrepTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"pattern": "target",
"path": tmp.path().join("ctx.txt").to_string_lossy(),
"context": 1
}),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(text.contains("target"));
assert!(text.contains("ccc"), "expected context line before match");
assert!(text.contains("ddd"), "expected context line after match");
});
}
#[test]
fn test_grep_limit() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let content: String = (0..200)
.map(|i| format!("match_line_{i}"))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(tmp.path().join("many.txt"), &content).unwrap();
let tool = GrepTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"pattern": "match_line",
"path": tmp.path().join("many.txt").to_string_lossy(),
"limit": 5
}),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
let match_count = text.matches("match_line_").count();
assert!(
match_count <= 5,
"expected at most 5 matches with limit=5, got {match_count}"
);
let details = out.details.expect("expected limit details");
assert_eq!(
details
.get("matchLimitReached")
.and_then(serde_json::Value::as_u64),
Some(5)
);
});
}
#[test]
fn test_grep_large_output_does_not_deadlock_reader_threads() {
asupersync::test_utils::run_test(|| async {
use std::fmt::Write as _;
let tmp = tempfile::tempdir().unwrap();
let mut content = String::with_capacity(80_000);
for i in 0..5000 {
let _ = writeln!(&mut content, "needle_line_{i}");
}
let file = tmp.path().join("large_grep.txt");
std::fs::write(&file, content).unwrap();
let tool = GrepTool::new(tmp.path());
let run = tool.execute(
"t",
serde_json::json!({
"pattern": "needle_line_",
"path": file.to_string_lossy(),
"limit": 6000
}),
None,
);
let out = asupersync::time::timeout(
asupersync::time::wall_now(),
Duration::from_secs(15),
Box::pin(run),
)
.await
.expect("grep timed out; possible stdout/stderr reader deadlock")
.expect("grep should succeed");
let text = get_text(&out.content);
assert!(text.contains("needle_line_0"));
});
}
#[test]
fn test_grep_respects_gitignore() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
std::fs::write(tmp.path().join("ignored.txt"), "needle in ignored file").unwrap();
std::fs::write(tmp.path().join("visible.txt"), "nothing here").unwrap();
let tool = GrepTool::new(tmp.path());
let out = tool
.execute("t", serde_json::json!({ "pattern": "needle" }), None)
.await
.unwrap();
let text = get_text(&out.content);
assert!(
text.contains("No matches found"),
"expected ignored file to be excluded, got: {text}"
);
});
}
#[test]
fn test_grep_literal_mode() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("literal.txt"), "a+b\na.b\nab\na\\+b").unwrap();
let tool = GrepTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"pattern": "a+b",
"path": tmp.path().join("literal.txt").to_string_lossy(),
"literal": true
}),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(text.contains("a+b"), "literal match should find 'a+b'");
});
}
#[test]
fn test_find_glob_pattern() {
asupersync::test_utils::run_test(|| async {
if find_fd_binary().is_none() {
return;
}
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("file1.rs"), "").unwrap();
std::fs::write(tmp.path().join("file2.rs"), "").unwrap();
std::fs::write(tmp.path().join("file3.txt"), "").unwrap();
let tool = FindTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"pattern": "*.rs",
"path": tmp.path().to_string_lossy()
}),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(text.contains("file1.rs"));
assert!(text.contains("file2.rs"));
assert!(!text.contains("file3.txt"));
});
}
#[test]
fn test_find_limit() {
asupersync::test_utils::run_test(|| async {
if find_fd_binary().is_none() {
return;
}
let tmp = tempfile::tempdir().unwrap();
for i in 0..20 {
std::fs::write(tmp.path().join(format!("f{i}.txt")), "").unwrap();
}
let tool = FindTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"pattern": "*.txt",
"path": tmp.path().to_string_lossy(),
"limit": 5
}),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
let file_count = text.lines().filter(|l| l.contains(".txt")).count();
assert!(
file_count <= 5,
"expected at most 5 files with limit=5, got {file_count}"
);
let details = out.details.expect("expected limit details");
assert_eq!(
details
.get("resultLimitReached")
.and_then(serde_json::Value::as_u64),
Some(5)
);
});
}
#[test]
fn test_find_no_matches() {
asupersync::test_utils::run_test(|| async {
if find_fd_binary().is_none() {
return;
}
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("only.txt"), "").unwrap();
let tool = FindTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"pattern": "*.rs",
"path": tmp.path().to_string_lossy()
}),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(
text.to_lowercase().contains("no files found")
|| text.to_lowercase().contains("no matches")
|| text.is_empty(),
"expected no-match indication, got: {text}"
);
});
}
#[test]
fn test_find_nonexistent_path() {
asupersync::test_utils::run_test(|| async {
if find_fd_binary().is_none() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let tool = FindTool::new(tmp.path());
let err = tool
.execute(
"t",
serde_json::json!({
"pattern": "*.rs",
"path": tmp.path().join("nonexistent").to_string_lossy()
}),
None,
)
.await;
assert!(err.is_err());
});
}
#[test]
fn test_find_nested_directories() {
asupersync::test_utils::run_test(|| async {
if find_fd_binary().is_none() {
return;
}
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("a/b/c")).unwrap();
std::fs::write(tmp.path().join("top.rs"), "").unwrap();
std::fs::write(tmp.path().join("a/mid.rs"), "").unwrap();
std::fs::write(tmp.path().join("a/b/c/deep.rs"), "").unwrap();
let tool = FindTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"pattern": "*.rs",
"path": tmp.path().to_string_lossy()
}),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(text.contains("top.rs"));
assert!(text.contains("mid.rs"));
assert!(text.contains("deep.rs"));
});
}
#[test]
fn test_find_results_are_sorted() {
asupersync::test_utils::run_test(|| async {
if find_fd_binary().is_none() {
return;
}
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("zeta.txt"), "").unwrap();
std::fs::write(tmp.path().join("alpha.txt"), "").unwrap();
std::fs::write(tmp.path().join("beta.txt"), "").unwrap();
let tool = FindTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"pattern": "*.txt",
"path": tmp.path().to_string_lossy()
}),
None,
)
.await
.unwrap();
let lines: Vec<String> = get_text(&out.content)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect();
let mut sorted = lines.clone();
sorted.sort_by_key(|line| line.to_lowercase());
assert_eq!(lines, sorted, "expected sorted find output");
});
}
#[test]
fn test_find_respects_gitignore() {
asupersync::test_utils::run_test(|| async {
if find_fd_binary().is_none() {
return;
}
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
std::fs::write(tmp.path().join("ignored.txt"), "").unwrap();
let tool = FindTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"pattern": "*.txt",
"path": tmp.path().to_string_lossy()
}),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(
text.contains("No files found matching pattern"),
"expected .gitignore'd files to be excluded, got: {text}"
);
});
}
#[test]
fn test_ls_directory_listing() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("file_a.txt"), "content").unwrap();
std::fs::write(tmp.path().join("file_b.rs"), "fn main() {}").unwrap();
std::fs::create_dir(tmp.path().join("subdir")).unwrap();
let tool = LsTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({ "path": tmp.path().to_string_lossy() }),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(text.contains("file_a.txt"));
assert!(text.contains("file_b.rs"));
assert!(text.contains("subdir"));
});
}
#[test]
fn test_ls_trailing_slash_for_dirs() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("file.txt"), "").unwrap();
std::fs::create_dir(tmp.path().join("mydir")).unwrap();
let tool = LsTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({ "path": tmp.path().to_string_lossy() }),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
assert!(
text.contains("mydir/"),
"expected trailing slash for directory, got: {text}"
);
});
}
#[test]
fn test_ls_limit() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
for i in 0..20 {
std::fs::write(tmp.path().join(format!("item_{i:02}.txt")), "").unwrap();
}
let tool = LsTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"path": tmp.path().to_string_lossy(),
"limit": 5
}),
None,
)
.await
.unwrap();
let text = get_text(&out.content);
let entry_count = text.lines().filter(|l| l.contains("item_")).count();
assert!(
entry_count <= 5,
"expected at most 5 entries, got {entry_count}"
);
let details = out.details.expect("expected limit details");
assert_eq!(
details
.get("entryLimitReached")
.and_then(serde_json::Value::as_u64),
Some(5)
);
});
}
#[test]
fn test_ls_nonexistent_directory() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let tool = LsTool::new(tmp.path());
let err = tool
.execute(
"t",
serde_json::json!({ "path": tmp.path().join("nope").to_string_lossy() }),
None,
)
.await;
assert!(err.is_err());
});
}
#[test]
fn test_ls_empty_directory() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let empty_dir = tmp.path().join("empty");
std::fs::create_dir(&empty_dir).unwrap();
let tool = LsTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({ "path": empty_dir.to_string_lossy() }),
None,
)
.await
.unwrap();
assert!(!out.is_error);
});
}
#[test]
fn test_ls_default_cwd() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("in_cwd.txt"), "").unwrap();
let tool = LsTool::new(tmp.path());
let out = tool
.execute("t", serde_json::json!({}), None)
.await
.unwrap();
let text = get_text(&out.content);
assert!(
text.contains("in_cwd.txt"),
"expected cwd listing to include the file, got: {text}"
);
});
}
#[test]
fn test_truncate_head_no_truncation() {
let content = "short".to_string();
let result = truncate_head(content, 100, 1000);
assert!(!result.truncated);
assert_eq!(result.content, "short");
assert_eq!(result.truncated_by, None);
}
#[test]
fn test_truncate_tail_no_truncation() {
let content = "short".to_string();
let result = truncate_tail(content, 100, 1000);
assert!(!result.truncated);
assert_eq!(result.content, "short");
}
#[test]
fn test_truncate_head_empty_input() {
let result = truncate_head(String::new(), 100, 1000);
assert!(!result.truncated);
assert_eq!(result.content, "");
}
#[test]
fn test_truncate_tail_empty_input() {
let result = truncate_tail(String::new(), 100, 1000);
assert!(!result.truncated);
assert_eq!(result.content, "");
}
#[test]
fn test_detect_line_ending_crlf() {
assert_eq!(detect_line_ending("hello\r\nworld"), "\r\n");
}
#[test]
fn test_detect_line_ending_lf() {
assert_eq!(detect_line_ending("hello\nworld"), "\n");
}
#[test]
fn test_detect_line_ending_no_newline() {
assert_eq!(detect_line_ending("hello world"), "\n");
}
#[test]
fn test_normalize_to_lf() {
assert_eq!(normalize_to_lf("a\r\nb\rc\nd"), "a\nb\nc\nd");
}
#[test]
fn test_strip_bom_present() {
let (result, had_bom) = strip_bom("\u{FEFF}hello");
assert_eq!(result, "hello");
assert!(had_bom);
}
#[test]
fn test_strip_bom_absent() {
let (result, had_bom) = strip_bom("hello");
assert_eq!(result, "hello");
assert!(!had_bom);
}
#[test]
fn test_resolve_path_tilde_expansion() {
let cwd = PathBuf::from("/home/user/project");
let result = resolve_path("~/file.txt", &cwd);
assert!(!result.to_string_lossy().starts_with("~/"));
}
fn arbitrary_text() -> impl Strategy<Value = String> {
prop::collection::vec(any::<u8>(), 0..512)
.prop_map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
}
fn match_char_strategy() -> impl Strategy<Value = char> {
prop_oneof![
8 => any::<char>(),
1 => Just('\u{00A0}'),
1 => Just('\u{202F}'),
1 => Just('\u{205F}'),
1 => Just('\u{3000}'),
1 => Just('\u{2018}'),
1 => Just('\u{2019}'),
1 => Just('\u{201C}'),
1 => Just('\u{201D}'),
1 => Just('\u{201E}'),
1 => Just('\u{201F}'),
1 => Just('\u{2010}'),
1 => Just('\u{2011}'),
1 => Just('\u{2012}'),
1 => Just('\u{2013}'),
1 => Just('\u{2014}'),
1 => Just('\u{2015}'),
1 => Just('\u{2212}'),
1 => Just('\u{200D}'),
1 => Just('\u{0301}'),
]
}
fn arbitrary_match_text() -> impl Strategy<Value = String> {
prop_oneof![
9 => prop::collection::vec(match_char_strategy(), 0..2048),
1 => prop::collection::vec(match_char_strategy(), 8192..16384),
]
.prop_map(|chars| chars.into_iter().collect())
}
fn line_char_strategy() -> impl Strategy<Value = char> {
prop_oneof![
8 => any::<char>().prop_filter("single-line chars only", |c| *c != '\n'),
1 => Just('é'),
1 => Just('你'),
1 => Just('😀'),
]
}
fn boundary_line_text() -> impl Strategy<Value = String> {
prop_oneof![
Just(0usize),
Just(GREP_MAX_LINE_LENGTH.saturating_sub(1)),
Just(GREP_MAX_LINE_LENGTH),
Just(GREP_MAX_LINE_LENGTH + 1),
0usize..(GREP_MAX_LINE_LENGTH + 128),
]
.prop_flat_map(|len| {
prop::collection::vec(line_char_strategy(), len)
.prop_map(|chars| chars.into_iter().collect())
})
}
fn safe_relative_segment() -> impl Strategy<Value = String> {
prop_oneof![
proptest::string::string_regex("[A-Za-z0-9._-]{1,12}")
.expect("segment regex should compile"),
Just("emoji😀".to_string()),
Just("accent-é".to_string()),
Just("rtl-עברית".to_string()),
Just("line\nbreak".to_string()),
Just("nul\0byte".to_string()),
]
.prop_filter("segment cannot be . or ..", |segment| {
segment != "." && segment != ".."
})
}
fn safe_relative_path() -> impl Strategy<Value = String> {
prop::collection::vec(safe_relative_segment(), 1..6).prop_map(|segments| segments.join("/"))
}
fn pathish_input() -> impl Strategy<Value = String> {
prop_oneof![
5 => safe_relative_path(),
2 => safe_relative_path().prop_map(|p| format!("../{p}")),
2 => safe_relative_path().prop_map(|p| format!("../../{p}")),
1 => safe_relative_path().prop_map(|p| format!("/tmp/{p}")),
1 => safe_relative_path().prop_map(|p| format!("~/{p}")),
1 => Just("~".to_string()),
1 => Just(".".to_string()),
1 => Just("..".to_string()),
1 => Just("././nested/../file.txt".to_string()),
]
}
proptest! {
#![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })]
#[test]
fn proptest_truncate_head_invariants(
input in arbitrary_text(),
max_lines in 0usize..32,
max_bytes in 0usize..256,
) {
let result = truncate_head(input.clone(), max_lines, max_bytes);
prop_assert!(result.output_lines <= max_lines);
prop_assert!(result.output_bytes <= max_bytes);
prop_assert_eq!(result.output_bytes, result.content.len());
prop_assert_eq!(result.truncated, result.truncated_by.is_some());
prop_assert!(input.starts_with(&result.content));
let repeat = truncate_head(result.content.clone(), max_lines, max_bytes);
prop_assert_eq!(&repeat.content, &result.content);
if result.truncated {
prop_assert!(result.total_lines > max_lines || result.total_bytes > max_bytes);
} else {
prop_assert_eq!(&result.content, &input);
prop_assert!(result.total_lines <= max_lines);
prop_assert!(result.total_bytes <= max_bytes);
}
if result.first_line_exceeds_limit {
prop_assert!(result.truncated);
prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
prop_assert!(result.output_bytes <= max_bytes);
prop_assert!(result.output_lines <= 1);
prop_assert!(input.starts_with(&result.content));
}
}
#[test]
fn proptest_truncate_tail_invariants(
input in arbitrary_text(),
max_lines in 0usize..32,
max_bytes in 0usize..256,
) {
let result = truncate_tail(input.clone(), max_lines, max_bytes);
prop_assert!(result.output_lines <= max_lines);
prop_assert!(result.output_bytes <= max_bytes);
prop_assert_eq!(result.output_bytes, result.content.len());
prop_assert_eq!(result.truncated, result.truncated_by.is_some());
prop_assert!(input.ends_with(&result.content));
let repeat = truncate_tail(result.content.clone(), max_lines, max_bytes);
prop_assert_eq!(&repeat.content, &result.content);
if result.last_line_partial {
prop_assert!(result.truncated);
prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
prop_assert!(result.output_lines >= 1 && result.output_lines <= 2);
let content_trimmed = result.content.trim_end_matches('\n');
prop_assert!(input
.split('\n')
.rev()
.any(|line| line.ends_with(content_trimmed)));
}
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
#[test]
fn proptest_normalize_for_match_invariants(input in arbitrary_match_text()) {
let normalized = normalize_for_match(&input);
let renormalized = normalize_for_match(&normalized);
prop_assert_eq!(&renormalized, &normalized);
prop_assert!(normalized.len() <= input.len());
prop_assert!(
normalized.chars().all(|c| {
!is_special_unicode_space(c)
&& !matches!(
c,
'\u{2018}'
| '\u{2019}'
| '\u{201C}'
| '\u{201D}'
| '\u{201E}'
| '\u{201F}'
| '\u{2010}'
| '\u{2011}'
| '\u{2012}'
| '\u{2013}'
| '\u{2014}'
| '\u{2015}'
| '\u{2212}'
)
}),
"normalize_for_match should remove target punctuation/space variants"
);
}
#[test]
fn proptest_truncate_line_boundary_invariants(line in boundary_line_text()) {
const TRUNCATION_SUFFIX: &str = "... [truncated]";
let result = truncate_line(&line, GREP_MAX_LINE_LENGTH);
let line_char_count = line.chars().count();
let suffix_chars = TRUNCATION_SUFFIX.chars().count();
if line_char_count <= GREP_MAX_LINE_LENGTH {
prop_assert!(!result.was_truncated);
prop_assert_eq!(result.text, line);
} else {
prop_assert!(result.was_truncated);
prop_assert!(result.text.ends_with(TRUNCATION_SUFFIX));
let expected_prefix: String = line.chars().take(GREP_MAX_LINE_LENGTH).collect();
let expected = format!("{expected_prefix}{TRUNCATION_SUFFIX}");
prop_assert_eq!(&result.text, &expected);
prop_assert!(result.text.chars().count() <= GREP_MAX_LINE_LENGTH + suffix_chars);
}
}
#[test]
fn proptest_resolve_path_safe_relative_invariants(relative_path in safe_relative_path()) {
let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
let resolved = resolve_path(&relative_path, &cwd);
let normalized = normalize_dot_segments(&resolved);
prop_assert_eq!(&resolved, &cwd.join(&relative_path));
prop_assert!(resolved.starts_with(&cwd));
prop_assert!(normalized.starts_with(&cwd));
prop_assert_eq!(normalize_dot_segments(&normalized), normalized);
}
#[test]
fn proptest_normalize_dot_segments_pathish_invariants(path_input in pathish_input()) {
let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
let resolved = resolve_path(&path_input, &cwd);
let normalized_once = normalize_dot_segments(&resolved);
let normalized_twice = normalize_dot_segments(&normalized_once);
prop_assert_eq!(&normalized_once, &normalized_twice);
prop_assert!(
normalized_once
.components()
.all(|component| !matches!(component, std::path::Component::CurDir))
);
if std::path::Path::new(&path_input).is_absolute() {
prop_assert!(resolved.is_absolute());
prop_assert!(normalized_once.is_absolute());
}
}
}
fn fuzzy_content_strategy() -> impl Strategy<Value = String> {
prop::collection::vec(
prop_oneof![
8 => any::<char>().prop_filter("no nul", |c| *c != '\0'),
1 => Just('\u{00A0}'),
1 => Just('\u{2019}'),
1 => Just('\u{201C}'),
1 => Just('\u{2014}'),
],
1..512,
)
.prop_map(|chars| chars.into_iter().collect())
}
fn needle_from_content(content: String) -> impl Strategy<Value = (String, String)> {
let len = content.len();
if len == 0 {
return Just((content, String::new())).boxed();
}
(0..len)
.prop_flat_map(move |start| {
let c = content.clone();
let remaining = c.len() - start;
let max_needle = remaining.min(256);
(Just(c), start..=start + max_needle.saturating_sub(1))
})
.prop_filter_map("valid char boundary", |(c, end)| {
let start_candidates: Vec<usize> =
(0..c.len()).filter(|i| c.is_char_boundary(*i)).collect();
if start_candidates.is_empty() {
return None;
}
let start = *start_candidates
.iter()
.min_by_key(|&&i| i.abs_diff(end.saturating_sub(end / 2)))
.unwrap_or(&0);
let end_clamped = end.min(c.len());
let actual_end = (end_clamped..=c.len())
.find(|i| c.is_char_boundary(*i))
.unwrap_or(c.len());
if start >= actual_end {
return Some((c, String::new()));
}
Some((c.clone(), c[start..actual_end].to_string()))
})
.boxed()
}
proptest! {
#![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
#[test]
fn proptest_fuzzy_find_text_exact_match_invariants(
(content, needle) in fuzzy_content_strategy().prop_flat_map(needle_from_content)
) {
let result = fuzzy_find_text(&content, &needle);
if needle.is_empty() {
prop_assert!(result.found, "empty needle should always match");
prop_assert_eq!(result.index, 0);
prop_assert_eq!(result.match_length, 0);
} else {
prop_assert!(
result.found,
"exact substring must be found: content len={}, needle len={}",
content.len(),
needle.len()
);
prop_assert!(content.is_char_boundary(result.index));
prop_assert!(content.is_char_boundary(result.index + result.match_length));
let matched = &content[result.index..result.index + result.match_length];
prop_assert_eq!(matched, needle.as_str());
}
}
#[test]
fn proptest_fuzzy_find_text_normalized_match_invariants(
content in arbitrary_match_text()
) {
let normalized = build_normalized_content(&content);
if normalized.is_empty() {
return Ok(());
}
let needle_end = normalized
.char_indices()
.nth(128.min(normalized.chars().count().saturating_sub(1)))
.map_or(normalized.len(), |(i, _)| i);
let needle_end = (needle_end..=normalized.len())
.find(|i| normalized.is_char_boundary(*i))
.unwrap_or(normalized.len());
let needle = &normalized[..needle_end];
if needle.is_empty() {
return Ok(());
}
let result = fuzzy_find_text(&content, needle);
prop_assert!(
result.found,
"normalized needle should be found via fuzzy match: needle={:?}",
needle
);
prop_assert!(content.is_char_boundary(result.index));
prop_assert!(content.is_char_boundary(result.index + result.match_length));
}
#[test]
fn proptest_build_normalized_content_invariants(input in arbitrary_match_text()) {
let normalized = build_normalized_content(&input);
let renormalized = build_normalized_content(&normalized);
prop_assert_eq!(
&renormalized,
&normalized,
"build_normalized_content should be idempotent"
);
prop_assert!(
normalized.len() <= input.len(),
"normalized should not be larger: {} vs {}",
normalized.len(),
input.len()
);
let input_lines = input.split('\n').count();
let norm_lines = normalized.split('\n').count();
prop_assert_eq!(
norm_lines, input_lines,
"line count must be preserved by normalization"
);
prop_assert!(
normalized.chars().all(|c| {
!is_special_unicode_space(c)
&& !matches!(
c,
'\u{2018}'
| '\u{2019}'
| '\u{201C}'
| '\u{201D}'
| '\u{201E}'
| '\u{201F}'
| '\u{2010}'
| '\u{2011}'
| '\u{2012}'
| '\u{2013}'
| '\u{2014}'
| '\u{2015}'
| '\u{2212}'
)
}),
"normalized content should not contain target Unicode chars"
);
}
#[test]
fn proptest_map_normalized_range_roundtrip(input in arbitrary_match_text()) {
let normalized = build_normalized_content(&input);
if normalized.is_empty() {
return Ok(());
}
let norm_chars: Vec<(usize, char)> = normalized.char_indices().collect();
let norm_len = norm_chars.len();
if norm_len == 0 {
return Ok(());
}
let end_char = (norm_len / 4).max(1).min(norm_len);
let norm_start = norm_chars[0].0;
let norm_end = if end_char < norm_chars.len() {
norm_chars[end_char].0
} else {
normalized.len()
};
let norm_match_len = norm_end - norm_start;
let (orig_start, orig_len) =
map_normalized_range_to_original(&input, norm_start, norm_match_len);
prop_assert!(
orig_start + orig_len <= input.len(),
"mapped range {orig_start}..{} exceeds input len {}",
orig_start + orig_len,
input.len()
);
prop_assert!(
input.is_char_boundary(orig_start),
"orig_start {} is not a char boundary",
orig_start
);
prop_assert!(
input.is_char_boundary(orig_start + orig_len),
"orig_end {} is not a char boundary",
orig_start + orig_len
);
prop_assert!(
orig_len >= norm_match_len
|| orig_len == 0
|| norm_match_len == 0,
"original range ({orig_len}) should be >= normalized range ({norm_match_len})"
);
let expected_norm = &normalized[norm_start..norm_end];
if !expected_norm.is_empty() {
let fuzzy_result = fuzzy_find_text(&input, expected_norm);
prop_assert!(
fuzzy_result.found,
"normalized needle should be findable in original content"
);
}
}
}
#[test]
fn test_truncate_head_preserves_newline() {
let content = "Line1\nLine2".to_string();
let result = truncate_head(content, 1, 1000);
assert_eq!(result.content, "Line1\n");
let content = "Line1".to_string();
let result = truncate_head(content, 1, 1000);
assert_eq!(result.content, "Line1");
let content = "Line1\n".to_string();
let result = truncate_head(content, 1, 1000);
assert_eq!(result.content, "Line1\n");
}
#[test]
fn test_edit_crlf_content_correctness() {
asupersync::test_utils::run_test(|| async {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("crlf.txt");
let content = "line1\r\nline2\r\nline3";
std::fs::write(&path, content).unwrap();
let tool = EditTool::new(tmp.path());
let out = tool
.execute(
"t",
serde_json::json!({
"path": path.to_string_lossy(),
"oldText": "line2",
"newText": "changed"
}),
None,
)
.await
.unwrap();
assert!(!out.is_error);
let new_content = std::fs::read_to_string(&path).unwrap();
assert_eq!(new_content, "line1\r\nchanged\r\nline3");
});
}
}