use std::path::Path;
use ignore::WalkBuilder;
use rig::completion::ToolDefinition;
use rig::tool::Tool;
use crate::agent::agent_loop::tool_input_repair::with_contract_hint;
use crate::agent::tools::cache::ToolCache;
use crate::agent::tools::{
AskSender, ListDirArgs, PermCheck, ToolError, check_perm_path, is_skip_dir,
};
fn format_size(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
let mut size = bytes as f64;
let mut unit_idx = 0;
while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
size /= 1024.0;
unit_idx += 1;
}
if unit_idx == 0 {
format!("{} {}", bytes, UNITS[unit_idx])
} else {
format!("{:.1} {}", size, UNITS[unit_idx])
}
}
fn count_dir_entries(path: &Path) -> u64 {
std::fs::read_dir(path)
.map(|rd| rd.count() as u64)
.unwrap_or(0)
}
pub struct ListDirTool {
pub permission: Option<PermCheck>,
pub ask_tx: Option<AskSender>,
pub cache: Option<ToolCache>,
}
impl ListDirTool {
#[allow(dead_code)]
pub fn new(permission: Option<PermCheck>, ask_tx: Option<AskSender>) -> Self {
ListDirTool {
permission,
ask_tx,
cache: None,
}
}
pub fn with_cache(
permission: Option<PermCheck>,
ask_tx: Option<AskSender>,
cache: ToolCache,
) -> Self {
ListDirTool {
permission,
ask_tx,
cache: Some(cache),
}
}
}
impl Tool for ListDirTool {
const NAME: &'static str = "list_dir";
type Error = ToolError;
type Args = ListDirArgs;
type Output = String;
async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition {
name: "list_dir".to_string(),
description: with_contract_hint(
"list_dir",
"List the immediate files and directories in ONE directory (non-recursive). Shows type, size, and subdirectory entry counts; sorted directories-first then alphabetically. Respects .gitignore. Use `repo_overview` for a whole-project tree, `glob`/`find_files` to locate files by pattern, and `grep` to search contents.",
),
parameters: serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory path (defaults to current working directory)"
},
"include_hidden": {
"type": "boolean",
"description": "Include dotfiles (.env, .gitignore, etc.) in the listing. Default false to avoid surfacing secrets and config files."
}
},
"required": []
}),
}
}
async fn call(&self, args: ListDirArgs) -> Result<String, ToolError> {
let path = args.path.as_deref().unwrap_or(".");
check_perm_path(&self.permission, &self.ask_tx, "list_dir", path).await?;
let stamp = crate::agent::tools::cache::fs_stamp_or_cwd(path);
let cache_key = format!("list_dir:{}:hidden={}:{}", path, args.include_hidden, stamp,);
if let Some(ref cache) = self.cache
&& let Some(cached) = cache.get(&cache_key)
{
return Ok(cached);
}
let walker = WalkBuilder::new(path)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.require_git(false)
.hidden(!args.include_hidden)
.max_depth(Some(1))
.filter_entry(|entry| {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
!is_skip_dir(entry.file_name().to_str().unwrap_or(""))
} else {
true
}
})
.build();
const MAX_LIST_DIR_RESULTS: usize = 200;
const COUNT_CEILING_MULTIPLIER: usize = 10;
let count_ceiling = MAX_LIST_DIR_RESULTS.saturating_mul(COUNT_CEILING_MULTIPLIER);
let mut entries: Vec<(String, String, String)> = Vec::new();
let mut total_entries: usize = 0;
let mut truncated_count = false;
for result in walker {
let entry = match result {
Ok(e) => e,
Err(_) => continue,
};
let name = entry.file_name().to_string_lossy().to_string();
if entry.depth() == 0 {
continue;
}
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
total_entries += 1;
if entries.len() < MAX_LIST_DIR_RESULTS {
let kind = if meta.is_dir() {
let count = count_dir_entries(entry.path());
format!("dir({})", count)
} else if meta.is_symlink() {
"link".to_string()
} else {
"file".to_string()
};
let size = if meta.is_file() {
format_size(meta.len())
} else {
String::new()
};
entries.push((name, kind, size));
}
if total_entries >= count_ceiling {
truncated_count = true;
break;
}
}
entries.sort_by(|a, b| {
let a_is_dir = a.1.starts_with("dir") || a.1 == "link";
let b_is_dir = b.1.starts_with("dir") || b.1 == "link";
if a_is_dir != b_is_dir {
b_is_dir.cmp(&a_is_dir)
} else {
a.0.cmp(&b.0)
}
});
if entries.is_empty() {
return Ok("(empty directory)".to_string());
}
let dir_count = entries
.iter()
.filter(|(_, k, _)| k.starts_with("dir"))
.count();
let link_count = entries.iter().filter(|(_, k, _)| k == "link").count();
let file_count = entries.len() - dir_count - link_count;
let max_name = entries.iter().map(|e| e.0.len()).max().unwrap_or(0);
let mut summary = format!(
"{} entries ({} dirs, {} files",
entries.len(),
dir_count,
file_count,
);
if link_count > 0 {
summary.push_str(&format!(", {link_count} symlinks"));
}
if total_entries > entries.len() {
let total_label = if truncated_count {
format!("{}+", total_entries)
} else {
total_entries.to_string()
};
let hidden = total_entries.saturating_sub(entries.len());
let hidden_label = if truncated_count {
format!("{}+", hidden)
} else {
hidden.to_string()
};
summary.push_str(&format!(
"; showing {} of {}; {} more not shown",
entries.len(),
total_label,
hidden_label,
));
}
summary.push_str("):\n");
let mut result = summary;
for (name, kind, size) in &entries {
let padded = format!("{:width$}", name, width = max_name);
let size_str = if size.is_empty() {
String::new()
} else {
format!(" {}", size)
};
result.push_str(&format!(" [{}] {}{}\n", kind, padded, size_str));
}
if let Some(ref cache) = self.cache {
cache.set(&cache_key, result.clone());
}
Ok(result)
}
}