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