#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
use serde_json::{json, Value};
use crate::tools::ToolCtx;
pub struct LsTool {
ctx: Arc<ToolCtx>,
}
impl LsTool {
pub fn new(ctx: Arc<ToolCtx>) -> Self {
Self { ctx }
}
}
impl Tool for LsTool {
fn def(&self) -> ToolDef {
ToolDef {
name: "ls".to_string(),
description: "List the immediate entries of a directory (non-recursive).".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Directory path (absolute or cwd-relative). Defaults to cwd." }
}
}),
}
}
fn call(
&self,
args: Value,
_ctx: &ToolContext,
) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
let ctx = Arc::clone(&self.ctx);
Box::pin(async move {
let rel = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let abs = resolve_in_cwd(&ctx.cwd, rel);
if !abs.starts_with(&ctx.cwd) {
return ToolResult::error(format!(
"path {} is outside the working directory",
abs.display()
));
}
let mut entries = match tokio::fs::read_dir(&abs).await {
Ok(rd) => rd,
Err(e) => {
return ToolResult::error(format!("failed to list {}: {e}", abs.display()))
}
};
let mut lines: Vec<String> = Vec::new();
loop {
match entries.next_entry().await {
Ok(Some(entry)) => {
let name = entry.file_name().to_string_lossy().to_string();
let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
lines.push(if is_dir { format!("{name}/") } else { name });
}
Ok(None) => break,
Err(e) => return ToolResult::error(format!("read_dir error: {e}")),
}
}
lines.sort();
ToolResult::text(lines.join("\n"))
})
}
}
pub(crate) fn resolve_in_cwd(cwd: &std::path::Path, rel: &str) -> PathBuf {
let joined = if std::path::Path::new(rel).is_absolute() {
PathBuf::from(rel)
} else {
cwd.join(rel)
};
let mut out = PathBuf::new();
for comp in joined.components() {
use std::path::Component::*;
match comp {
ParentDir => {
out.pop();
}
CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permissions::NoOpPermissionGate;
use tempfile::tempdir;
use tokio::sync::mpsc;
fn test_ctx(cwd: &std::path::Path) -> Arc<ToolCtx> {
let (tx, _rx) = mpsc::channel(8);
Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
}
#[tokio::test]
async fn lists_directory_entries_sorted_with_dir_marker() {
let dir = tempdir().expect("tempdir");
tokio::fs::write(dir.path().join("b.txt"), "x")
.await
.unwrap();
tokio::fs::write(dir.path().join("a.txt"), "x")
.await
.unwrap();
tokio::fs::create_dir(dir.path().join("sub")).await.unwrap();
let tool = LsTool::new(test_ctx(dir.path()));
let result = tool.call(json!({}), &ToolContext::default()).await;
let text = result.as_text().unwrap_or_default();
assert_eq!(text, "a.txt\nb.txt\nsub/");
}
#[tokio::test]
async fn rejects_path_outside_cwd() {
let dir = tempdir().expect("tempdir");
let tool = LsTool::new(test_ctx(dir.path()));
let result = tool
.call(json!({ "path": "../.." }), &ToolContext::default())
.await;
assert!(result.is_error, "expected escape rejection");
}
#[tokio::test]
async fn errors_on_missing_directory() {
let dir = tempdir().expect("tempdir");
let tool = LsTool::new(test_ctx(dir.path()));
let result = tool
.call(json!({ "path": "nope" }), &ToolContext::default())
.await;
assert!(result.is_error);
}
}