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