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#[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        // Check if path exists
84        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        // Sort alphabetically (case-insensitive)
103        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}