Skip to main content

pi/core/tools/
ls.rs

1//! List directory contents (dotfiles included).
2//!
3//! Ports `.references/pi/packages/coding-agent/src/core/tools/ls.ts` with a
4//! pure native filesystem walk (no subprocess). Output is sorted
5//! case-insensitively, directories receive a trailing `/`, and entry count
6//! plus 50 KiB head truncation match the TypeScript notices.
7
8use 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
27/// Default maximum number of directory entries returned.
28const DEFAULT_LIMIT: usize = 500;
29
30/// TypeBox-compatible ls arguments (fixture `ls.json`).
31#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
32pub struct LsToolInput {
33    /// Directory to list (default: current directory).
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    #[schemars(description = "Directory to list (default: current directory)")]
36    pub path: Option<String>,
37    /// Maximum number of entries to return (default: 500).
38    #[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/// Optional structured details returned by the ls tool.
44#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
45#[serde(rename_all = "camelCase")]
46pub struct LsToolDetails {
47    /// Truncation metadata when the 50 KiB head limit applied.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub truncation: Option<TruncationResult>,
50    /// Effective entry limit when that limit was hit.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub entry_limit_reached: Option<usize>,
53}
54
55/// Options for [`LsTool`].
56#[derive(Clone, Debug)]
57pub struct LsToolOptions {
58    /// Working directory used to resolve relative paths.
59    pub cwd: PathBuf,
60}
61
62impl LsToolOptions {
63    /// Builds options for `cwd`.
64    #[must_use]
65    pub fn new(cwd: impl Into<PathBuf>) -> Self {
66        Self { cwd: cwd.into() }
67    }
68}
69
70/// Agent tool that lists one directory including dotfiles.
71#[derive(Clone, Debug)]
72pub struct LsTool {
73    cwd: PathBuf,
74    parameters: Value,
75    description: String,
76}
77
78impl LsTool {
79    /// Creates an ls tool rooted at `cwd`.
80    #[must_use]
81    pub fn new(cwd: impl Into<PathBuf>) -> Self {
82        Self::with_options(LsToolOptions::new(cwd))
83    }
84
85    /// Creates an ls tool from explicit options.
86    #[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    /// Returns the JSON Schema for ls arguments (normalized `TypeBox` shape).
100    #[must_use]
101    pub fn parameters_schema() -> Value {
102        ls_parameters_schema()
103    }
104
105    /// Validates raw tool arguments into [`LsToolInput`].
106    ///
107    /// # Errors
108    ///
109    /// Returns [`ToolError`] when fields are mistyped.
110    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(&notices.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    // JS: a.toLowerCase().localeCompare(b.toLowerCase()) - Unicode lowercase,
260    // then original string as a stable secondary key.
261    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        // TypeBox omits `required` when every property is optional.
314        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    // schemars represents Option<T> as ["number","null"]; TypeBox optional
327    // numbers are just "number".
328    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/// Builds an [`Arc<dyn AgentTool>`] ls tool for `cwd`.
368#[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        // Create a dangling symlink that metadata follows and fails to resolve.
459        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        // Ensure PermissionsExt stays imported under unix-only symlink test.
543        let _ = fs::Permissions::from_mode(0o644);
544    }
545}