use futures_util::future::BoxFuture;
use serde_json::{Map, Value, json};
use tokio_util::sync::CancellationToken;
use yapi_agent::{Tool, UpdateSink};
use yapi_types::event::ToolResult;
use yapi_types::message::ToolDeclaration;
use super::path::resolve_to_cwd;
use super::truncate::DEFAULT_MAX_BYTES;
use super::{
ToolEnv, capped_output, check_abort, js_number, node_error, plain_declaration, text_result,
};
use yapi_types::collate::locale_compare;
const DEFAULT_LIMIT: f64 = 500.0;
pub struct Ls {
env: ToolEnv,
declaration: ToolDeclaration,
}
impl Ls {
pub fn new(env: ToolEnv) -> Ls {
Ls {
env,
declaration: plain_declaration(
"ls",
format!(
"List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to {} entries or {}KB (whichever is hit first).",
DEFAULT_LIMIT,
DEFAULT_MAX_BYTES / 1024
),
json!({"type":"object","properties":{
"path":{"type":"string","description":"Directory to list (default: current directory)"},
"limit":{"type":"number","description":"Maximum number of entries to return (default: 500)"}}}),
),
}
}
}
impl Tool for Ls {
fn declaration(&self) -> &ToolDeclaration {
&self.declaration
}
fn execute(
&self,
_call_id: String,
args: Value,
cancel: CancellationToken,
_updates: UpdateSink,
) -> BoxFuture<'_, Result<ToolResult, String>> {
Box::pin(async move {
check_abort(&cancel)?;
self.list(args)
})
}
}
impl Ls {
fn list(&self, args: Value) -> Result<ToolResult, String> {
let dir = args["path"]
.as_str()
.filter(|p| !p.is_empty())
.unwrap_or(".");
let path = resolve_to_cwd(dir, &self.env.cwd);
let limit = args["limit"].as_f64().unwrap_or(DEFAULT_LIMIT);
if !path.exists() {
return Err(format!("Path not found: {}", path.display()));
}
let metadata = std::fs::metadata(&path).map_err(|err| node_error(&err, "stat", &path))?;
if !metadata.is_dir() {
return Err(format!("Not a directory: {}", path.display()));
}
let mut entries: Vec<String> = std::fs::read_dir(&path)
.map_err(|err| {
format!(
"Cannot read directory: {}",
node_error(&err, "scandir", &path)
)
})?
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
entries.sort_by(|a, b| locale_compare(&a.to_lowercase(), &b.to_lowercase()));
let mut results = Vec::new();
let mut limit_reached = false;
for entry in entries {
if results.len() as f64 >= limit {
limit_reached = true;
break;
}
let Ok(metadata) = std::fs::metadata(path.join(&entry)) else {
continue;
};
results.push(if metadata.is_dir() {
format!("{entry}/")
} else {
entry
});
}
if results.is_empty() {
return Ok(text_result("(empty directory)", None));
}
let mut details = Map::new();
let mut notices = Vec::new();
if limit_reached {
notices.push(format!(
"{} entries limit reached. Use limit={} for more",
js_number(limit),
js_number(limit * 2.0)
));
details.insert("entryLimitReached".into(), json!(limit));
}
Ok(capped_output(
&results.join("\n"),
notices,
Vec::new(),
details,
))
}
}