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 crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
18use super::types::{DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType};
19
20pub const LS_TOOL_NAME: &str = "ls";
22
23pub 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
46pub 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
87const DEFAULT_LIMIT: usize = 1000;
89
90#[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#[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 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 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 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
217pub struct LsTool {
219 permission_registry: Arc<PermissionRegistry>,
220}
221
222impl LsTool {
223 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 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 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 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 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 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 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 if !include_hidden && entry.name.starts_with('.') {
362 return false;
363 }
364
365 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 entries.sort_by(|a, b| {
378 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 let total_count = entries.len();
402 entries.truncate(limit);
403
404 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 let path_str = input.get("path").and_then(|v| v.as_str())?;
494
495 let path = Path::new(path_str);
496
497 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 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}