Skip to main content

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