Skip to main content

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