1use futures_util::future::BoxFuture;
4use serde_json::{Map, Value, json};
5use tokio_util::sync::CancellationToken;
6use yapi_agent::{Tool, UpdateSink};
7use yapi_types::event::ToolResult;
8use yapi_types::message::ToolDeclaration;
9
10use super::path::resolve_to_cwd;
11use super::truncate::DEFAULT_MAX_BYTES;
12use super::{
13 ToolEnv, capped_output, check_abort, js_number, node_error, plain_declaration, text_result,
14};
15use yapi_types::collate::locale_compare;
16
17const DEFAULT_LIMIT: f64 = 500.0;
18
19pub struct Ls {
21 env: ToolEnv,
22 declaration: ToolDeclaration,
23}
24
25impl Ls {
26 pub fn new(env: ToolEnv) -> Ls {
28 Ls {
29 env,
30 declaration: plain_declaration(
31 "ls",
32 format!(
33 "List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to {} entries or {}KB (whichever is hit first).",
34 DEFAULT_LIMIT,
35 DEFAULT_MAX_BYTES / 1024
36 ),
37 json!({"type":"object","properties":{
38 "path":{"type":"string","description":"Directory to list (default: current directory)"},
39 "limit":{"type":"number","description":"Maximum number of entries to return (default: 500)"}}}),
40 ),
41 }
42 }
43}
44
45impl Tool for Ls {
46 fn declaration(&self) -> &ToolDeclaration {
47 &self.declaration
48 }
49
50 fn execute(
51 &self,
52 _call_id: String,
53 args: Value,
54 cancel: CancellationToken,
55 _updates: UpdateSink,
56 ) -> BoxFuture<'_, Result<ToolResult, String>> {
57 Box::pin(async move {
58 check_abort(&cancel)?;
59 self.list(args)
60 })
61 }
62}
63
64impl Ls {
65 fn list(&self, args: Value) -> Result<ToolResult, String> {
66 let dir = args["path"]
67 .as_str()
68 .filter(|p| !p.is_empty())
69 .unwrap_or(".");
70 let path = resolve_to_cwd(dir, &self.env.cwd);
71 let limit = args["limit"].as_f64().unwrap_or(DEFAULT_LIMIT);
72 if !path.exists() {
73 return Err(format!("Path not found: {}", path.display()));
74 }
75 let metadata = std::fs::metadata(&path).map_err(|err| node_error(&err, "stat", &path))?;
76 if !metadata.is_dir() {
77 return Err(format!("Not a directory: {}", path.display()));
78 }
79 let mut entries: Vec<String> = std::fs::read_dir(&path)
80 .map_err(|err| {
81 format!(
82 "Cannot read directory: {}",
83 node_error(&err, "scandir", &path)
84 )
85 })?
86 .flatten()
87 .map(|entry| entry.file_name().to_string_lossy().into_owned())
88 .collect();
89 entries.sort_by(|a, b| locale_compare(&a.to_lowercase(), &b.to_lowercase()));
90 let mut results = Vec::new();
91 let mut limit_reached = false;
92 for entry in entries {
93 if results.len() as f64 >= limit {
94 limit_reached = true;
95 break;
96 }
97 let Ok(metadata) = std::fs::metadata(path.join(&entry)) else {
99 continue;
100 };
101 results.push(if metadata.is_dir() {
102 format!("{entry}/")
103 } else {
104 entry
105 });
106 }
107 if results.is_empty() {
108 return Ok(text_result("(empty directory)", None));
109 }
110 let mut details = Map::new();
111 let mut notices = Vec::new();
112 if limit_reached {
113 notices.push(format!(
114 "{} entries limit reached. Use limit={} for more",
115 js_number(limit),
116 js_number(limit * 2.0)
117 ));
118 details.insert("entryLimitReached".into(), json!(limit));
119 }
120 Ok(capped_output(
121 &results.join("\n"),
122 notices,
123 Vec::new(),
124 details,
125 ))
126 }
127}