Skip to main content

opi_coding_agent/tool/
ls.rs

1use std::future::Future;
2use std::path::PathBuf;
3use std::pin::Pin;
4
5use opi_agent::tool::{ExecutionMode, Tool, ToolError, ToolResult};
6use opi_ai::message::{OutputContent, ToolDef};
7use schemars::JsonSchema;
8use serde::Deserialize;
9use tokio_util::sync::CancellationToken;
10
11const DEFAULT_MAX_ENTRIES: usize = 200;
12
13#[derive(Debug, Deserialize, JsonSchema)]
14pub struct LsArgs {
15    /// Directory path to list (relative to workspace root, use "." for root).
16    pub path: String,
17    /// Maximum number of entries to return. Defaults to 200.
18    #[serde(default)]
19    pub max_entries: Option<usize>,
20    /// Maximum recursion depth. 0 lists only the specified directory, 1 includes
21    /// immediate children and their types, etc. Defaults to 0 (flat listing).
22    #[serde(default)]
23    pub max_depth: Option<usize>,
24}
25
26pub struct LsTool {
27    workspace_root: PathBuf,
28    schema: serde_json::Value,
29}
30
31impl LsTool {
32    pub fn new(workspace_root: PathBuf) -> Self {
33        let schema = schemars::schema_for!(LsArgs);
34        Self {
35            workspace_root,
36            schema: serde_json::to_value(&schema).unwrap_or_default(),
37        }
38    }
39}
40
41impl Tool for LsTool {
42    fn definition(&self) -> ToolDef {
43        ToolDef {
44            name: "ls".into(),
45            description: "List directory contents with bounded output. Entries are sorted deterministically. Directories are indicated with a trailing /.".into(),
46            input_schema: self.schema.clone(),
47        }
48    }
49
50    fn execute(
51        &self,
52        _call_id: &str,
53        arguments: serde_json::Value,
54        _signal: CancellationToken,
55        _on_update: Option<opi_agent::tool::UpdateCallback>,
56    ) -> Pin<Box<dyn Future<Output = Result<ToolResult, ToolError>> + Send>> {
57        let args: LsArgs = match serde_json::from_value(arguments) {
58            Ok(a) => a,
59            Err(e) => {
60                return Box::pin(async move {
61                    Ok(ToolResult {
62                        content: vec![OutputContent::Text {
63                            text: format!("invalid arguments: {e}"),
64                        }],
65                        details: None,
66                        is_error: true,
67                        terminate: false,
68                    })
69                });
70            }
71        };
72        let workspace_root = self.workspace_root.clone();
73        let max_entries = args.max_entries.unwrap_or(DEFAULT_MAX_ENTRIES);
74        let max_depth = args.max_depth.unwrap_or(0);
75        let path_arg = args.path;
76
77        Box::pin(async move {
78            // Resolve target directory within workspace
79            let target = if path_arg == "." {
80                workspace_root.clone()
81            } else {
82                match super::validate_workspace_path(&workspace_root, &path_arg) {
83                    Ok(p) => p,
84                    Err(msg) => {
85                        return Ok(ToolResult {
86                            content: vec![OutputContent::Text { text: msg }],
87                            details: None,
88                            is_error: true,
89                            terminate: false,
90                        });
91                    }
92                }
93            };
94
95            if !target.exists() {
96                return Ok(ToolResult {
97                    content: vec![OutputContent::Text {
98                        text: format!("path '{}' does not exist", path_arg),
99                    }],
100                    details: None,
101                    is_error: true,
102                    terminate: false,
103                });
104            }
105
106            if !target.is_dir() {
107                return Ok(ToolResult {
108                    content: vec![OutputContent::Text {
109                        text: format!("'{}' is not a directory", path_arg),
110                    }],
111                    details: None,
112                    is_error: true,
113                    terminate: false,
114                });
115            }
116
117            // Read and sort directory entries
118            let mut entries: Vec<Entry> = Vec::new();
119            collect_entries(&workspace_root, &target, &mut entries, 0, max_depth);
120
121            entries.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
122
123            let total_entries = entries.len();
124            let truncated = total_entries > max_entries;
125            entries.truncate(max_entries);
126
127            let mut lines: Vec<String> = entries
128                .iter()
129                .map(|e| {
130                    if e.is_dir {
131                        format!("{}/", e.relative_path)
132                    } else {
133                        e.relative_path.clone()
134                    }
135                })
136                .collect();
137
138            if truncated {
139                lines.push(format!(
140                    "... (truncated, {} entries omitted)",
141                    total_entries - max_entries
142                ));
143            }
144
145            let text = lines.join("\n");
146            let details = serde_json::json!({
147                "workspace_root": workspace_root.to_string_lossy(),
148                "path": path_arg,
149                "entry_count": entries.len(),
150                "total_entries": total_entries,
151                "truncated": truncated,
152            });
153
154            Ok(ToolResult {
155                content: vec![OutputContent::Text { text }],
156                details: Some(details),
157                is_error: false,
158                terminate: false,
159            })
160        })
161    }
162
163    fn execution_mode(&self) -> ExecutionMode {
164        ExecutionMode::Parallel
165    }
166}
167
168struct Entry {
169    relative_path: String,
170    is_dir: bool,
171}
172
173fn collect_entries(
174    workspace_root: &std::path::Path,
175    dir: &std::path::Path,
176    entries: &mut Vec<Entry>,
177    current_depth: usize,
178    max_depth: usize,
179) {
180    let read_dir = match std::fs::read_dir(dir) {
181        Ok(rd) => rd,
182        Err(_) => return,
183    };
184
185    for entry in read_dir.flatten() {
186        let path = entry.path();
187        let relative = path
188            .strip_prefix(workspace_root)
189            .unwrap_or(&path)
190            .to_string_lossy()
191            .into_owned();
192        let is_dir = path.is_dir();
193
194        // Skip gitignored entries
195        if is_gitignored(workspace_root, &path) {
196            continue;
197        }
198
199        entries.push(Entry {
200            relative_path: relative.clone(),
201            is_dir,
202        });
203
204        if is_dir && current_depth < max_depth {
205            collect_entries(workspace_root, &path, entries, current_depth + 1, max_depth);
206        }
207    }
208}
209
210fn is_gitignored(workspace_root: &std::path::Path, path: &std::path::Path) -> bool {
211    let mut builder = ignore::gitignore::GitignoreBuilder::new(workspace_root);
212    // Load .gitignore if present
213    let gitignore_path = workspace_root.join(".gitignore");
214    if gitignore_path.exists() {
215        let _ = builder.add(&gitignore_path);
216    }
217    match builder.build() {
218        Ok(gi) => {
219            let relative = path.strip_prefix(workspace_root).unwrap_or(path);
220            gi.matched(relative, path.is_dir()).is_ignore()
221        }
222        Err(_) => false,
223    }
224}