opi_coding_agent/tool/
ls.rs1use 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 pub path: String,
17 #[serde(default)]
19 pub max_entries: Option<usize>,
20 #[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 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 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 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 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}