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