Skip to main content

soul_coder/tools/
ls.rs

1//! Ls tool — list directory contents with metadata.
2
3use 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
14/// Maximum entries returned.
15const 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        // Check if path exists
83        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        // Sort alphabetically (case-insensitive)
102        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}