Skip to main content

agent_core/controller/tools/
ls.rs

1//! Ls tool implementation
2//!
3//! This tool lists directory contents with optional detailed information,
4//! filtering, and sorting. It requires permission to read directories.
5
6use 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
21/// Ls tool name constant.
22pub const LS_TOOL_NAME: &str = "ls";
23
24/// Ls tool description constant.
25pub 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
47/// Ls tool JSON schema constant.
48pub 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
88/// Default maximum entries to return.
89const DEFAULT_LIMIT: usize = 1000;
90
91/// Sort field for directory entries.
92#[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/// A file or directory entry with metadata.
110#[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        // Owner permissions
170        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        // Group permissions
175        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        // Other permissions
180        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
218/// Tool that lists directory contents.
219pub struct LsTool {
220    permission_registry: Arc<PermissionRegistry>,
221}
222
223impl LsTool {
224    /// Create a new LsTool instance.
225    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            // Extract required parameter
270            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            // Validate path
278            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            // Check permission
291            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                // Request permission from user
298                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                // Block until user responds
309                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            // Optional parameters
320            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            // Build glob matcher if pattern provided
355            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            // Read directory entries
366            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                    // Filter hidden files
374                    if !include_hidden && entry.name.starts_with('.') {
375                        return false;
376                    }
377
378                    // Filter by pattern
379                    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            // Sort entries
390            entries.sort_by(|a, b| {
391                // Directories first if enabled
392                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            // Apply limit
414            let total_count = entries.len();
415            entries.truncate(limit);
416
417            // Format output
418            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}