use thiserror::Error;
#[derive(Debug, Error)]
pub enum ToolError {
#[error("Tool not found: {0}")]
NotFound(String),
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Execution error: {0}")]
Execution(String),
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error("Tool requires approval: {0}")]
ApprovalRequired(String),
#[error("File not found: {0}")]
FileNotFound(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Tool execution timed out after {0}s")]
Timeout(u64),
#[error("Internal error: {0}")]
Internal(String),
}
impl ToolError {
pub fn is_pre_execution_miss(&self) -> bool {
matches!(self, ToolError::NotFound(_) | ToolError::InvalidInput(_))
}
}
pub type Result<T> = std::result::Result<T, ToolError>;
pub fn expand_tilde(path: &str) -> std::path::PathBuf {
use std::path::PathBuf;
if let Some(rest) = path.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest);
}
if path == "~"
&& let Some(home) = dirs::home_dir()
{
return home;
}
PathBuf::from(path)
}
pub fn collapse_home(path: &std::path::Path) -> String {
if let Some(home) = dirs::home_dir()
&& let Ok(rest) = path.strip_prefix(&home)
{
if rest.as_os_str().is_empty() {
return "~".to_string();
}
let suffix = rest.display().to_string();
if let Some(stripped) = suffix.strip_prefix('/') {
return format!("~/{}", stripped);
}
return format!("~/{}", suffix);
}
path.display().to_string()
}
pub fn resolve_tool_path(
requested_path: &str,
working_directory: &std::path::Path,
) -> std::path::PathBuf {
let expanded = expand_tilde(requested_path);
if expanded.is_absolute() {
expanded
} else {
working_directory.join(expanded)
}
}
pub fn validate_path_safety(
requested_path: &str,
working_directory: &std::path::Path,
) -> Result<std::path::PathBuf> {
let path = resolve_tool_path(requested_path, working_directory);
if !path.exists() {
let parent = path
.parent()
.ok_or_else(|| ToolError::InvalidInput("Invalid path: no parent directory".into()))?;
if !parent.exists() {
return Err(ToolError::InvalidInput(format!(
"Parent directory does not exist: {}",
parent.display()
)));
}
}
Ok(path)
}
fn describe_file_type(md: &std::fs::Metadata) -> &'static str {
let ft = md.file_type();
if ft.is_dir() {
return "directory";
}
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
if ft.is_char_device() {
return "character device";
}
if ft.is_block_device() {
return "block device";
}
if ft.is_fifo() {
return "FIFO (named pipe)";
}
if ft.is_socket() {
return "socket";
}
}
"special file"
}
pub fn strip_path_wrappers(raw: &str) -> &str {
let t = raw.trim();
for (open, close) in [("**", "**"), ("`", "`"), ("\"", "\""), ("'", "'")] {
if t.len() >= open.len() + close.len()
&& let Some(inner) = t.strip_prefix(open).and_then(|s| s.strip_suffix(close))
&& !inner.is_empty()
{
return inner.trim();
}
}
t
}
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.to_lowercase().chars().collect();
let b: Vec<char> = b.to_lowercase().chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for i in 1..=a.len() {
cur[0] = i;
for j in 1..=b.len() {
let cost = usize::from(a[i - 1] != b[j - 1]);
cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
fn missing_file_hint(path: &std::path::Path) -> String {
let base = format!("File not found: {}", path.display());
let Some(parent) = path.parent() else {
return base;
};
let Ok(entries) = std::fs::read_dir(parent) else {
return base;
};
let target = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if target.is_empty() {
return base;
}
let mut candidates: Vec<String> = entries
.filter_map(std::result::Result::ok)
.take(50)
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
candidates.sort_by_key(|name| edit_distance(name, &target));
let suggestions: Vec<String> = candidates
.iter()
.filter(|name| edit_distance(name, &target) * 8 <= name.len().max(target.len()) * 5)
.take(3)
.cloned()
.collect();
if suggestions.is_empty() {
return base;
}
format!(
"{} — did you mean: {}?",
base,
suggestions
.iter()
.map(|s| format!("'{}'", s))
.collect::<Vec<_>>()
.join(", ")
)
}
pub fn validate_file_path(
requested_path: &str,
working_directory: &std::path::Path,
) -> std::result::Result<std::path::PathBuf, String> {
let stripped = strip_path_wrappers(requested_path);
if stripped != requested_path.trim()
&& let Ok(p) = validate_path_safety(stripped, working_directory)
&& p.is_file()
{
return Ok(p);
}
let path = match validate_path_safety(requested_path, working_directory) {
Ok(p) => p,
Err(ToolError::InvalidInput(msg)) => {
let resolved = resolve_tool_path(stripped, working_directory);
if !resolved.exists() && resolved.parent().is_some_and(|p| !p.exists()) {
return Err(missing_file_hint(&resolved));
}
return Err(format!("Invalid path: {}", msg));
}
Err(e) => {
return Err(format!("Path validation failed: {}", e));
}
};
if !path.exists() {
return Err(missing_file_hint(&path));
}
if !path.is_file() {
let kind = std::fs::metadata(&path)
.map(|md| describe_file_type(&md))
.unwrap_or("special file");
return Err(format!(
"Path is not a regular file: {} ({}) — file tools read regular \
files only; devices, pipes and directories are not readable",
path.display(),
kind
));
}
Ok(path)
}
pub fn validate_directory_path(
requested_path: &str,
working_directory: &std::path::Path,
) -> std::result::Result<std::path::PathBuf, String> {
let path = match validate_path_safety(requested_path, working_directory) {
Ok(p) => p,
Err(ToolError::InvalidInput(msg)) => {
return Err(format!("Invalid path: {}", msg));
}
Err(e) => {
return Err(format!("Path validation failed: {}", e));
}
};
if !path.exists() {
return Err(format!("Directory not found: {}", path.display()));
}
if !path.is_dir() {
return Err(format!("Path is not a directory: {}", path.display()));
}
Ok(path)
}