agent_core/controller/tools/
ls.rs1use std::collections::HashMap;
7use std::fs::{self, DirEntry};
8use std::future::Future;
9use std::path::Path;
10use std::pin::Pin;
11use std::sync::Arc;
12use std::time::SystemTime;
13
14use chrono::{DateTime, Local};
15use globset::{Glob, GlobMatcher};
16
17use super::ask_for_permissions::{PermissionCategory, PermissionRequest};
18use super::permission_registry::PermissionRegistry;
19use super::types::{DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType};
20
21pub const LS_TOOL_NAME: &str = "ls";
23
24pub const LS_TOOL_DESCRIPTION: &str = r#"Lists directory contents with optional detailed information.
26
27Usage:
28- The path parameter must be an absolute path to a directory
29- Returns file and directory names in the specified location
30- Use long_format for detailed information (size, permissions, modified time)
31- Supports filtering by pattern and showing hidden files
32
33Options:
34- long_format: Show detailed file information (size, permissions, modified time)
35- include_hidden: Include files starting with '.' (default: false)
36- pattern: Filter results by glob pattern (e.g., "*.rs")
37- sort_by: Sort results by "name", "size", "modified" (default: "name")
38- reverse: Reverse sort order (default: false)
39- directories_first: List directories before files (default: true)
40
41Examples:
42- List current directory: path="/path/to/dir"
43- Show hidden files: include_hidden=true
44- Filter by pattern: pattern="*.rs"
45- Sort by size: sort_by="size", reverse=true"#;
46
47pub const LS_TOOL_SCHEMA: &str = r#"{
49 "type": "object",
50 "properties": {
51 "path": {
52 "type": "string",
53 "description": "The absolute path to the directory to list"
54 },
55 "long_format": {
56 "type": "boolean",
57 "description": "Show detailed information (size, permissions, modified time). Defaults to false."
58 },
59 "include_hidden": {
60 "type": "boolean",
61 "description": "Include hidden files (starting with dot). Defaults to false."
62 },
63 "pattern": {
64 "type": "string",
65 "description": "Glob pattern to filter results (e.g., '*.rs')"
66 },
67 "sort_by": {
68 "type": "string",
69 "enum": ["name", "size", "modified"],
70 "description": "Field to sort by. Defaults to 'name'."
71 },
72 "reverse": {
73 "type": "boolean",
74 "description": "Reverse the sort order. Defaults to false."
75 },
76 "directories_first": {
77 "type": "boolean",
78 "description": "List directories before files. Defaults to true."
79 },
80 "limit": {
81 "type": "integer",
82 "description": "Maximum number of entries to return. Defaults to 1000."
83 }
84 },
85 "required": ["path"]
86}"#;
87
88const DEFAULT_LIMIT: usize = 1000;
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum SortBy {
94 Name,
95 Size,
96 Modified,
97}
98
99impl SortBy {
100 fn from_str(s: &str) -> Self {
101 match s.to_lowercase().as_str() {
102 "size" => SortBy::Size,
103 "modified" | "mtime" | "time" => SortBy::Modified,
104 _ => SortBy::Name,
105 }
106 }
107}
108
109#[derive(Debug)]
111struct FileEntry {
112 name: String,
113 is_dir: bool,
114 size: u64,
115 modified: SystemTime,
116 #[cfg(unix)]
117 permissions: u32,
118 #[cfg(not(unix))]
119 readonly: bool,
120}
121
122impl FileEntry {
123 fn from_dir_entry(entry: &DirEntry) -> Option<Self> {
124 let metadata = entry.metadata().ok()?;
125 let name = entry.file_name().to_string_lossy().to_string();
126
127 Some(Self {
128 name,
129 is_dir: metadata.is_dir(),
130 size: metadata.len(),
131 modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
132 #[cfg(unix)]
133 permissions: std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()),
134 #[cfg(not(unix))]
135 readonly: metadata.permissions().readonly(),
136 })
137 }
138
139 fn format_short(&self) -> String {
140 if self.is_dir {
141 format!("{}/", self.name)
142 } else {
143 self.name.clone()
144 }
145 }
146
147 fn format_long(&self) -> String {
148 let size_str = format_size(self.size);
149 let time_str = format_time(self.modified);
150 let perm_str = self.format_permissions();
151 let type_char = if self.is_dir { 'd' } else { '-' };
152
153 format!(
154 "{}{} {:>8} {} {}{}",
155 type_char,
156 perm_str,
157 size_str,
158 time_str,
159 self.name,
160 if self.is_dir { "/" } else { "" }
161 )
162 }
163
164 #[cfg(unix)]
165 fn format_permissions(&self) -> String {
166 let mode = self.permissions;
167 let mut perm = String::with_capacity(9);
168
169 perm.push(if mode & 0o400 != 0 { 'r' } else { '-' });
171 perm.push(if mode & 0o200 != 0 { 'w' } else { '-' });
172 perm.push(if mode & 0o100 != 0 { 'x' } else { '-' });
173
174 perm.push(if mode & 0o040 != 0 { 'r' } else { '-' });
176 perm.push(if mode & 0o020 != 0 { 'w' } else { '-' });
177 perm.push(if mode & 0o010 != 0 { 'x' } else { '-' });
178
179 perm.push(if mode & 0o004 != 0 { 'r' } else { '-' });
181 perm.push(if mode & 0o002 != 0 { 'w' } else { '-' });
182 perm.push(if mode & 0o001 != 0 { 'x' } else { '-' });
183
184 perm
185 }
186
187 #[cfg(not(unix))]
188 fn format_permissions(&self) -> String {
189 if self.readonly {
190 "r--r--r--".to_string()
191 } else {
192 "rw-rw-rw-".to_string()
193 }
194 }
195}
196
197fn format_size(bytes: u64) -> String {
198 const KB: u64 = 1024;
199 const MB: u64 = KB * 1024;
200 const GB: u64 = MB * 1024;
201
202 if bytes >= GB {
203 format!("{:.1}G", bytes as f64 / GB as f64)
204 } else if bytes >= MB {
205 format!("{:.1}M", bytes as f64 / MB as f64)
206 } else if bytes >= KB {
207 format!("{:.1}K", bytes as f64 / KB as f64)
208 } else {
209 format!("{}B", bytes)
210 }
211}
212
213fn format_time(time: SystemTime) -> String {
214 let datetime: DateTime<Local> = time.into();
215 datetime.format("%b %d %H:%M").to_string()
216}
217
218pub struct LsTool {
220 permission_registry: Arc<PermissionRegistry>,
221}
222
223impl LsTool {
224 pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
226 Self { permission_registry }
227 }
228
229 fn build_permission_request(path: &str) -> PermissionRequest {
230 let dirname = Path::new(path)
231 .file_name()
232 .and_then(|n| n.to_str())
233 .unwrap_or(path);
234
235 PermissionRequest {
236 action: format!("List directory: {}", dirname),
237 reason: Some("Read directory contents".to_string()),
238 resources: vec![path.to_string()],
239 category: PermissionCategory::DirectoryRead,
240 }
241 }
242}
243
244impl Executable for LsTool {
245 fn name(&self) -> &str {
246 LS_TOOL_NAME
247 }
248
249 fn description(&self) -> &str {
250 LS_TOOL_DESCRIPTION
251 }
252
253 fn input_schema(&self) -> &str {
254 LS_TOOL_SCHEMA
255 }
256
257 fn tool_type(&self) -> ToolType {
258 ToolType::FileRead
259 }
260
261 fn execute(
262 &self,
263 context: ToolContext,
264 input: HashMap<String, serde_json::Value>,
265 ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
266 let permission_registry = self.permission_registry.clone();
267
268 Box::pin(async move {
269 let path_str = input
271 .get("path")
272 .and_then(|v| v.as_str())
273 .ok_or_else(|| "Missing required 'path' parameter".to_string())?;
274
275 let path = Path::new(path_str);
276
277 if !path.is_absolute() {
279 return Err(format!("path must be an absolute path, got: {}", path_str));
280 }
281
282 if !path.exists() {
283 return Err(format!("Path does not exist: {}", path_str));
284 }
285
286 if !path.is_dir() {
287 return Err(format!("Path is not a directory: {}", path_str));
288 }
289
290 let permission_request = Self::build_permission_request(path_str);
292 let already_granted = permission_registry
293 .is_granted(context.session_id, &permission_request)
294 .await;
295
296 if !already_granted {
297 let response_rx = permission_registry
299 .register(
300 context.tool_use_id.clone(),
301 context.session_id,
302 permission_request,
303 context.turn_id.clone(),
304 )
305 .await
306 .map_err(|e| format!("Failed to request permission: {}", e))?;
307
308 let response = response_rx
310 .await
311 .map_err(|_| "Permission request was cancelled".to_string())?;
312
313 if !response.granted {
314 let reason = response.message.unwrap_or_else(|| "User denied".to_string());
315 return Err(format!("Permission denied to list '{}': {}", path_str, reason));
316 }
317 }
318
319 let long_format = input
321 .get("long_format")
322 .and_then(|v| v.as_bool())
323 .unwrap_or(false);
324
325 let include_hidden = input
326 .get("include_hidden")
327 .and_then(|v| v.as_bool())
328 .unwrap_or(false);
329
330 let pattern = input.get("pattern").and_then(|v| v.as_str());
331
332 let sort_by = input
333 .get("sort_by")
334 .and_then(|v| v.as_str())
335 .map(SortBy::from_str)
336 .unwrap_or(SortBy::Name);
337
338 let reverse = input
339 .get("reverse")
340 .and_then(|v| v.as_bool())
341 .unwrap_or(false);
342
343 let directories_first = input
344 .get("directories_first")
345 .and_then(|v| v.as_bool())
346 .unwrap_or(true);
347
348 let limit = input
349 .get("limit")
350 .and_then(|v| v.as_i64())
351 .map(|v| v.max(1) as usize)
352 .unwrap_or(DEFAULT_LIMIT);
353
354 let glob_matcher: Option<GlobMatcher> = if let Some(pat) = pattern {
356 Some(
357 Glob::new(pat)
358 .map_err(|e| format!("Invalid pattern: {}", e))?
359 .compile_matcher(),
360 )
361 } else {
362 None
363 };
364
365 let read_dir = fs::read_dir(path)
367 .map_err(|e| format!("Failed to read directory: {}", e))?;
368
369 let mut entries: Vec<FileEntry> = read_dir
370 .filter_map(|entry| entry.ok())
371 .filter_map(|entry| FileEntry::from_dir_entry(&entry))
372 .filter(|entry| {
373 if !include_hidden && entry.name.starts_with('.') {
375 return false;
376 }
377
378 if let Some(ref matcher) = glob_matcher {
380 if !matcher.is_match(&entry.name) {
381 return false;
382 }
383 }
384
385 true
386 })
387 .collect();
388
389 entries.sort_by(|a, b| {
391 if directories_first && a.is_dir != b.is_dir {
393 return if a.is_dir {
394 std::cmp::Ordering::Less
395 } else {
396 std::cmp::Ordering::Greater
397 };
398 }
399
400 let cmp = match sort_by {
401 SortBy::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
402 SortBy::Size => a.size.cmp(&b.size),
403 SortBy::Modified => a.modified.cmp(&b.modified),
404 };
405
406 if reverse {
407 cmp.reverse()
408 } else {
409 cmp
410 }
411 });
412
413 let total_count = entries.len();
415 entries.truncate(limit);
416
417 if entries.is_empty() {
419 return Ok("Directory is empty".to_string());
420 }
421
422 let mut output: Vec<String> = entries
423 .iter()
424 .map(|e| {
425 if long_format {
426 e.format_long()
427 } else {
428 e.format_short()
429 }
430 })
431 .collect();
432
433 if total_count > limit {
434 output.push(format!(
435 "\n... and {} more entries (showing {}/{})",
436 total_count - limit,
437 limit,
438 total_count
439 ));
440 }
441
442 Ok(output.join("\n"))
443 })
444 }
445
446 fn display_config(&self) -> DisplayConfig {
447 DisplayConfig {
448 display_name: "List Directory".to_string(),
449 display_title: Box::new(|input| {
450 input
451 .get("path")
452 .and_then(|v| v.as_str())
453 .map(|p| {
454 Path::new(p)
455 .file_name()
456 .and_then(|n| n.to_str())
457 .unwrap_or(p)
458 .to_string()
459 })
460 .unwrap_or_default()
461 }),
462 display_content: Box::new(|_input, result| {
463 let lines: Vec<&str> = result.lines().take(30).collect();
464 let total_lines = result.lines().count();
465
466 DisplayResult {
467 content: lines.join("\n"),
468 content_type: ResultContentType::PlainText,
469 is_truncated: total_lines > 30,
470 full_length: total_lines,
471 }
472 }),
473 }
474 }
475
476 fn compact_summary(
477 &self,
478 input: &HashMap<String, serde_json::Value>,
479 result: &str,
480 ) -> String {
481 let dirname = input
482 .get("path")
483 .and_then(|v| v.as_str())
484 .map(|p| {
485 Path::new(p)
486 .file_name()
487 .and_then(|n| n.to_str())
488 .unwrap_or(p)
489 })
490 .unwrap_or("unknown");
491
492 let entry_count = result
493 .lines()
494 .filter(|line| !line.starts_with("...") && !line.is_empty())
495 .count();
496
497 format!("[Ls: {} ({} entries)]", dirname, entry_count)
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use std::fs;
505 use tempfile::TempDir;
506 use tokio::sync::mpsc;
507
508 fn setup_test_dir() -> TempDir {
509 let temp = TempDir::new().unwrap();
510
511 fs::create_dir(temp.path().join("subdir")).unwrap();
512 fs::write(temp.path().join("file1.txt"), "content").unwrap();
513 fs::write(temp.path().join("file2.rs"), "code").unwrap();
514 fs::write(temp.path().join(".hidden"), "secret").unwrap();
515
516 temp
517 }
518
519 #[test]
520 fn test_sort_by_from_str() {
521 assert_eq!(SortBy::from_str("name"), SortBy::Name);
522 assert_eq!(SortBy::from_str("size"), SortBy::Size);
523 assert_eq!(SortBy::from_str("modified"), SortBy::Modified);
524 assert_eq!(SortBy::from_str("mtime"), SortBy::Modified);
525 assert_eq!(SortBy::from_str("unknown"), SortBy::Name);
526 }
527
528 #[test]
529 fn test_format_size() {
530 assert_eq!(format_size(500), "500B");
531 assert_eq!(format_size(1024), "1.0K");
532 assert_eq!(format_size(1536), "1.5K");
533 assert_eq!(format_size(1024 * 1024), "1.0M");
534 assert_eq!(format_size(1024 * 1024 * 1024), "1.0G");
535 }
536
537 #[test]
538 fn test_build_permission_request() {
539 let request = LsTool::build_permission_request("/home/user/project");
540 assert_eq!(request.action, "List directory: project");
541 assert_eq!(request.category, PermissionCategory::DirectoryRead);
542 assert_eq!(request.resources, vec!["/home/user/project".to_string()]);
543 }
544
545 #[tokio::test]
546 async fn test_ls_requires_absolute_path() {
547 let (event_tx, _event_rx) = mpsc::channel(10);
548 let registry = Arc::new(PermissionRegistry::new(event_tx));
549 let tool = LsTool::new(registry);
550
551 let context = ToolContext {
552 session_id: 1,
553 tool_use_id: "test".to_string(),
554 turn_id: None,
555 };
556
557 let mut input = HashMap::new();
558 input.insert(
559 "path".to_string(),
560 serde_json::Value::String("relative/path".to_string()),
561 );
562
563 let result = tool.execute(context, input).await;
564 assert!(result.is_err());
565 assert!(result.unwrap_err().contains("must be an absolute path"));
566 }
567
568 #[tokio::test]
569 async fn test_ls_path_not_found() {
570 let (event_tx, _event_rx) = mpsc::channel(10);
571 let registry = Arc::new(PermissionRegistry::new(event_tx));
572 let tool = LsTool::new(registry);
573
574 let context = ToolContext {
575 session_id: 1,
576 tool_use_id: "test".to_string(),
577 turn_id: None,
578 };
579
580 let mut input = HashMap::new();
581 input.insert(
582 "path".to_string(),
583 serde_json::Value::String("/nonexistent/path".to_string()),
584 );
585
586 let result = tool.execute(context, input).await;
587 assert!(result.is_err());
588 assert!(result.unwrap_err().contains("does not exist"));
589 }
590
591 #[tokio::test]
592 async fn test_ls_not_a_directory() {
593 let temp = setup_test_dir();
594 let file_path = temp.path().join("file1.txt");
595
596 let (event_tx, _event_rx) = mpsc::channel(10);
597 let registry = Arc::new(PermissionRegistry::new(event_tx));
598 let tool = LsTool::new(registry);
599
600 let context = ToolContext {
601 session_id: 1,
602 tool_use_id: "test".to_string(),
603 turn_id: None,
604 };
605
606 let mut input = HashMap::new();
607 input.insert(
608 "path".to_string(),
609 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
610 );
611
612 let result = tool.execute(context, input).await;
613 assert!(result.is_err());
614 assert!(result.unwrap_err().contains("not a directory"));
615 }
616
617 #[test]
618 fn test_compact_summary() {
619 let (event_tx, _event_rx) = mpsc::channel(10);
620 let registry = Arc::new(PermissionRegistry::new(event_tx));
621 let tool = LsTool::new(registry);
622
623 let mut input = HashMap::new();
624 input.insert(
625 "path".to_string(),
626 serde_json::Value::String("/path/to/project".to_string()),
627 );
628
629 let result = "subdir/\nfile1.txt\nfile2.rs";
630 assert_eq!(
631 tool.compact_summary(&input, result),
632 "[Ls: project (3 entries)]"
633 );
634 }
635}