macOS uses U+202F (Narrow No-Break Space) in screenshot filenames between the time and am/pm. When users type or paste these paths, they use regular spaces, causing file-not-found errors. Changes: - Add resolve_path_with_unicode_fallback() to try U+202F variants - Add resolve_paths_in_shell_command() for shell command paths - Apply fix to read_file, read_image, and shell tools - Fix read_image prompt docs: file_path -> file_paths (array) - Add 6 unit tests for Unicode space normalization
516 lines
18 KiB
Rust
516 lines
18 KiB
Rust
//! File operation tools: read_file, write_file, str_replace, read_image.
|
|
|
|
use anyhow::Result;
|
|
use tracing::debug;
|
|
|
|
use crate::ui_writer::UiWriter;
|
|
use crate::utils::resolve_path_with_unicode_fallback;
|
|
use crate::utils::apply_unified_diff_to_string;
|
|
use crate::ToolCall;
|
|
|
|
use super::executor::ToolContext;
|
|
|
|
/// Execute the `read_file` tool.
|
|
pub async fn execute_read_file<W: UiWriter>(
|
|
tool_call: &ToolCall,
|
|
ctx: &ToolContext<'_, W>,
|
|
) -> Result<String> {
|
|
debug!("Processing read_file tool call");
|
|
|
|
let file_path = match tool_call.args.get("file_path").and_then(|v| v.as_str()) {
|
|
Some(p) => p,
|
|
None => return Ok("❌ Missing file_path argument".to_string()),
|
|
};
|
|
|
|
// Expand tilde (~) to home directory
|
|
let expanded_path = shellexpand::tilde(file_path);
|
|
// Try to resolve with Unicode space fallback (macOS uses U+202F in screenshot names)
|
|
let resolved_path = resolve_path_with_unicode_fallback(expanded_path.as_ref());
|
|
let path_str = resolved_path.as_ref();
|
|
|
|
// Check if this is an image file
|
|
let is_image = path_str.to_lowercase().ends_with(".png")
|
|
|| path_str.to_lowercase().ends_with(".jpg")
|
|
|| path_str.to_lowercase().ends_with(".jpeg")
|
|
|| path_str.to_lowercase().ends_with(".gif")
|
|
|| path_str.to_lowercase().ends_with(".bmp")
|
|
|| path_str.to_lowercase().ends_with(".tiff")
|
|
|| path_str.to_lowercase().ends_with(".tif")
|
|
|| path_str.to_lowercase().ends_with(".webp");
|
|
|
|
// If it's an image file, use OCR via extract_text
|
|
if is_image {
|
|
if let Some(controller) = ctx.computer_controller {
|
|
match controller.extract_text_from_image(path_str).await {
|
|
Ok(text) => {
|
|
return Ok(format!("📄 Image file (OCR extracted):\n{}", text));
|
|
}
|
|
Err(e) => {
|
|
return Ok(format!(
|
|
"❌ Failed to extract text from image '{}': {}",
|
|
path_str, e
|
|
));
|
|
}
|
|
}
|
|
} else {
|
|
return Ok("❌ Computer control not enabled. Cannot perform OCR on image files. Set computer_control.enabled = true in config.".to_string());
|
|
}
|
|
}
|
|
|
|
// Extract optional start and end positions
|
|
let start_char = tool_call
|
|
.args
|
|
.get("start")
|
|
.and_then(|v| v.as_u64())
|
|
.map(|n| n as usize);
|
|
let end_char = tool_call
|
|
.args
|
|
.get("end")
|
|
.and_then(|v| v.as_u64())
|
|
.map(|n| n as usize);
|
|
|
|
debug!(
|
|
"Reading file: {}, start={:?}, end={:?}",
|
|
path_str, start_char, end_char
|
|
);
|
|
|
|
match std::fs::read_to_string(path_str) {
|
|
Ok(content) => {
|
|
// Validate and apply range if specified
|
|
let start = start_char.unwrap_or(0);
|
|
let end = end_char.unwrap_or(content.len());
|
|
|
|
// Validation
|
|
if start > content.len() {
|
|
return Ok(format!(
|
|
"❌ Start position {} exceeds file length {}",
|
|
start,
|
|
content.len()
|
|
));
|
|
}
|
|
if end > content.len() {
|
|
return Ok(format!(
|
|
"❌ End position {} exceeds file length {}",
|
|
end,
|
|
content.len()
|
|
));
|
|
}
|
|
if start > end {
|
|
return Ok(format!(
|
|
"❌ Start position {} is greater than end position {}",
|
|
start, end
|
|
));
|
|
}
|
|
|
|
// Extract the requested portion, ensuring we're at char boundaries
|
|
let start_boundary = if start == 0 {
|
|
0
|
|
} else {
|
|
content
|
|
.char_indices()
|
|
.find(|(i, _)| *i >= start)
|
|
.map(|(i, _)| i)
|
|
.unwrap_or(start)
|
|
};
|
|
let end_boundary = content
|
|
.char_indices()
|
|
.find(|(i, _)| *i >= end)
|
|
.map(|(i, _)| i)
|
|
.unwrap_or(content.len());
|
|
|
|
let partial_content = &content[start_boundary..end_boundary];
|
|
let line_count = partial_content.lines().count();
|
|
let total_lines = content.lines().count();
|
|
|
|
// Format output with range info if partial
|
|
if start_char.is_some() || end_char.is_some() {
|
|
Ok(format!(
|
|
"📄 File content (chars {}-{}, {} lines of {} total):\n{}",
|
|
start_boundary, end_boundary, line_count, total_lines, partial_content
|
|
))
|
|
} else {
|
|
Ok(format!("📄 File content ({} lines):\n{}", line_count, content))
|
|
}
|
|
}
|
|
Err(e) => Ok(format!("❌ Failed to read file '{}': {}", path_str, e)),
|
|
}
|
|
}
|
|
|
|
/// Execute the `read_image` tool.
|
|
pub async fn execute_read_image<W: UiWriter>(
|
|
tool_call: &ToolCall,
|
|
ctx: &mut ToolContext<'_, W>,
|
|
) -> Result<String> {
|
|
debug!("Processing read_image tool call");
|
|
|
|
// Get paths from file_paths array
|
|
let mut paths: Vec<String> = Vec::new();
|
|
|
|
if let Some(file_paths) = tool_call.args.get("file_paths") {
|
|
if let Some(arr) = file_paths.as_array() {
|
|
for p in arr {
|
|
if let Some(s) = p.as_str() {
|
|
paths.push(s.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if paths.is_empty() {
|
|
return Ok("❌ Missing or empty file_paths argument".to_string());
|
|
}
|
|
|
|
let mut results: Vec<String> = Vec::new();
|
|
let mut success_count = 0;
|
|
|
|
// Print └─ and newline before images to break out of tool output box
|
|
println!("└─\n");
|
|
|
|
for path_str in &paths {
|
|
// Expand tilde (~) to home directory
|
|
let expanded_path = shellexpand::tilde(path_str);
|
|
// Try to resolve with Unicode space fallback (macOS uses U+202F in screenshot names)
|
|
let resolved_path = resolve_path_with_unicode_fallback(expanded_path.as_ref());
|
|
let path = std::path::Path::new(resolved_path.as_ref());
|
|
|
|
// Check file exists
|
|
if !path.exists() {
|
|
results.push(format!("❌ Image file not found: {}", path_str));
|
|
continue;
|
|
}
|
|
|
|
// Read the file first, then detect format from magic bytes
|
|
match std::fs::read(path) {
|
|
Ok(bytes) => {
|
|
// Detect media type from magic bytes (file signature)
|
|
let media_type = match g3_providers::ImageContent::media_type_from_bytes(&bytes) {
|
|
Some(mt) => mt,
|
|
None => {
|
|
// Fall back to extension-based detection
|
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
|
match g3_providers::ImageContent::media_type_from_extension(ext) {
|
|
Some(mt) => mt,
|
|
None => {
|
|
results.push(format!(
|
|
"❌ {}: Unsupported or unrecognized image format",
|
|
path_str
|
|
));
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let file_size = bytes.len();
|
|
|
|
// Try to get image dimensions
|
|
let dimensions = get_image_dimensions(&bytes, media_type);
|
|
|
|
// Build info string
|
|
let dim_str = dimensions
|
|
.map(|(w, h)| format!("{}x{}", w, h))
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
|
|
let size_str = if file_size >= 1024 * 1024 {
|
|
format!("{:.1} MB", file_size as f64 / (1024.0 * 1024.0))
|
|
} else if file_size >= 1024 {
|
|
format!("{:.1} KB", file_size as f64 / 1024.0)
|
|
} else {
|
|
format!("{} bytes", file_size)
|
|
};
|
|
|
|
// Output imgcat inline image to terminal (height constrained)
|
|
print_imgcat(&bytes, path_str, &dim_str, media_type, &size_str, 5);
|
|
|
|
// Store the image to be attached to the next user message
|
|
use base64::Engine;
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
|
let image = g3_providers::ImageContent::new(media_type, encoded);
|
|
ctx.pending_images.push(image);
|
|
|
|
success_count += 1;
|
|
}
|
|
Err(e) => {
|
|
results.push(format!("❌ Failed to read '{}': {}", path_str, e));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Print ┌─ to resume tool output box
|
|
print!("┌─\n");
|
|
|
|
let summary = if success_count == paths.len() {
|
|
format!("{} image(s) read.", success_count)
|
|
} else {
|
|
format!("{}/{} image(s) read.", success_count, paths.len())
|
|
};
|
|
|
|
// Only include error results if there are any
|
|
if results.is_empty() {
|
|
Ok(summary)
|
|
} else {
|
|
Ok(format!("{}\n{}", results.join("\n"), summary))
|
|
}
|
|
}
|
|
|
|
/// Execute the `write_file` tool.
|
|
pub async fn execute_write_file<W: UiWriter>(
|
|
tool_call: &ToolCall,
|
|
_ctx: &ToolContext<'_, W>,
|
|
) -> Result<String> {
|
|
debug!("Processing write_file tool call");
|
|
debug!("Raw tool_call.args: {:?}", tool_call.args);
|
|
|
|
// Try multiple argument formats that different providers might use
|
|
let (path_str, content_str) = extract_path_and_content(&tool_call.args);
|
|
|
|
debug!(
|
|
"Final extracted values: path_str={:?}, content_str_len={:?}",
|
|
path_str,
|
|
content_str.map(|c| c.len())
|
|
);
|
|
|
|
if let (Some(path), Some(content)) = (path_str, content_str) {
|
|
// Expand tilde (~) to home directory
|
|
let expanded_path = shellexpand::tilde(path);
|
|
let path = expanded_path.as_ref();
|
|
|
|
debug!("Writing to file: {}", path);
|
|
|
|
// Create parent directories if they don't exist
|
|
if let Some(parent) = std::path::Path::new(path).parent() {
|
|
if let Err(e) = std::fs::create_dir_all(parent) {
|
|
return Ok(format!(
|
|
"❌ Failed to create parent directories for '{}': {}",
|
|
path, e
|
|
));
|
|
}
|
|
}
|
|
|
|
match std::fs::write(path, content) {
|
|
Ok(()) => {
|
|
let line_count = content.lines().count();
|
|
let char_count = content.len();
|
|
Ok(format!(
|
|
"✅ Successfully wrote {} lines ({} characters)",
|
|
line_count, char_count
|
|
))
|
|
}
|
|
Err(e) => Ok(format!("❌ Failed to write to file '{}': {}", path, e)),
|
|
}
|
|
} else {
|
|
// Provide more detailed error information
|
|
let available_keys = if let Some(obj) = tool_call.args.as_object() {
|
|
obj.keys().collect::<Vec<_>>()
|
|
} else {
|
|
vec![]
|
|
};
|
|
|
|
Ok(format!(
|
|
"❌ Missing file_path or content argument. Available keys: {:?}. Expected formats: {{\"file_path\": \"...\", \"content\": \"...\"}}, {{\"path\": \"...\", \"content\": \"...\"}}, {{\"filename\": \"...\", \"text\": \"...\"}}, or {{\"file\": \"...\", \"data\": \"...\"}}",
|
|
available_keys
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Execute the `str_replace` tool.
|
|
pub async fn execute_str_replace<W: UiWriter>(
|
|
tool_call: &ToolCall,
|
|
_ctx: &ToolContext<'_, W>,
|
|
) -> Result<String> {
|
|
debug!("Processing str_replace tool call");
|
|
|
|
let args_obj = match tool_call.args.as_object() {
|
|
Some(obj) => obj,
|
|
None => return Ok("❌ Invalid arguments: expected object".to_string()),
|
|
};
|
|
|
|
let file_path = match args_obj.get("file_path").and_then(|v| v.as_str()) {
|
|
Some(path) => {
|
|
let expanded_path = shellexpand::tilde(path);
|
|
expanded_path.into_owned()
|
|
}
|
|
None => return Ok("❌ Missing or invalid file_path argument".to_string()),
|
|
};
|
|
|
|
let diff = match args_obj.get("diff").and_then(|v| v.as_str()) {
|
|
Some(d) => d,
|
|
None => return Ok("❌ Missing or invalid diff argument".to_string()),
|
|
};
|
|
|
|
// Optional start and end character positions (0-indexed, end is EXCLUSIVE)
|
|
let start_char = args_obj
|
|
.get("start")
|
|
.and_then(|v| v.as_u64())
|
|
.map(|n| n as usize);
|
|
let end_char = args_obj
|
|
.get("end")
|
|
.and_then(|v| v.as_u64())
|
|
.map(|n| n as usize);
|
|
|
|
debug!(
|
|
"str_replace: path={}, start={:?}, end={:?}",
|
|
file_path, start_char, end_char
|
|
);
|
|
|
|
// Read the existing file
|
|
let file_content = match std::fs::read_to_string(&file_path) {
|
|
Ok(content) => content,
|
|
Err(e) => return Ok(format!("❌ Failed to read file '{}': {}", file_path, e)),
|
|
};
|
|
|
|
// Apply unified diff to content
|
|
let result = match apply_unified_diff_to_string(&file_content, diff, start_char, end_char) {
|
|
Ok(r) => r,
|
|
Err(e) => return Ok(format!("❌ {}", e)),
|
|
};
|
|
|
|
// Write the result back to the file
|
|
match std::fs::write(&file_path, &result) {
|
|
Ok(()) => Ok("✅ applied unified diff".to_string()),
|
|
Err(e) => Ok(format!("❌ Failed to write to file '{}': {}", file_path, e)),
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
/// Extract path and content from various argument formats.
|
|
fn extract_path_and_content(args: &serde_json::Value) -> (Option<&str>, Option<&str>) {
|
|
if let Some(args_obj) = args.as_object() {
|
|
// Format 1: Standard format with file_path and content
|
|
if let (Some(path_val), Some(content_val)) =
|
|
(args_obj.get("file_path"), args_obj.get("content"))
|
|
{
|
|
if let (Some(path), Some(content)) = (path_val.as_str(), content_val.as_str()) {
|
|
return (Some(path), Some(content));
|
|
}
|
|
}
|
|
// Format 2: Anthropic-style with path and content
|
|
if let (Some(path_val), Some(content_val)) =
|
|
(args_obj.get("path"), args_obj.get("content"))
|
|
{
|
|
if let (Some(path), Some(content)) = (path_val.as_str(), content_val.as_str()) {
|
|
return (Some(path), Some(content));
|
|
}
|
|
}
|
|
// Format 3: Alternative naming with filename and text
|
|
if let (Some(path_val), Some(content_val)) =
|
|
(args_obj.get("filename"), args_obj.get("text"))
|
|
{
|
|
if let (Some(path), Some(content)) = (path_val.as_str(), content_val.as_str()) {
|
|
return (Some(path), Some(content));
|
|
}
|
|
}
|
|
// Format 4: Alternative naming with file and data
|
|
if let (Some(path_val), Some(content_val)) = (args_obj.get("file"), args_obj.get("data")) {
|
|
if let (Some(path), Some(content)) = (path_val.as_str(), content_val.as_str()) {
|
|
return (Some(path), Some(content));
|
|
}
|
|
}
|
|
} else if let Some(args_array) = args.as_array() {
|
|
// Format 5: Args might be an array [path, content]
|
|
if args_array.len() >= 2 {
|
|
if let (Some(path), Some(content)) = (args_array[0].as_str(), args_array[1].as_str()) {
|
|
return (Some(path), Some(content));
|
|
}
|
|
}
|
|
}
|
|
(None, None)
|
|
}
|
|
|
|
/// Get image dimensions from raw bytes.
|
|
pub fn get_image_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32, u32)> {
|
|
match media_type {
|
|
"image/png" => {
|
|
// PNG: width at bytes 16-19, height at bytes 20-23 (big-endian)
|
|
if bytes.len() >= 24 {
|
|
let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
|
|
let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
|
Some((width, height))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
"image/jpeg" => {
|
|
// JPEG: Need to find SOF0/SOF2 marker (FF C0 or FF C2)
|
|
let mut i = 2; // Skip FF D8
|
|
while i + 8 < bytes.len() {
|
|
if bytes[i] == 0xFF {
|
|
let marker = bytes[i + 1];
|
|
// SOF0, SOF1, SOF2 markers contain dimensions
|
|
if marker == 0xC0 || marker == 0xC1 || marker == 0xC2 {
|
|
let height = u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]) as u32;
|
|
let width = u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32;
|
|
return Some((width, height));
|
|
}
|
|
// Skip to next marker
|
|
if marker == 0xD8
|
|
|| marker == 0xD9
|
|
|| marker == 0x01
|
|
|| (0xD0..=0xD7).contains(&marker)
|
|
{
|
|
i += 2;
|
|
} else {
|
|
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
|
|
i += 2 + len;
|
|
}
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
None
|
|
}
|
|
"image/gif" => {
|
|
// GIF: width at bytes 6-7, height at bytes 8-9 (little-endian)
|
|
if bytes.len() >= 10 {
|
|
let width = u16::from_le_bytes([bytes[6], bytes[7]]) as u32;
|
|
let height = u16::from_le_bytes([bytes[8], bytes[9]]) as u32;
|
|
Some((width, height))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
"image/webp" => {
|
|
// WebP VP8: dimensions at specific offsets (simplified)
|
|
if bytes.len() >= 30 && &bytes[12..16] == b"VP8 " {
|
|
let width = (u16::from_le_bytes([bytes[26], bytes[27]]) & 0x3FFF) as u32;
|
|
let height = (u16::from_le_bytes([bytes[28], bytes[29]]) & 0x3FFF) as u32;
|
|
Some((width, height))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Print image using iTerm2 imgcat protocol with info line.
|
|
pub fn print_imgcat(
|
|
bytes: &[u8],
|
|
name: &str,
|
|
dimensions: &str,
|
|
media_type: &str,
|
|
size: &str,
|
|
max_height: u32,
|
|
) {
|
|
use base64::Engine;
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
|
// Extract just the filename from the path
|
|
let filename = std::path::Path::new(name)
|
|
.file_name()
|
|
.and_then(|f| f.to_str())
|
|
.unwrap_or(name);
|
|
// iTerm2 inline image protocol (single space prefix)
|
|
print!(
|
|
" \x1b]1337;File=inline=1;height={};name={}:{}\x07\n",
|
|
max_height, name, encoded
|
|
);
|
|
// Print dimmed info line with filename only (no │ prefix)
|
|
println!(
|
|
" \x1b[2m{} | {} | {} | {}\x1b[0m",
|
|
filename, dimensions, media_type, size
|
|
);
|
|
// Blank line before next image (no │ prefix)
|
|
println!();
|
|
}
|