1use std::sync::Arc;
4
5use async_trait::async_trait;
6use serde_json::json;
7use tokio::sync::mpsc;
8
9use soul_core::error::SoulResult;
10use soul_core::tool::{Tool, ToolOutput};
11use soul_core::types::ToolDefinition;
12use soul_core::vfs::VirtualFs;
13
14const MAX_ENTRIES: usize = 500;
16
17use super::resolve_path;
18
19pub struct LsTool {
20 fs: Arc<dyn VirtualFs>,
21 cwd: String,
22}
23
24impl LsTool {
25 pub fn new(fs: Arc<dyn VirtualFs>, cwd: impl Into<String>) -> Self {
26 Self {
27 fs,
28 cwd: cwd.into(),
29 }
30 }
31}
32
33#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
34#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
35impl Tool for LsTool {
36 fn name(&self) -> &str {
37 "ls"
38 }
39
40 fn definition(&self) -> ToolDefinition {
41 ToolDefinition {
42 name: "ls".into(),
43 description: "List the contents of a directory. Shows files and subdirectories with '/' suffix for directories.".into(),
44 input_schema: json!({
45 "type": "object",
46 "properties": {
47 "path": {
48 "type": "string",
49 "description": "Directory path to list (defaults to working directory)"
50 },
51 "limit": {
52 "type": "integer",
53 "description": "Maximum entries to return (default: 500)"
54 }
55 }
56 }),
57 }
58 }
59
60 async fn execute(
61 &self,
62 _call_id: &str,
63 arguments: serde_json::Value,
64 _partial_tx: Option<mpsc::UnboundedSender<String>>,
65 ) -> SoulResult<ToolOutput> {
66 let path = arguments
67 .get("path")
68 .and_then(|v| v.as_str())
69 .unwrap_or("");
70
71 let resolved = if path.is_empty() {
72 self.cwd.clone()
73 } else {
74 resolve_path(&self.cwd, path)
75 };
76
77 let limit = arguments
78 .get("limit")
79 .and_then(|v| v.as_u64())
80 .map(|v| (v as usize).min(MAX_ENTRIES))
81 .unwrap_or(MAX_ENTRIES);
82
83 let exists = self.fs.exists(&resolved).await?;
85 if !exists {
86 return Ok(ToolOutput::error(format!(
87 "Directory not found: {}",
88 if path.is_empty() { &self.cwd } else { path }
89 )));
90 }
91
92 let entries = match self.fs.read_dir(&resolved).await {
93 Ok(e) => e,
94 Err(e) => {
95 return Ok(ToolOutput::error(format!(
96 "Failed to read directory {}: {}",
97 path, e
98 )));
99 }
100 };
101
102 let mut sorted: Vec<_> = entries.into_iter().collect();
104 sorted.sort_by(|a, b| {
105 a.name
106 .to_lowercase()
107 .cmp(&b.name.to_lowercase())
108 });
109
110 let total = sorted.len();
111 let displayed: Vec<String> = sorted
112 .iter()
113 .take(limit)
114 .map(|e| {
115 if e.is_dir {
116 format!("{}/", e.name)
117 } else {
118 e.name.clone()
119 }
120 })
121 .collect();
122
123 let mut output = displayed.join("\n");
124
125 if total > limit {
126 output.push_str(&format!(
127 "\n[Showing {} of {} entries]",
128 limit, total
129 ));
130 }
131
132 if total == 0 {
133 output = "(empty directory)".into();
134 }
135
136 Ok(ToolOutput::success(output).with_metadata(json!({
137 "total_entries": total,
138 "displayed": displayed.len(),
139 })))
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use soul_core::vfs::MemoryFs;
147
148 async fn setup() -> (Arc<MemoryFs>, LsTool) {
149 let fs = Arc::new(MemoryFs::new());
150 let tool = LsTool::new(fs.clone() as Arc<dyn VirtualFs>, "/project");
151 (fs, tool)
152 }
153
154 #[tokio::test]
155 async fn ls_directory() {
156 let (fs, tool) = setup().await;
157 fs.write("/project/file.txt", "content").await.unwrap();
158 fs.write("/project/code.rs", "fn main() {}").await.unwrap();
159 fs.write("/project/sub/nested.txt", "nested").await.unwrap();
160
161 let result = tool
162 .execute("c1", json!({}), None)
163 .await
164 .unwrap();
165
166 assert!(!result.is_error);
167 assert!(result.content.contains("file.txt"));
168 assert!(result.content.contains("code.rs"));
169 assert!(result.content.contains("sub/"));
170 }
171
172 #[tokio::test]
173 async fn ls_subdirectory() {
174 let (fs, tool) = setup().await;
175 fs.write("/project/src/main.rs", "fn main() {}").await.unwrap();
176
177 let result = tool
178 .execute("c2", json!({"path": "src"}), None)
179 .await
180 .unwrap();
181
182 assert!(!result.is_error);
183 assert!(result.content.contains("main.rs"));
184 }
185
186 #[tokio::test]
187 async fn ls_empty_dir() {
188 let (fs, tool) = setup().await;
189 fs.create_dir_all("/project/empty").await.unwrap();
190
191 let result = tool
192 .execute("c3", json!({"path": "empty"}), None)
193 .await
194 .unwrap();
195
196 assert!(!result.is_error);
197 assert!(result.content.contains("empty directory"));
198 }
199
200 #[tokio::test]
201 async fn ls_nonexistent() {
202 let (_fs, tool) = setup().await;
203 let result = tool
204 .execute("c4", json!({"path": "nope"}), None)
205 .await
206 .unwrap();
207 assert!(result.is_error);
208 assert!(result.content.contains("not found"));
209 }
210
211 #[tokio::test]
212 async fn ls_with_limit() {
213 let (fs, tool) = setup().await;
214 for i in 0..10 {
215 fs.write(&format!("/project/file{}.txt", i), "").await.unwrap();
216 }
217
218 let result = tool
219 .execute("c5", json!({"limit": 3}), None)
220 .await
221 .unwrap();
222
223 assert!(!result.is_error);
224 assert!(result.content.contains("Showing 3 of 10"));
225 }
226
227 #[tokio::test]
228 async fn ls_sorted_case_insensitive() {
229 let (fs, tool) = setup().await;
230 fs.write("/project/Banana.txt", "").await.unwrap();
231 fs.write("/project/apple.txt", "").await.unwrap();
232 fs.write("/project/Cherry.txt", "").await.unwrap();
233
234 let result = tool
235 .execute("c6", json!({}), None)
236 .await
237 .unwrap();
238
239 assert!(!result.is_error);
240 let lines: Vec<&str> = result.content.lines().collect();
241 assert_eq!(lines[0], "apple.txt");
242 assert_eq!(lines[1], "Banana.txt");
243 assert_eq!(lines[2], "Cherry.txt");
244 }
245
246 #[tokio::test]
247 async fn tool_name_and_definition() {
248 let (_fs, tool) = setup().await;
249 assert_eq!(tool.name(), "ls");
250 let def = tool.definition();
251 assert_eq!(def.name, "ls");
252 }
253}