1use std::cmp::Ordering;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use futures::FutureExt as _;
13use futures::future::BoxFuture;
14use pi_agent::{AgentTool, AgentToolResult, ToolError, ToolUpdates};
15use pi_ai::ToolResultContent;
16use pi_ai::types::TextContent;
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use serde_json::{Map, Value, json};
20use tokio_util::sync::CancellationToken;
21
22use super::{
23 DEFAULT_MAX_BYTES, PathResolveError, TruncationOptions, TruncationResult, format_size,
24 resolve_to_cwd, truncate_head,
25};
26
27const DEFAULT_LIMIT: usize = 500;
29
30#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
32pub struct LsToolInput {
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 #[schemars(description = "Directory to list (default: current directory)")]
36 pub path: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 #[schemars(description = "Maximum number of entries to return (default: 500)")]
40 pub limit: Option<f64>,
41}
42
43#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
45#[serde(rename_all = "camelCase")]
46pub struct LsToolDetails {
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub truncation: Option<TruncationResult>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub entry_limit_reached: Option<usize>,
53}
54
55#[derive(Clone, Debug)]
57pub struct LsToolOptions {
58 pub cwd: PathBuf,
60}
61
62impl LsToolOptions {
63 #[must_use]
65 pub fn new(cwd: impl Into<PathBuf>) -> Self {
66 Self { cwd: cwd.into() }
67 }
68}
69
70#[derive(Clone, Debug)]
72pub struct LsTool {
73 cwd: PathBuf,
74 parameters: Value,
75 description: String,
76}
77
78impl LsTool {
79 #[must_use]
81 pub fn new(cwd: impl Into<PathBuf>) -> Self {
82 Self::with_options(LsToolOptions::new(cwd))
83 }
84
85 #[must_use]
87 pub fn with_options(options: LsToolOptions) -> Self {
88 let description = format!(
89 "List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to {DEFAULT_LIMIT} entries or {}KB (whichever is hit first).",
90 DEFAULT_MAX_BYTES / 1024
91 );
92 Self {
93 cwd: options.cwd,
94 parameters: ls_parameters_schema(),
95 description,
96 }
97 }
98
99 #[must_use]
101 pub fn parameters_schema() -> Value {
102 ls_parameters_schema()
103 }
104
105 pub fn parse_input(args: &Map<String, Value>) -> Result<LsToolInput, ToolError> {
111 serde_json::from_value(Value::Object(args.clone()))
112 .map_err(|error| ToolError::new(format!("Ls tool input is invalid. {error}")))
113 }
114}
115
116impl AgentTool for LsTool {
117 fn name(&self) -> &'static str {
118 "ls"
119 }
120
121 fn label(&self) -> &'static str {
122 "ls"
123 }
124
125 fn description(&self) -> &str {
126 &self.description
127 }
128
129 fn parameters(&self) -> &Value {
130 &self.parameters
131 }
132
133 fn validate_arguments(
134 &self,
135 args: &Map<String, Value>,
136 ) -> Result<Map<String, Value>, ToolError> {
137 let _ = Self::parse_input(args)?;
138 Ok(args.clone())
139 }
140
141 #[allow(clippy::too_many_lines)]
142 fn execute(
143 &self,
144 _tool_call_id: &str,
145 args: Map<String, Value>,
146 cancel: CancellationToken,
147 _updates: ToolUpdates,
148 ) -> BoxFuture<'static, Result<AgentToolResult, ToolError>> {
149 let cwd = self.cwd.clone();
150 async move {
151 throw_if_cancelled(&cancel)?;
152 let input = LsTool::parse_input(&args)?;
153 let path_arg = input.path.as_deref().unwrap_or(".");
154 let dir_path = resolve_to_cwd(path_arg, cwd.to_string_lossy().as_ref())
155 .map_err(|error| path_error(&error))?;
156 let effective_limit = effective_limit(input.limit, DEFAULT_LIMIT);
157 throw_if_cancelled(&cancel)?;
158
159 let meta = tokio::fs::metadata(&dir_path).await;
160 match meta {
161 Ok(meta) if meta.is_dir() => {}
162 Ok(_) => {
163 return Err(ToolError::new(format!("Not a directory: {dir_path}")));
164 }
165 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
166 return Err(ToolError::new(format!("Path not found: {dir_path}")));
167 }
168 Err(error) => {
169 return Err(ToolError::new(format!("Cannot read directory: {error}")));
170 }
171 }
172 throw_if_cancelled(&cancel)?;
173
174 let mut names = Vec::new();
175 let mut read_dir = tokio::fs::read_dir(&dir_path)
176 .await
177 .map_err(|error| ToolError::new(format!("Cannot read directory: {error}")))?;
178 loop {
179 throw_if_cancelled(&cancel)?;
180 match read_dir.next_entry().await {
181 Ok(Some(entry)) => {
182 let name = entry.file_name();
183 let name = name.to_string_lossy().into_owned();
184 names.push(name);
185 }
186 Ok(None) => break,
187 Err(error) => {
188 return Err(ToolError::new(format!("Cannot read directory: {error}")));
189 }
190 }
191 }
192
193 names.sort_by(|a, b| compare_case_insensitive(a, b));
194
195 let mut results = Vec::new();
196 let mut entry_limit_reached = false;
197 for name in names {
198 throw_if_cancelled(&cancel)?;
199 if results.len() >= effective_limit {
200 entry_limit_reached = true;
201 break;
202 }
203 let full_path = Path::new(&dir_path).join(&name);
204 let suffix = match tokio::fs::metadata(&full_path).await {
205 Ok(meta) if meta.is_dir() => "/",
206 Ok(_) => "",
207 Err(_) => continue,
208 };
209 results.push(format!("{name}{suffix}"));
210 }
211
212 if results.is_empty() {
213 return Ok(text_result("(empty directory)", None));
214 }
215
216 let raw_output = results.join("\n");
217 let truncation = truncate_head(
218 &raw_output,
219 TruncationOptions {
220 max_lines: Some(usize::MAX),
221 max_bytes: Some(DEFAULT_MAX_BYTES),
222 },
223 );
224 let mut output = truncation.content.clone();
225 let mut details = LsToolDetails::default();
226 let mut notices = Vec::new();
227 if entry_limit_reached {
228 notices.push(format!(
229 "{effective_limit} entries limit reached. Use limit={} for more",
230 effective_limit.saturating_mul(2)
231 ));
232 details.entry_limit_reached = Some(effective_limit);
233 }
234 if truncation.truncated {
235 notices.push(format!(
236 "{} limit reached",
237 format_size(DEFAULT_MAX_BYTES as u64)
238 ));
239 details.truncation = Some(truncation);
240 }
241 if !notices.is_empty() {
242 output.push_str("\n\n[");
243 output.push_str(¬ices.join(". "));
244 output.push(']');
245 }
246
247 let details = if details.entry_limit_reached.is_some() || details.truncation.is_some() {
248 Some(details)
249 } else {
250 None
251 };
252 Ok(text_result(output, details))
253 }
254 .boxed()
255 }
256}
257
258fn compare_case_insensitive(a: &str, b: &str) -> Ordering {
259 let a_lower = a.to_lowercase();
262 let b_lower = b.to_lowercase();
263 match a_lower.cmp(&b_lower) {
264 Ordering::Equal => a.cmp(b),
265 other => other,
266 }
267}
268
269fn effective_limit(limit: Option<f64>, default: usize) -> usize {
270 match limit {
271 Some(value) if value.is_finite() => {
272 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
273 let as_i = value as i64;
274 if as_i < 1 {
275 1
276 } else {
277 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
278 {
279 as_i as usize
280 }
281 }
282 }
283 _ => default,
284 }
285}
286
287fn text_result(text: impl Into<String>, details: Option<LsToolDetails>) -> AgentToolResult {
288 AgentToolResult {
289 content: vec![ToolResultContent::Text(TextContent::new(text.into()))],
290 details: details_value(details),
291 added_tool_names: None,
292 terminate: None,
293 }
294}
295
296fn details_value(details: Option<LsToolDetails>) -> Value {
297 details.map_or(Value::Null, |details| {
298 serde_json::to_value(details).unwrap_or_else(|_| json!({}))
299 })
300}
301
302fn ls_parameters_schema() -> Value {
303 normalize_tool_schema(schemars::schema_for!(LsToolInput))
304}
305
306fn normalize_tool_schema(schema: schemars::Schema) -> Value {
307 let mut value = serde_json::to_value(schema).unwrap_or_else(|_| Value::Object(Map::new()));
308 if let Value::Object(map) = &mut value {
309 map.remove("$schema");
310 map.remove("title");
311 map.remove("description");
312 map.remove("additionalProperties");
313 if let Some(Value::Array(required)) = map.get("required")
315 && required.is_empty()
316 {
317 map.remove("required");
318 }
319 normalize_schema_node(map);
320 }
321 value
322}
323
324fn normalize_schema_node(map: &mut Map<String, Value>) {
325 map.remove("format");
326 if let Some(Value::Array(types)) = map.get("type").cloned() {
329 let non_null: Vec<Value> = types
330 .into_iter()
331 .filter(|t| t.as_str() != Some("null"))
332 .collect();
333 if non_null.len() == 1 {
334 map.insert("type".to_owned(), non_null[0].clone());
335 } else if !non_null.is_empty() {
336 map.insert("type".to_owned(), Value::Array(non_null));
337 }
338 }
339 let keys: Vec<String> = map.keys().cloned().collect();
340 for key in keys {
341 match map.get_mut(&key) {
342 Some(Value::Object(child)) => normalize_schema_node(child),
343 Some(Value::Array(items)) => {
344 for item in items {
345 if let Value::Object(child) = item {
346 normalize_schema_node(child);
347 }
348 }
349 }
350 _ => {}
351 }
352 }
353}
354
355fn throw_if_cancelled(cancel: &CancellationToken) -> Result<(), ToolError> {
356 if cancel.is_cancelled() {
357 Err(ToolError::new("Operation aborted"))
358 } else {
359 Ok(())
360 }
361}
362
363fn path_error(error: &PathResolveError) -> ToolError {
364 ToolError::new(error.to_string())
365}
366
367#[must_use]
369pub fn create_ls_tool(cwd: impl Into<PathBuf>) -> Arc<dyn AgentTool> {
370 Arc::new(LsTool::new(cwd))
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use std::fs;
377 use std::os::unix::fs::PermissionsExt;
378
379 use serde_json::json;
380 use tempfile::tempdir;
381
382 fn fixture_schema() -> Result<Value, serde_json::Error> {
383 let text = include_str!("../../../tests/fixtures/tool-schemas/ls.json");
384 serde_json::from_str(text)
385 }
386
387 fn json_map(value: &Value) -> Result<Map<String, Value>, ToolError> {
388 value
389 .as_object()
390 .cloned()
391 .ok_or_else(|| ToolError::new("test arguments must be a JSON object"))
392 }
393
394 fn text_of(result: &AgentToolResult) -> String {
395 match result.content.first() {
396 Some(ToolResultContent::Text(text)) => text.text.to_string(),
397 _ => String::new(),
398 }
399 }
400
401 async fn run(tool: &LsTool, args: &Value) -> Result<AgentToolResult, ToolError> {
402 tool.execute(
403 "t",
404 json_map(args)?,
405 CancellationToken::new(),
406 ToolUpdates::noop(),
407 )
408 .await
409 }
410
411 #[test]
412 fn schema_matches_typebox_fixture() -> Result<(), Box<dyn std::error::Error>> {
413 assert_eq!(LsTool::parameters_schema(), fixture_schema()?);
414 Ok(())
415 }
416
417 #[tokio::test]
418 async fn lists_dotfiles_and_directory_suffix() -> Result<(), Box<dyn std::error::Error>> {
419 let dir = tempdir()?;
420 fs::write(dir.path().join(".hidden-file"), "secret")?;
421 fs::create_dir(dir.path().join(".hidden-dir"))?;
422 fs::write(dir.path().join("plain.txt"), "x")?;
423 fs::create_dir(dir.path().join("subdir"))?;
424
425 let tool = LsTool::new(dir.path());
426 let result = run(&tool, &json!({})).await?;
427 let text = text_of(&result);
428 let lines: Vec<&str> = text.lines().collect();
429 assert!(lines.contains(&".hidden-file"));
430 assert!(lines.contains(&".hidden-dir/"));
431 assert!(lines.contains(&"plain.txt"));
432 assert!(lines.contains(&"subdir/"));
433 Ok(())
434 }
435
436 #[tokio::test]
437 async fn sorts_case_insensitively() -> Result<(), Box<dyn std::error::Error>> {
438 let dir = tempdir()?;
439 for name in ["b.txt", "A.txt", "c.txt", "a.txt"] {
440 fs::write(dir.path().join(name), "x")?;
441 }
442 let tool = LsTool::new(dir.path());
443 let text = text_of(&run(&tool, &json!({})).await?);
444 let lines: Vec<&str> = text.lines().collect();
445 let mut expected = vec!["A.txt", "a.txt", "b.txt", "c.txt"];
446 expected.sort_by(|a, b| compare_case_insensitive(a, b));
447 assert_eq!(lines, expected);
448 Ok(())
449 }
450
451 #[tokio::test]
452 async fn skips_unstatable_entries() -> Result<(), Box<dyn std::error::Error>> {
453 let dir = tempdir()?;
454 let keep = dir.path().join("keep.txt");
455 fs::write(&keep, "ok")?;
456 let trap = dir.path().join("trap");
457 fs::create_dir(&trap)?;
458 let dangling = dir.path().join("dangling");
460 #[cfg(unix)]
461 {
462 std::os::unix::fs::symlink(dir.path().join("missing-target"), &dangling)?;
463 }
464
465 let tool = LsTool::new(dir.path());
466 let text = text_of(&run(&tool, &json!({})).await?);
467 assert!(text.contains("keep.txt"));
468 assert!(text.contains("trap/"));
469 assert!(
470 !text
471 .lines()
472 .any(|line| line == "dangling" || line == "dangling/")
473 );
474 Ok(())
475 }
476
477 #[tokio::test]
478 async fn entry_limit_notice() -> Result<(), Box<dyn std::error::Error>> {
479 let dir = tempdir()?;
480 for i in 0..5 {
481 fs::write(dir.path().join(format!("f{i}.txt")), "x")?;
482 }
483 let tool = LsTool::new(dir.path());
484 let result = run(&tool, &json!({"limit": 2})).await?;
485 let text = text_of(&result);
486 assert_eq!(
487 text.lines()
488 .filter(|l| !l.is_empty() && !l.starts_with('['))
489 .count(),
490 2
491 );
492 assert!(text.contains("[2 entries limit reached. Use limit=4 for more]"));
493 assert_eq!(result.details.get("entryLimitReached"), Some(&json!(2)));
494 Ok(())
495 }
496
497 #[tokio::test]
498 async fn empty_directory_message() -> Result<(), Box<dyn std::error::Error>> {
499 let dir = tempdir()?;
500 let tool = LsTool::new(dir.path());
501 let text = text_of(&run(&tool, &json!({})).await?);
502 assert_eq!(text, "(empty directory)");
503 Ok(())
504 }
505
506 #[tokio::test]
507 async fn missing_and_not_directory_errors() -> Result<(), Box<dyn std::error::Error>> {
508 let dir = tempdir()?;
509 fs::write(dir.path().join("file.txt"), "x")?;
510 let tool = LsTool::new(dir.path());
511 let result = run(&tool, &json!({"path": "nope"})).await;
512 let Err(missing) = result else {
513 return Err("missing path unexpectedly succeeded".into());
514 };
515 assert!(missing.message().starts_with("Path not found:"));
516 let result = run(&tool, &json!({"path": "file.txt"})).await;
517 let Err(not_dir) = result else {
518 return Err("file path unexpectedly listed as a directory".into());
519 };
520 assert!(not_dir.message().starts_with("Not a directory:"));
521 Ok(())
522 }
523
524 #[tokio::test]
525 async fn cancellation_aborts() -> Result<(), Box<dyn std::error::Error>> {
526 let dir = tempdir()?;
527 let tool = LsTool::new(dir.path());
528 let cancel = CancellationToken::new();
529 cancel.cancel();
530 let result = tool
531 .execute("t", Map::new(), cancel, ToolUpdates::noop())
532 .await;
533 let Err(err) = result else {
534 return Err("cancelled ls unexpectedly succeeded".into());
535 };
536 assert_eq!(err.message(), "Operation aborted");
537 Ok(())
538 }
539
540 #[tokio::test]
541 async fn unique_tmp_names_avoid_collision() {
542 let _ = fs::Permissions::from_mode(0o644);
544 }
545}