Skip to main content

rpi_tools/tools/
ls.rs

1//! Mirrors `packages/coding-agent/src/core/tools/ls.ts` — the built-in `ls`
2//! `AgentTool`. Lists directory entries (dotfiles included), sorted
3//! case-insensitively, with a `/` suffix on directories. Read-only — goes
4//! through the `FileSystem` trait so it runs against both `OsExecutionEnv` and
5//! `InMemoryExecutionEnv`.
6//!
7//! Divergence from TS (documented): the TS tool delegates to pluggable
8//! `LsOperations` (`readdir` + per-entry `stat`); the Rust port uses
9//! `FileSystem::list_dir`, which already returns `FileInfo` with a `kind`, so
10//! the per-entry stat call is folded into the listing (no second round-trip).
11//! Unstatable entries are therefore never silently skipped here — `list_dir`
12//! either lists them or the whole call fails. Output shape, sorting, limits,
13//! and truncation match TS exactly.
14
15use std::sync::Arc;
16
17use async_trait::async_trait;
18use rpi_agent::agent_tool::AgentTool;
19use rpi_agent::error::AgentError;
20use rpi_agent::types::{AgentToolResult, TextContentOrImage, ToolResultPartial};
21use rpi_ai::types::Tool;
22use schemars::JsonSchema;
23use serde::Deserialize;
24use tokio_util::sync::CancellationToken;
25
26use crate::env::ExecutionEnv;
27use crate::env::FileKind;
28use crate::path_utils::resolve_read_tool_path;
29use crate::tools::tool_context::ExecutionToolContext;
30use crate::truncate::{
31    format_size, truncate_head, TruncationOptions, TruncationResult, DEFAULT_MAX_BYTES,
32};
33
34/// Default max entries. Mirrors TS `ls.ts::DEFAULT_LIMIT`.
35const DEFAULT_LIMIT: u32 = 500;
36
37/// Input for the ls tool. Mirrors TS `LsToolInput`.
38#[derive(Debug, Clone, JsonSchema, Deserialize)]
39pub struct LsInput {
40    /// Directory to list (default: current directory).
41    #[serde(default)]
42    pub path: Option<String>,
43    /// Maximum number of entries to return (default 500).
44    #[serde(default)]
45    pub limit: Option<u32>,
46}
47
48/// Structured details for the ls tool. Mirrors TS `LsToolDetails`.
49#[derive(Debug, Clone, serde::Serialize)]
50pub struct LsToolDetails {
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub truncation: Option<TruncationResult>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub entry_limit_reached: Option<u32>,
55}
56
57/// Options for `create_ls_tool`. The TS tool carries an optional `operations`
58/// bag for remote backends; the Rust port has no pluggable backend (it always
59/// goes through the `FileSystem` trait), so this is an empty forward-compat
60/// seam kept for parity with `ReadToolOptions`.
61#[derive(Debug, Clone, Default)]
62pub struct LsToolOptions {}
63
64/// The built-in `ls` tool. Holds an `Arc<dyn ExecutionEnv>` (read-only view).
65pub struct LsTool {
66    schema: Tool,
67    env: Arc<dyn ExecutionEnv>,
68}
69
70impl LsTool {
71    fn schema() -> Tool {
72        let params = schemars::schema_for!(LsInput);
73        Tool {
74            name: "ls".to_string(),
75            description: format!(
76                "List directory contents. Returns entries sorted alphabetically, with a '/' suffix \
77                 for directories. Includes dotfiles. Output is truncated to {DEFAULT_LIMIT} entries \
78                 or {kb}KB (whichever is hit first).",
79                kb = DEFAULT_MAX_BYTES / 1024
80            ),
81            parameters: rpi_ai::types::Schema::new(serde_json::to_value(params).unwrap_or_default()),
82            constrained_sampling: None,
83        }
84    }
85}
86
87/// Build the `ls` tool from a context. Mirrors TS `createLsTool`.
88pub fn create_ls_tool(
89    context: &ExecutionToolContext,
90    options: Option<LsToolOptions>,
91) -> Arc<dyn AgentTool> {
92    let _ = options; // no options surface in v1 (forward-compat seam)
93    Arc::new(LsTool {
94        schema: LsTool::schema(),
95        env: context.env().clone(),
96    })
97}
98
99#[async_trait]
100impl AgentTool for LsTool {
101    fn schema(&self) -> &Tool {
102        &self.schema
103    }
104    fn label(&self) -> &str {
105        "ls"
106    }
107
108    async fn execute(
109        &self,
110        _tool_call_id: &str,
111        params: serde_json::Value,
112        signal: CancellationToken,
113        _on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
114    ) -> Result<AgentToolResult, AgentError> {
115        let input: LsInput = serde_json::from_value(params)
116            .map_err(|e| AgentError::Validation(format!("ls input invalid: {e}")))?;
117
118        let cancel = Some(&signal);
119        let effective_limit = input.limit.unwrap_or(DEFAULT_LIMIT).max(1) as usize;
120
121        // Resolve the directory path (read-only resolution + fuzzy variants).
122        let dir_path =
123            resolve_read_tool_path(&*self.env, input.path.as_deref().unwrap_or("."), cancel)
124                .await
125                .map_err(file_err_to_agent)?;
126
127        // Path-not-found.
128        if !self
129            .env
130            .exists(&dir_path, cancel)
131            .await
132            .map_err(file_err_to_agent)?
133        {
134            return Err(AgentError::Tool(format!("Path not found: {dir_path}")));
135        }
136        // Must be a directory.
137        let info = self
138            .env
139            .file_info(&dir_path, cancel)
140            .await
141            .map_err(file_err_to_agent)?;
142        if info.kind != FileKind::Directory {
143            return Err(AgentError::Tool(format!("Not a directory: {dir_path}")));
144        }
145
146        // Read entries. (TS wraps readdir in try/catch → "Cannot read directory";
147        // the env surfaces that as a FileError which we map here.)
148        let mut entries = self
149            .env
150            .list_dir(&dir_path, cancel)
151            .await
152            .map_err(file_err_to_agent)?;
153
154        // Sort alphabetically, case-insensitive. Mirrors TS
155        // `entries.sort((a,b) => a.toLowerCase().localeCompare(b.toLowerCase()))`.
156        entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
157
158        // Format with directory indicators.
159        let mut results: Vec<String> = Vec::new();
160        let mut entry_limit_reached = false;
161        for entry in &entries {
162            if results.len() >= effective_limit {
163                entry_limit_reached = true;
164                break;
165            }
166            let suffix = if entry.kind == FileKind::Directory {
167                "/"
168            } else {
169                ""
170            };
171            results.push(format!("{}{suffix}", entry.name));
172        }
173
174        if results.is_empty() {
175            return Ok(AgentToolResult {
176                content: vec![TextContentOrImage::text("(empty directory)")],
177                details: serde_json::Value::Null,
178                ..Default::default()
179            });
180        }
181
182        let raw_output = results.join("\n");
183        // Byte cap only — line cap disabled (entry count already capped).
184        let truncation = truncate_head(
185            &raw_output,
186            TruncationOptions {
187                max_lines: Some(usize::MAX),
188                max_bytes: None,
189            },
190        );
191        let was_truncated = truncation.truncated;
192        let mut output = truncation.content.clone();
193        let mut details = LsToolDetails {
194            truncation: None,
195            entry_limit_reached: None,
196        };
197        let mut notices: Vec<String> = Vec::new();
198        if entry_limit_reached {
199            notices.push(format!(
200                "{effective_limit} entries limit reached. Use limit={} for more",
201                effective_limit * 2
202            ));
203            details.entry_limit_reached = Some(effective_limit as u32);
204        }
205        if was_truncated {
206            notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
207            details.truncation = Some(truncation);
208        }
209        if !notices.is_empty() {
210            output.push_str(&format!("\n\n[{}]", notices.join(". ")));
211        }
212
213        let details_value = serde_json::to_value(&details).unwrap_or(serde_json::Value::Null);
214        Ok(AgentToolResult {
215            content: vec![TextContentOrImage::text(output)],
216            details: details_value,
217            ..Default::default()
218        })
219    }
220}
221
222/// Map a `FileError` into an `AgentError::Tool` (same shape as `read.rs`).
223fn file_err_to_agent(e: crate::error::FileError) -> AgentError {
224    AgentError::Tool(e.to_string())
225}