1use 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
14pub struct ListFilesTool;
16
17impl ListFilesTool {
18 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 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}