1use 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
22pub const LS_TOOL_NAME: &str = "ls";
24
25pub 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
48pub 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
89const DEFAULT_LIMIT: usize = 1000;
91
92#[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#[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 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 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 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
219pub struct LsTool {
221 permission_registry: Arc<PermissionRegistry>,
222}
223
224impl LsTool {
225 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 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 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 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 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 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 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 if !include_hidden && entry.name.starts_with('.') {
376 return false;
377 }
378
379 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 entries.sort_by(|a, b| {
392 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 let total_count = entries.len();
412 entries.truncate(limit);
413
414 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 let path_str = input.get("path").and_then(|v| v.as_str())?;
500
501 let path = Path::new(path_str);
502
503 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 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}