Skip to main content

oxi_agent/tools/
ls.rs

1use super::path_security::PathGuard;
2/// Ls tool - list directory contents
3use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
4use crate::tools::truncate::{format_bytes, truncate_head, TruncationOptions};
5use async_trait::async_trait;
6use serde_json::{json, Value};
7use std::path::{Path, PathBuf};
8use tokio::fs;
9use tokio::sync::oneshot;
10
11/// Default max entries to show (use truncate for more)
12const DEFAULT_ENTRY_LIMIT: usize = 100;
13/// Default max output lines (for truncation)
14const DEFAULT_MAX_LINES: usize = 2000;
15/// Default max output bytes (for truncation)
16const DEFAULT_MAX_BYTES: usize = 50 * 1024;
17
18/// LsTool.
19pub struct LsTool {
20    root_dir: Option<PathBuf>,
21}
22
23impl LsTool {
24    /// Create with no explicit root (uses ToolContext.workspace_dir at runtime).
25    pub fn new() -> Self {
26        Self { root_dir: None }
27    }
28
29    /// Create with a specific working directory (overrides ToolContext).
30    pub fn with_cwd(cwd: PathBuf) -> Self {
31        Self {
32            root_dir: Some(cwd),
33        }
34    }
35
36    /// Format file size in human-readable format
37    fn format_size(size: u64) -> String {
38        format_bytes(size as usize)
39    }
40
41    /// Get file type indicator: / for dirs, @ for symlinks, * for executables
42    fn get_type_indicator(metadata: &std::fs::Metadata) -> &'static str {
43        if metadata.is_dir() {
44            "/"
45        } else if metadata.file_type().is_symlink() {
46            "@"
47        } else {
48            // Check if executable
49            #[cfg(unix)]
50            {
51                use std::os::unix::fs::PermissionsExt;
52                if metadata.permissions().mode() & 0o111 != 0 {
53                    return "*";
54                }
55            }
56            ""
57        }
58    }
59
60    async fn ls_impl(
61        root_dir: &Path,
62        path: &str,
63        all: bool,
64        long_format: bool,
65        entry_limit: Option<usize>,
66    ) -> Result<String, ToolError> {
67        // Security: validate path with PathGuard
68        let guard = PathGuard::new(root_dir);
69        let dir_path = guard
70            .validate_traversal(Path::new(path))
71            .map_err(|e| e.to_string())?;
72
73        if !dir_path.exists() {
74            return Err(format!("Path not found: {}", path));
75        }
76
77        if !dir_path.is_dir() {
78            // If it's a file, just return its info
79            let meta = fs::metadata(&dir_path)
80                .await
81                .map_err(|e| format!("Cannot read metadata: {}", e))?;
82            let size = meta.len();
83            let name = dir_path
84                .file_name()
85                .map(|n| n.to_string_lossy().to_string())
86                .unwrap_or_default();
87
88            let type_indicator = Self::get_type_indicator(&meta);
89
90            return Ok(if long_format {
91                format!("{:<10} {}{}", Self::format_size(size), name, type_indicator)
92            } else {
93                format!("{}{}", name, type_indicator)
94            });
95        }
96
97        // Read all entries first
98        let mut entries: Vec<(String, bool, u64, std::fs::Metadata)> = Vec::new();
99        let mut dir = fs::read_dir(&dir_path)
100            .await
101            .map_err(|e| format!("Cannot read directory: {}", e))?;
102
103        while let Some(entry) = dir
104            .next_entry()
105            .await
106            .map_err(|e| format!("Error reading entry: {}", e))?
107        {
108            let file_name = entry.file_name().to_string_lossy().to_string();
109
110            // Skip hidden files unless --all
111            if !all && file_name.starts_with('.') {
112                continue;
113            }
114
115            let metadata = entry.metadata().await.map_err(|e| e.to_string())?;
116            let is_dir = metadata.is_dir();
117            let size = metadata.len();
118
119            entries.push((file_name, is_dir, size, metadata));
120        }
121
122        // Sort: directories first, then alphabetically (case-insensitive)
123        entries.sort_by(|a, b| match (a.1, b.1) {
124            (true, false) => std::cmp::Ordering::Less,
125            (false, true) => std::cmp::Ordering::Greater,
126            _ => a.0.to_lowercase().cmp(&b.0.to_lowercase()),
127        });
128
129        // Apply entry limit if specified
130        let limit = entry_limit.unwrap_or(DEFAULT_ENTRY_LIMIT);
131        let limited = if entries.len() > limit {
132            entries.truncate(limit);
133            true
134        } else {
135            false
136        };
137
138        let total_entries = entries.len();
139        let dir_count = entries.iter().filter(|e| e.1).count();
140        let file_count = total_entries - dir_count;
141
142        // Build output based on format
143        let output = if long_format {
144            let mut lines: Vec<String> = entries
145                .iter()
146                .map(|(name, _is_dir, size, meta)| {
147                    let type_indicator = Self::get_type_indicator(meta);
148                    format!(
149                        "{:<10} {}{}",
150                        Self::format_size(*size),
151                        name,
152                        type_indicator
153                    )
154                })
155                .collect();
156
157            // Add entry count summary
158            lines.push(format!(
159                "\n{} director{}, {} file{}",
160                dir_count,
161                if dir_count == 1 { "y" } else { "ies" },
162                file_count,
163                if file_count == 1 { "" } else { "s" }
164            ));
165
166            lines.join("\n")
167        } else {
168            let lines: Vec<String> = entries
169                .iter()
170                .map(|(name, _, _, meta)| {
171                    let type_indicator = Self::get_type_indicator(meta);
172                    format!("{}{}", name, type_indicator)
173                })
174                .collect();
175
176            lines.join("\n")
177        };
178
179        // Add entry limit notice if truncated
180        let output = if limited {
181            format!(
182                "{}\n\n... [limit reached: {} entries total, use limit=N to see more]",
183                output, total_entries
184            )
185        } else {
186            output
187        };
188
189        // Apply output truncation (for very large directories)
190        let truncation_options = TruncationOptions {
191            max_lines: Some(DEFAULT_MAX_LINES),
192            max_bytes: Some(DEFAULT_MAX_BYTES),
193        };
194        let result = truncate_head(&output, &truncation_options);
195
196        Ok(result.content)
197    }
198}
199
200impl Default for LsTool {
201    fn default() -> Self {
202        Self::new()
203    }
204}
205
206#[async_trait]
207impl AgentTool for LsTool {
208    fn name(&self) -> &str {
209        "ls"
210    }
211
212    fn label(&self) -> &str {
213        "Ls"
214    }
215
216    fn essential(&self) -> bool {
217        true
218    }
219    fn description(&self) -> &str {
220        "List directory contents. Shows files and subdirectories with optional details."
221    }
222
223    fn parameters_schema(&self) -> Value {
224        json!({
225            "type": "object",
226            "properties": {
227                "path": {
228                    "type": "string",
229                    "description": "The directory to list",
230                    "default": "."
231                },
232                "all": {
233                    "type": "boolean",
234                    "description": "If true, show hidden files (starting with .)",
235                    "default": false
236                },
237                "long": {
238                    "type": "boolean",
239                    "description": "If true, show detailed listing with file sizes",
240                    "default": false
241                },
242                "limit": {
243                    "type": "integer",
244                    "description": "Maximum number of entries to display (truncation notice shown if exceeded)",
245                    "default": 100
246                }
247            },
248            "required": ["path"]
249        })
250    }
251
252    async fn execute(
253        &self,
254        _tool_call_id: &str,
255        params: Value,
256        _signal: Option<oneshot::Receiver<()>>,
257        ctx: &ToolContext,
258    ) -> Result<AgentToolResult, ToolError> {
259        let path = params
260            .get("path")
261            .and_then(|v: &Value| v.as_str())
262            .unwrap_or(".");
263
264        let all = params
265            .get("all")
266            .and_then(|v: &Value| v.as_bool())
267            .unwrap_or(false);
268
269        let long_format = params
270            .get("long")
271            .and_then(|v: &Value| v.as_bool())
272            .unwrap_or(false);
273
274        let entry_limit = params
275            .get("limit")
276            .and_then(|v: &Value| v.as_u64())
277            .map(|l| l as usize);
278
279        // Use root_dir if set, else ctx.root()
280        let root = self.root_dir.as_deref().unwrap_or(ctx.root());
281
282        match Self::ls_impl(root, path, all, long_format, entry_limit).await {
283            Ok(output) => Ok(AgentToolResult::success(output)),
284            Err(e) => Ok(AgentToolResult::error(e)),
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use std::fs;
293    use tempfile::TempDir;
294
295    fn create_test_dir() -> TempDir {
296        let temp_dir = TempDir::new().unwrap();
297
298        // Create test files and directories
299        let test_files = vec![
300            ("alpha.txt", false),
301            ("beta.txt", false),
302            ("gamma.rs", false),
303            ("subdir", true),
304            ("another_dir", true),
305        ];
306
307        for (name, is_dir) in test_files {
308            let path = temp_dir.path().join(name);
309            if is_dir {
310                fs::create_dir(&path).unwrap();
311            } else {
312                fs::write(&path, "test content").unwrap();
313            }
314        }
315
316        // Create hidden file
317        fs::write(temp_dir.path().join(".hidden"), "hidden").unwrap();
318
319        temp_dir
320    }
321
322    #[test]
323    fn test_basic_ls() {
324        let temp_dir = create_test_dir();
325        let rt = tokio::runtime::Runtime::new().unwrap();
326
327        let result = rt
328            .block_on(async {
329                LsTool::ls_impl(
330                    Path::new("."),
331                    temp_dir.path().to_str().unwrap(),
332                    false,
333                    false,
334                    None,
335                )
336                .await
337            })
338            .unwrap();
339
340        // Should include visible files and directories
341        assert!(result.contains("alpha.txt"));
342        assert!(result.contains("beta.txt"));
343        assert!(result.contains("gamma.rs"));
344        // Should not show hidden file by default
345        assert!(!result.contains(".hidden"));
346    }
347
348    #[test]
349    fn test_ls_all() {
350        let temp_dir = create_test_dir();
351        let rt = tokio::runtime::Runtime::new().unwrap();
352
353        let result = rt
354            .block_on(async {
355                LsTool::ls_impl(
356                    Path::new("."),
357                    temp_dir.path().to_str().unwrap(),
358                    true,
359                    false,
360                    None,
361                )
362                .await
363            })
364            .unwrap();
365
366        // Should show hidden file with all flag
367        assert!(result.contains(".hidden"));
368    }
369
370    #[test]
371    fn test_ls_long_format() {
372        let temp_dir = create_test_dir();
373        let rt = tokio::runtime::Runtime::new().unwrap();
374
375        let result = rt
376            .block_on(async {
377                LsTool::ls_impl(
378                    Path::new("."),
379                    temp_dir.path().to_str().unwrap(),
380                    false,
381                    true,
382                    None,
383                )
384                .await
385            })
386            .unwrap();
387
388        // Long format should have sizes
389        assert!(result.contains("B") || result.contains("KB") || result.contains("MB"));
390    }
391
392    #[test]
393    fn test_entry_count_summary() {
394        let temp_dir = create_test_dir();
395        let rt = tokio::runtime::Runtime::new().unwrap();
396
397        let result = rt
398            .block_on(async {
399                LsTool::ls_impl(
400                    Path::new("."),
401                    temp_dir.path().to_str().unwrap(),
402                    false,
403                    true,
404                    None,
405                )
406                .await
407            })
408            .unwrap();
409
410        // Should have entry count summary in long format
411        assert!(result.contains("directories") || result.contains("directory"));
412        assert!(result.contains("files") || result.contains("file"));
413    }
414
415    #[test]
416    fn test_entry_limit() {
417        let temp_dir = create_test_dir();
418        let rt = tokio::runtime::Runtime::new().unwrap();
419
420        // Set limit to 2
421        let result = rt
422            .block_on(async {
423                LsTool::ls_impl(
424                    Path::new("."),
425                    temp_dir.path().to_str().unwrap(),
426                    false,
427                    false,
428                    Some(2),
429                )
430                .await
431            })
432            .unwrap();
433
434        // Should show limit reached notice
435        assert!(result.contains("limit reached") || result.contains("limit=N"));
436    }
437
438    #[test]
439    fn test_case_insensitive_sort() {
440        let temp_dir = TempDir::new().unwrap();
441
442        // Create files with various cases
443        fs::write(temp_dir.path().join("Zebra.rs"), "").unwrap();
444        fs::write(temp_dir.path().join("apple.rs"), "").unwrap();
445        fs::write(temp_dir.path().join("Banana.rs"), "").unwrap();
446
447        let rt = tokio::runtime::Runtime::new().unwrap();
448        let result = rt
449            .block_on(async {
450                LsTool::ls_impl(
451                    Path::new("."),
452                    temp_dir.path().to_str().unwrap(),
453                    false,
454                    false,
455                    None,
456                )
457                .await
458            })
459            .unwrap();
460
461        let _lines: Vec<&str> = result.lines().collect();
462        // Should be sorted case-insensitely: apple, Banana, Zebra
463        assert!(result.contains("apple.rs"));
464        assert!(result.contains("Banana.rs"));
465        assert!(result.contains("Zebra.rs"));
466    }
467
468    #[test]
469    fn test_type_indicators() {
470        let temp_dir = TempDir::new().unwrap();
471
472        // Create directory
473        fs::create_dir(temp_dir.path().join("test_dir")).unwrap();
474        // Create regular file
475        fs::write(temp_dir.path().join("test_file.txt"), "").unwrap();
476
477        let rt = tokio::runtime::Runtime::new().unwrap();
478        let result = rt
479            .block_on(async {
480                LsTool::ls_impl(
481                    Path::new("."),
482                    temp_dir.path().to_str().unwrap(),
483                    false,
484                    false,
485                    None,
486                )
487                .await
488            })
489            .unwrap();
490
491        // Directories should have / indicator
492        assert!(result.contains("test_dir/"));
493        // Regular files should not have indicator
494        assert!(result.contains("test_file.txt"));
495        assert!(!result.contains("test_file.txt/"));
496    }
497
498    #[test]
499    fn test_path_traversal_prevention() {
500        let rt = tokio::runtime::Runtime::new().unwrap();
501        let result = rt.block_on(async {
502            LsTool::ls_impl(Path::new("."), "../etc", false, false, None).await
503        });
504
505        assert!(result.is_err());
506        assert!(result.unwrap_err().contains("traversal"));
507    }
508
509    #[test]
510    fn test_nonexistent_path() {
511        let rt = tokio::runtime::Runtime::new().unwrap();
512        let result = rt.block_on(async {
513            LsTool::ls_impl(
514                Path::new("."),
515                "/nonexistent/path/12345",
516                false,
517                false,
518                None,
519            )
520            .await
521        });
522
523        assert!(result.is_err());
524        assert!(result.unwrap_err().contains("not found"));
525    }
526
527    #[test]
528    fn test_single_file() {
529        let temp_dir = TempDir::new().unwrap();
530        let file_path = temp_dir.path().join("single_file.txt");
531        fs::write(&file_path, "content").unwrap();
532
533        let rt = tokio::runtime::Runtime::new().unwrap();
534        let result = rt
535            .block_on(async {
536                LsTool::ls_impl(
537                    Path::new("."),
538                    file_path.to_str().unwrap(),
539                    false,
540                    false,
541                    None,
542                )
543                .await
544            })
545            .unwrap();
546
547        assert!(result.contains("single_file.txt"));
548    }
549
550    #[test]
551    fn test_format_size() {
552        assert!(LsTool::format_size(500).contains("B"));
553        assert!(LsTool::format_size(1024).contains("KB"));
554        assert!(LsTool::format_size(1024 * 1024).contains("MB"));
555    }
556}