astrid_tools/
list_directory.rs1use std::fmt::Write;
4
5use crate::{BuiltinTool, ToolContext, ToolError, ToolResult};
6use serde_json::Value;
7use std::path::PathBuf;
8
9pub struct ListDirectoryTool;
11
12#[async_trait::async_trait]
13impl BuiltinTool for ListDirectoryTool {
14 fn name(&self) -> &'static str {
15 "list_directory"
16 }
17
18 fn description(&self) -> &'static str {
19 "Lists the contents of a directory. Shows directories first, then files, \
20 both sorted alphabetically. Includes type indicator and file sizes."
21 }
22
23 fn input_schema(&self) -> Value {
24 serde_json::json!({
25 "type": "object",
26 "properties": {
27 "path": {
28 "type": "string",
29 "description": "Absolute path to the directory to list"
30 }
31 },
32 "required": ["path"]
33 })
34 }
35
36 async fn execute(&self, args: Value, _ctx: &ToolContext) -> ToolResult {
37 let dir_path = args
38 .get("path")
39 .and_then(Value::as_str)
40 .ok_or_else(|| ToolError::InvalidArguments("path is required".into()))?;
41
42 let path = PathBuf::from(dir_path);
43 if !path.exists() {
44 return Err(ToolError::PathNotFound(dir_path.to_string()));
45 }
46 if !path.is_dir() {
47 return Err(ToolError::InvalidArguments(format!(
48 "{dir_path} is not a directory"
49 )));
50 }
51
52 let mut dirs: Vec<String> = Vec::new();
53 let mut files: Vec<String> = Vec::new();
54
55 let mut entries = tokio::fs::read_dir(&path).await?;
56 while let Some(entry) = entries.next_entry().await? {
57 let name = entry.file_name().to_string_lossy().to_string();
58 let metadata = entry.metadata().await?;
59
60 if metadata.is_dir() {
61 dirs.push(format!(" {name}/"));
62 } else {
63 let size = metadata.len();
64 let size_str = format_size(size);
65 files.push(format!(" {name} ({size_str})"));
66 }
67 }
68
69 dirs.sort();
70 files.sort();
71
72 let mut output = String::new();
73 for d in &dirs {
74 output.push_str(d);
75 output.push('\n');
76 }
77 for f in &files {
78 output.push_str(f);
79 output.push('\n');
80 }
81
82 let total = dirs.len().saturating_add(files.len());
83
84 let _ = write!(
85 output,
86 "\n({} directories, {} files)",
87 dirs.len(),
88 files.len()
89 );
90
91 if total == 0 {
92 return Ok(format!("{dir_path} is empty"));
93 }
94
95 Ok(output)
96 }
97}
98
99#[allow(clippy::cast_precision_loss)]
101fn format_size(bytes: u64) -> String {
102 const KB: u64 = 1024;
103 const MB: u64 = 1024 * KB;
104 const GB: u64 = 1024 * MB;
105
106 if bytes >= GB {
107 format!("{:.1} GB", bytes as f64 / GB as f64)
108 } else if bytes >= MB {
109 format!("{:.1} MB", bytes as f64 / MB as f64)
110 } else if bytes >= KB {
111 format!("{:.1} KB", bytes as f64 / KB as f64)
112 } else {
113 format!("{bytes} B")
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use tempfile::TempDir;
121
122 fn ctx() -> ToolContext {
123 ToolContext::new(std::env::temp_dir())
124 }
125
126 #[tokio::test]
127 async fn test_list_directory_basic() {
128 let dir = TempDir::new().unwrap();
129 std::fs::create_dir(dir.path().join("subdir")).unwrap();
130 std::fs::write(dir.path().join("file.txt"), "hello").unwrap();
131
132 let result = ListDirectoryTool
133 .execute(
134 serde_json::json!({"path": dir.path().to_str().unwrap()}),
135 &ctx(),
136 )
137 .await
138 .unwrap();
139
140 assert!(result.contains("subdir/"));
141 assert!(result.contains("file.txt"));
142 assert!(result.contains("1 directories, 1 files"));
143 }
144
145 #[tokio::test]
146 async fn test_list_directory_dirs_first() {
147 let dir = TempDir::new().unwrap();
148 std::fs::write(dir.path().join("aaa.txt"), "").unwrap();
149 std::fs::create_dir(dir.path().join("zzz")).unwrap();
150
151 let result = ListDirectoryTool
152 .execute(
153 serde_json::json!({"path": dir.path().to_str().unwrap()}),
154 &ctx(),
155 )
156 .await
157 .unwrap();
158
159 let dir_pos = result.find("zzz/").unwrap();
160 let file_pos = result.find("aaa.txt").unwrap();
161 assert!(dir_pos < file_pos);
162 }
163
164 #[tokio::test]
165 async fn test_list_directory_not_found() {
166 let result = ListDirectoryTool
167 .execute(
168 serde_json::json!({"path": "/tmp/astrid_nonexistent_dir_12345"}),
169 &ctx(),
170 )
171 .await;
172
173 assert!(result.is_err());
174 }
175
176 #[tokio::test]
177 async fn test_list_directory_not_a_dir() {
178 let dir = TempDir::new().unwrap();
179 let file_path = dir.path().join("file.txt");
180 std::fs::write(&file_path, "hello").unwrap();
181
182 let result = ListDirectoryTool
183 .execute(
184 serde_json::json!({"path": file_path.to_str().unwrap()}),
185 &ctx(),
186 )
187 .await;
188
189 assert!(result.is_err());
190 }
191
192 #[test]
193 fn test_format_size() {
194 assert_eq!(format_size(0), "0 B");
195 assert_eq!(format_size(512), "512 B");
196 assert_eq!(format_size(1024), "1.0 KB");
197 assert_eq!(format_size(1_048_576), "1.0 MB");
198 assert_eq!(format_size(1_073_741_824), "1.0 GB");
199 }
200}