1use super::path_security::PathGuard;
2use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
4use crate::tools::truncate::{format_bytes, truncate_head, TruncationOptions};
5use async_trait::async_trait;
6use serde_json::{json, Value};
7use std::path::{Path, PathBuf};
8use tokio::fs;
9use tokio::sync::oneshot;
10
11const DEFAULT_ENTRY_LIMIT: usize = 100;
13const DEFAULT_MAX_LINES: usize = 2000;
15const DEFAULT_MAX_BYTES: usize = 50 * 1024;
17
18pub struct LsTool {
20 root_dir: Option<PathBuf>,
21}
22
23impl LsTool {
24 pub fn new() -> Self {
26 Self { root_dir: None }
27 }
28
29 pub fn with_cwd(cwd: PathBuf) -> Self {
31 Self {
32 root_dir: Some(cwd),
33 }
34 }
35
36 fn format_size(size: u64) -> String {
38 format_bytes(size as usize)
39 }
40
41 fn get_type_indicator(metadata: &std::fs::Metadata) -> &'static str {
43 if metadata.is_dir() {
44 "/"
45 } else if metadata.file_type().is_symlink() {
46 "@"
47 } else {
48 #[cfg(unix)]
50 {
51 use std::os::unix::fs::PermissionsExt;
52 if metadata.permissions().mode() & 0o111 != 0 {
53 return "*";
54 }
55 }
56 ""
57 }
58 }
59
60 async fn ls_impl(
61 root_dir: &Path,
62 path: &str,
63 all: bool,
64 long_format: bool,
65 entry_limit: Option<usize>,
66 ) -> Result<String, ToolError> {
67 let guard = PathGuard::new(root_dir);
69 let dir_path = guard
70 .validate_traversal(Path::new(path))
71 .map_err(|e| e.to_string())?;
72
73 if !dir_path.exists() {
74 return Err(format!("Path not found: {}", path));
75 }
76
77 if !dir_path.is_dir() {
78 let meta = fs::metadata(&dir_path)
80 .await
81 .map_err(|e| format!("Cannot read metadata: {}", e))?;
82 let size = meta.len();
83 let name = dir_path
84 .file_name()
85 .map(|n| n.to_string_lossy().to_string())
86 .unwrap_or_default();
87
88 let type_indicator = Self::get_type_indicator(&meta);
89
90 return Ok(if long_format {
91 format!("{:<10} {}{}", Self::format_size(size), name, type_indicator)
92 } else {
93 format!("{}{}", name, type_indicator)
94 });
95 }
96
97 let mut entries: Vec<(String, bool, u64, std::fs::Metadata)> = Vec::new();
99 let mut dir = fs::read_dir(&dir_path)
100 .await
101 .map_err(|e| format!("Cannot read directory: {}", e))?;
102
103 while let Some(entry) = dir
104 .next_entry()
105 .await
106 .map_err(|e| format!("Error reading entry: {}", e))?
107 {
108 let file_name = entry.file_name().to_string_lossy().to_string();
109
110 if !all && file_name.starts_with('.') {
112 continue;
113 }
114
115 let metadata = entry.metadata().await.map_err(|e| e.to_string())?;
116 let is_dir = metadata.is_dir();
117 let size = metadata.len();
118
119 entries.push((file_name, is_dir, size, metadata));
120 }
121
122 entries.sort_by(|a, b| match (a.1, b.1) {
124 (true, false) => std::cmp::Ordering::Less,
125 (false, true) => std::cmp::Ordering::Greater,
126 _ => a.0.to_lowercase().cmp(&b.0.to_lowercase()),
127 });
128
129 let limit = entry_limit.unwrap_or(DEFAULT_ENTRY_LIMIT);
131 let limited = if entries.len() > limit {
132 entries.truncate(limit);
133 true
134 } else {
135 false
136 };
137
138 let total_entries = entries.len();
139 let dir_count = entries.iter().filter(|e| e.1).count();
140 let file_count = total_entries - dir_count;
141
142 let output = if long_format {
144 let mut lines: Vec<String> = entries
145 .iter()
146 .map(|(name, _is_dir, size, meta)| {
147 let type_indicator = Self::get_type_indicator(meta);
148 format!(
149 "{:<10} {}{}",
150 Self::format_size(*size),
151 name,
152 type_indicator
153 )
154 })
155 .collect();
156
157 lines.push(format!(
159 "\n{} director{}, {} file{}",
160 dir_count,
161 if dir_count == 1 { "y" } else { "ies" },
162 file_count,
163 if file_count == 1 { "" } else { "s" }
164 ));
165
166 lines.join("\n")
167 } else {
168 let lines: Vec<String> = entries
169 .iter()
170 .map(|(name, _, _, meta)| {
171 let type_indicator = Self::get_type_indicator(meta);
172 format!("{}{}", name, type_indicator)
173 })
174 .collect();
175
176 lines.join("\n")
177 };
178
179 let output = if limited {
181 format!(
182 "{}\n\n... [limit reached: {} entries total, use limit=N to see more]",
183 output, total_entries
184 )
185 } else {
186 output
187 };
188
189 let truncation_options = TruncationOptions {
191 max_lines: Some(DEFAULT_MAX_LINES),
192 max_bytes: Some(DEFAULT_MAX_BYTES),
193 };
194 let result = truncate_head(&output, &truncation_options);
195
196 Ok(result.content)
197 }
198}
199
200impl Default for LsTool {
201 fn default() -> Self {
202 Self::new()
203 }
204}
205
206#[async_trait]
207impl AgentTool for LsTool {
208 fn name(&self) -> &str {
209 "ls"
210 }
211
212 fn label(&self) -> &str {
213 "Ls"
214 }
215
216 fn essential(&self) -> bool {
217 true
218 }
219 fn description(&self) -> &str {
220 "List directory contents. Shows files and subdirectories with optional details."
221 }
222
223 fn parameters_schema(&self) -> Value {
224 json!({
225 "type": "object",
226 "properties": {
227 "path": {
228 "type": "string",
229 "description": "The directory to list",
230 "default": "."
231 },
232 "all": {
233 "type": "boolean",
234 "description": "If true, show hidden files (starting with .)",
235 "default": false
236 },
237 "long": {
238 "type": "boolean",
239 "description": "If true, show detailed listing with file sizes",
240 "default": false
241 },
242 "limit": {
243 "type": "integer",
244 "description": "Maximum number of entries to display (truncation notice shown if exceeded)",
245 "default": 100
246 }
247 },
248 "required": ["path"]
249 })
250 }
251
252 async fn execute(
253 &self,
254 _tool_call_id: &str,
255 params: Value,
256 _signal: Option<oneshot::Receiver<()>>,
257 ctx: &ToolContext,
258 ) -> Result<AgentToolResult, ToolError> {
259 let path = params
260 .get("path")
261 .and_then(|v: &Value| v.as_str())
262 .unwrap_or(".");
263
264 let all = params
265 .get("all")
266 .and_then(|v: &Value| v.as_bool())
267 .unwrap_or(false);
268
269 let long_format = params
270 .get("long")
271 .and_then(|v: &Value| v.as_bool())
272 .unwrap_or(false);
273
274 let entry_limit = params
275 .get("limit")
276 .and_then(|v: &Value| v.as_u64())
277 .map(|l| l as usize);
278
279 let root = self.root_dir.as_deref().unwrap_or(ctx.root());
281
282 match Self::ls_impl(root, path, all, long_format, entry_limit).await {
283 Ok(output) => Ok(AgentToolResult::success(output)),
284 Err(e) => Ok(AgentToolResult::error(e)),
285 }
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use std::fs;
293 use tempfile::TempDir;
294
295 fn create_test_dir() -> TempDir {
296 let temp_dir = TempDir::new().unwrap();
297
298 let test_files = vec![
300 ("alpha.txt", false),
301 ("beta.txt", false),
302 ("gamma.rs", false),
303 ("subdir", true),
304 ("another_dir", true),
305 ];
306
307 for (name, is_dir) in test_files {
308 let path = temp_dir.path().join(name);
309 if is_dir {
310 fs::create_dir(&path).unwrap();
311 } else {
312 fs::write(&path, "test content").unwrap();
313 }
314 }
315
316 fs::write(temp_dir.path().join(".hidden"), "hidden").unwrap();
318
319 temp_dir
320 }
321
322 #[test]
323 fn test_basic_ls() {
324 let temp_dir = create_test_dir();
325 let rt = tokio::runtime::Runtime::new().unwrap();
326
327 let result = rt
328 .block_on(async {
329 LsTool::ls_impl(
330 Path::new("."),
331 temp_dir.path().to_str().unwrap(),
332 false,
333 false,
334 None,
335 )
336 .await
337 })
338 .unwrap();
339
340 assert!(result.contains("alpha.txt"));
342 assert!(result.contains("beta.txt"));
343 assert!(result.contains("gamma.rs"));
344 assert!(!result.contains(".hidden"));
346 }
347
348 #[test]
349 fn test_ls_all() {
350 let temp_dir = create_test_dir();
351 let rt = tokio::runtime::Runtime::new().unwrap();
352
353 let result = rt
354 .block_on(async {
355 LsTool::ls_impl(
356 Path::new("."),
357 temp_dir.path().to_str().unwrap(),
358 true,
359 false,
360 None,
361 )
362 .await
363 })
364 .unwrap();
365
366 assert!(result.contains(".hidden"));
368 }
369
370 #[test]
371 fn test_ls_long_format() {
372 let temp_dir = create_test_dir();
373 let rt = tokio::runtime::Runtime::new().unwrap();
374
375 let result = rt
376 .block_on(async {
377 LsTool::ls_impl(
378 Path::new("."),
379 temp_dir.path().to_str().unwrap(),
380 false,
381 true,
382 None,
383 )
384 .await
385 })
386 .unwrap();
387
388 assert!(result.contains("B") || result.contains("KB") || result.contains("MB"));
390 }
391
392 #[test]
393 fn test_entry_count_summary() {
394 let temp_dir = create_test_dir();
395 let rt = tokio::runtime::Runtime::new().unwrap();
396
397 let result = rt
398 .block_on(async {
399 LsTool::ls_impl(
400 Path::new("."),
401 temp_dir.path().to_str().unwrap(),
402 false,
403 true,
404 None,
405 )
406 .await
407 })
408 .unwrap();
409
410 assert!(result.contains("directories") || result.contains("directory"));
412 assert!(result.contains("files") || result.contains("file"));
413 }
414
415 #[test]
416 fn test_entry_limit() {
417 let temp_dir = create_test_dir();
418 let rt = tokio::runtime::Runtime::new().unwrap();
419
420 let result = rt
422 .block_on(async {
423 LsTool::ls_impl(
424 Path::new("."),
425 temp_dir.path().to_str().unwrap(),
426 false,
427 false,
428 Some(2),
429 )
430 .await
431 })
432 .unwrap();
433
434 assert!(result.contains("limit reached") || result.contains("limit=N"));
436 }
437
438 #[test]
439 fn test_case_insensitive_sort() {
440 let temp_dir = TempDir::new().unwrap();
441
442 fs::write(temp_dir.path().join("Zebra.rs"), "").unwrap();
444 fs::write(temp_dir.path().join("apple.rs"), "").unwrap();
445 fs::write(temp_dir.path().join("Banana.rs"), "").unwrap();
446
447 let rt = tokio::runtime::Runtime::new().unwrap();
448 let result = rt
449 .block_on(async {
450 LsTool::ls_impl(
451 Path::new("."),
452 temp_dir.path().to_str().unwrap(),
453 false,
454 false,
455 None,
456 )
457 .await
458 })
459 .unwrap();
460
461 let _lines: Vec<&str> = result.lines().collect();
462 assert!(result.contains("apple.rs"));
464 assert!(result.contains("Banana.rs"));
465 assert!(result.contains("Zebra.rs"));
466 }
467
468 #[test]
469 fn test_type_indicators() {
470 let temp_dir = TempDir::new().unwrap();
471
472 fs::create_dir(temp_dir.path().join("test_dir")).unwrap();
474 fs::write(temp_dir.path().join("test_file.txt"), "").unwrap();
476
477 let rt = tokio::runtime::Runtime::new().unwrap();
478 let result = rt
479 .block_on(async {
480 LsTool::ls_impl(
481 Path::new("."),
482 temp_dir.path().to_str().unwrap(),
483 false,
484 false,
485 None,
486 )
487 .await
488 })
489 .unwrap();
490
491 assert!(result.contains("test_dir/"));
493 assert!(result.contains("test_file.txt"));
495 assert!(!result.contains("test_file.txt/"));
496 }
497
498 #[test]
499 fn test_path_traversal_prevention() {
500 let rt = tokio::runtime::Runtime::new().unwrap();
501 let result = rt.block_on(async {
502 LsTool::ls_impl(Path::new("."), "../etc", false, false, None).await
503 });
504
505 assert!(result.is_err());
506 assert!(result.unwrap_err().contains("traversal"));
507 }
508
509 #[test]
510 fn test_nonexistent_path() {
511 let rt = tokio::runtime::Runtime::new().unwrap();
512 let result = rt.block_on(async {
513 LsTool::ls_impl(
514 Path::new("."),
515 "/nonexistent/path/12345",
516 false,
517 false,
518 None,
519 )
520 .await
521 });
522
523 assert!(result.is_err());
524 assert!(result.unwrap_err().contains("not found"));
525 }
526
527 #[test]
528 fn test_single_file() {
529 let temp_dir = TempDir::new().unwrap();
530 let file_path = temp_dir.path().join("single_file.txt");
531 fs::write(&file_path, "content").unwrap();
532
533 let rt = tokio::runtime::Runtime::new().unwrap();
534 let result = rt
535 .block_on(async {
536 LsTool::ls_impl(
537 Path::new("."),
538 file_path.to_str().unwrap(),
539 false,
540 false,
541 None,
542 )
543 .await
544 })
545 .unwrap();
546
547 assert!(result.contains("single_file.txt"));
548 }
549
550 #[test]
551 fn test_format_size() {
552 assert!(LsTool::format_size(500).contains("B"));
553 assert!(LsTool::format_size(1024).contains("KB"));
554 assert!(LsTool::format_size(1024 * 1024).contains("MB"));
555 }
556}