Skip to main content

aegis_tools/
listing.rs

1//! # ListFilesTool
2//!
3//! Lists a directory as a tree, confined to the working directory. Uses only
4//! `std::fs` (zero new dependencies). Skips heavy/noise dirs (`.git`,
5//! `node_modules`, `target`) unless `all` is set.
6
7use crate::registry::{Tool, ToolContext};
8use aegis_security::check_path;
9use anyhow::Result;
10use async_trait::async_trait;
11use serde_json::{json, Value};
12use std::path::Path;
13
14/// Lists directory contents as a tree.
15pub struct ListFilesTool;
16
17impl ListFilesTool {
18    /// Create a new `ListFilesTool`.
19    pub fn new() -> Self {
20        ListFilesTool
21    }
22}
23
24impl Default for ListFilesTool {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30const SKIP_DIRS: &[&str] = &[".git", "node_modules", "target", ".cache"];
31
32#[async_trait]
33impl Tool for ListFilesTool {
34    fn name(&self) -> &str {
35        "list_files"
36    }
37
38    fn description(&self) -> &str {
39        "List a directory as a tree (within the working directory). Skips .git/node_modules/target by default."
40    }
41
42    fn parameters(&self) -> Value {
43        json!({
44            "type": "object",
45            "properties": {
46                "path": { "type": "string", "description": "Directory to list (default: current working directory)" },
47                "depth": { "type": "integer", "description": "How many levels deep to recurse (default 1)" },
48                "all": { "type": "boolean", "description": "Include hidden files and normally-skipped dirs (default false)" },
49                "max_entries": { "type": "integer", "description": "Maximum entries to list (default 200)" }
50            }
51        })
52    }
53
54    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
55        let path_arg = args["path"].as_str().unwrap_or(".");
56        let depth = args["depth"].as_u64().unwrap_or(1) as usize;
57        let all = args["all"].as_bool().unwrap_or(false);
58        let max_entries = args["max_entries"].as_u64().unwrap_or(200) as usize;
59
60        let dir = check_path(path_arg, &ctx.cwd)?;
61        if !dir.exists() {
62            anyhow::bail!("Path not found: {path_arg}");
63        }
64        if !dir.is_dir() {
65            anyhow::bail!("Not a directory: {path_arg}");
66        }
67
68        let mut out = String::new();
69        let mut count = 0usize;
70        let mut truncated = false;
71        walk(&dir, depth, all, max_entries, &mut count, &mut truncated, 0, &mut out);
72
73        if out.is_empty() {
74            return Ok(format!("(empty directory: {path_arg})"));
75        }
76        if truncated {
77            out.push_str(&format!(
78                "\n... [truncated at {max_entries} entries; increase max_entries or narrow the path]"
79            ));
80        }
81        Ok(out.trim_end().to_string())
82    }
83}
84
85#[allow(clippy::too_many_arguments)]
86fn walk(
87    dir: &Path,
88    depth: usize,
89    all: bool,
90    max_entries: usize,
91    count: &mut usize,
92    truncated: &mut bool,
93    indent: usize,
94    out: &mut String,
95) {
96    if *truncated {
97        return;
98    }
99    let mut entries: Vec<_> = match std::fs::read_dir(dir) {
100        Ok(rd) => rd.flatten().collect(),
101        Err(_) => return,
102    };
103    // Directories first, then files; each alphabetical.
104    entries.sort_by_key(|e| {
105        let is_dir = e.path().is_dir();
106        (!is_dir, e.file_name().to_string_lossy().to_lowercase())
107    });
108
109    for entry in entries {
110        if *count >= max_entries {
111            *truncated = true;
112            return;
113        }
114        let name = entry.file_name().to_string_lossy().to_string();
115        if !all && name.starts_with('.') {
116            continue;
117        }
118        let path = entry.path();
119        let is_dir = path.is_dir();
120        if is_dir && !all && SKIP_DIRS.contains(&name.as_str()) {
121            out.push_str(&format!("{}{}/  (skipped)\n", "  ".repeat(indent), name));
122            *count += 1;
123            continue;
124        }
125
126        let suffix = if is_dir { "/" } else { "" };
127        out.push_str(&format!("{}{}{}\n", "  ".repeat(indent), name, suffix));
128        *count += 1;
129
130        if is_dir && depth > 1 {
131            walk(&path, depth - 1, all, max_entries, count, truncated, indent + 1, out);
132        }
133    }
134}