1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3use std::future::Future;
4use std::path::PathBuf;
5use std::pin::Pin;
6use std::sync::Arc;
7
8use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
9use serde_json::{json, Value};
10
11use crate::tools::ToolCtx;
12
13pub struct LsTool {
14 ctx: Arc<ToolCtx>,
15}
16
17impl LsTool {
18 pub fn new(ctx: Arc<ToolCtx>) -> Self {
19 Self { ctx }
20 }
21}
22
23impl Tool for LsTool {
24 fn def(&self) -> ToolDef {
25 ToolDef {
26 name: "ls".to_string(),
27 description: "List the immediate entries of a directory (non-recursive).".to_string(),
28 input_schema: json!({
29 "type": "object",
30 "properties": {
31 "path": { "type": "string", "description": "Directory path (absolute or cwd-relative). Defaults to cwd." }
32 }
33 }),
34 }
35 }
36
37 fn call(
38 &self,
39 args: Value,
40 _ctx: &ToolContext,
41 ) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
42 let ctx = Arc::clone(&self.ctx);
43 Box::pin(async move {
44 let rel = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
45 let abs = resolve_in_cwd(&ctx.cwd, rel);
46 if !abs.starts_with(&ctx.cwd) {
47 return ToolResult::error(format!(
48 "path {} is outside the working directory",
49 abs.display()
50 ));
51 }
52 let mut entries = match tokio::fs::read_dir(&abs).await {
53 Ok(rd) => rd,
54 Err(e) => {
55 return ToolResult::error(format!("failed to list {}: {e}", abs.display()))
56 }
57 };
58 let mut lines: Vec<String> = Vec::new();
59 loop {
60 match entries.next_entry().await {
61 Ok(Some(entry)) => {
62 let name = entry.file_name().to_string_lossy().to_string();
63 let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
64 lines.push(if is_dir { format!("{name}/") } else { name });
65 }
66 Ok(None) => break,
67 Err(e) => return ToolResult::error(format!("read_dir error: {e}")),
68 }
69 }
70 lines.sort();
71 ToolResult::text(lines.join("\n"))
72 })
73 }
74}
75
76pub(crate) fn resolve_in_cwd(cwd: &std::path::Path, rel: &str) -> PathBuf {
79 let joined = if std::path::Path::new(rel).is_absolute() {
80 PathBuf::from(rel)
81 } else {
82 cwd.join(rel)
83 };
84 let mut out = PathBuf::new();
85 for comp in joined.components() {
86 use std::path::Component::*;
87 match comp {
88 ParentDir => {
89 out.pop();
90 }
91 CurDir => {}
92 other => out.push(other.as_os_str()),
93 }
94 }
95 out
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101 use crate::permissions::NoOpPermissionGate;
102 use tempfile::tempdir;
103 use tokio::sync::mpsc;
104
105 fn test_ctx(cwd: &std::path::Path) -> Arc<ToolCtx> {
106 let (tx, _rx) = mpsc::channel(8);
107 Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
108 }
109
110 #[tokio::test]
111 async fn lists_directory_entries_sorted_with_dir_marker() {
112 let dir = tempdir().expect("tempdir");
113 tokio::fs::write(dir.path().join("b.txt"), "x")
114 .await
115 .unwrap();
116 tokio::fs::write(dir.path().join("a.txt"), "x")
117 .await
118 .unwrap();
119 tokio::fs::create_dir(dir.path().join("sub")).await.unwrap();
120
121 let tool = LsTool::new(test_ctx(dir.path()));
122 let result = tool.call(json!({}), &ToolContext::default()).await;
123 let text = result.as_text().unwrap_or_default();
124 assert_eq!(text, "a.txt\nb.txt\nsub/");
125 }
126
127 #[tokio::test]
128 async fn rejects_path_outside_cwd() {
129 let dir = tempdir().expect("tempdir");
130 let tool = LsTool::new(test_ctx(dir.path()));
131 let result = tool
132 .call(json!({ "path": "../.." }), &ToolContext::default())
133 .await;
134 assert!(result.is_error, "expected escape rejection");
135 }
136
137 #[tokio::test]
138 async fn errors_on_missing_directory() {
139 let dir = tempdir().expect("tempdir");
140 let tool = LsTool::new(test_ctx(dir.path()));
141 let result = tool
142 .call(json!({ "path": "nope" }), &ToolContext::default())
143 .await;
144 assert!(result.is_error);
145 }
146}