1use std::collections::BTreeSet;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use rig_core::completion::ToolAnnotations;
8use rig_core::tool::{
9 ContextValue, KeptEnd, OutputCut, Scope, Tool, ToolContext, ToolExecutionError, Touches,
10 args_schema,
11};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15use crate::fs::listed;
16use crate::workspace::{Listing, Local, Workspace};
17use crate::{MAX_BYTES, MAX_LINES, blocking};
18
19#[derive(Clone, Debug)]
25pub struct List {
26 pub workspace: Arc<dyn Workspace>,
28 pub base: Option<PathBuf>,
31 pub confine: bool,
34}
35
36impl Default for List {
37 fn default() -> Self {
39 Self {
40 workspace: Arc::new(Local),
41 base: None,
42 confine: false,
43 }
44 }
45}
46
47#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, JsonSchema)]
49#[serde(deny_unknown_fields)]
50pub struct ListArgs {
51 pub path: Option<String>,
53 pub depth: Option<usize>,
56 pub glob: Option<String>,
58 #[serde(default)]
61 pub hidden: bool,
62}
63
64impl ListArgs {
65 pub fn new(path: impl Into<String>) -> Self {
67 Self {
68 path: Some(path.into()),
69 ..Self::default()
70 }
71 }
72
73 pub fn depth(mut self, depth: usize) -> Self {
75 self.depth = Some(depth);
76 self
77 }
78
79 pub fn glob(mut self, glob: impl Into<String>) -> Self {
81 self.glob = Some(glob.into());
82 self
83 }
84
85 pub fn hidden(mut self) -> Self {
87 self.hidden = true;
88 self
89 }
90
91 pub fn listing(&self) -> Listing {
93 match self.hidden {
94 true => Listing::Hidden,
95 false => Listing::Visible,
96 }
97 .for_glob(self.glob.as_deref())
98 }
99}
100
101#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
103pub struct FilesListed {
104 pub root: String,
106 pub entries: Vec<String>,
109}
110
111impl ContextValue for FilesListed {
112 const KEY: &'static str = "files_listed";
113}
114
115impl Tool for List {
116 const NAME: &'static str = "list";
117 type Args = ListArgs;
118 type Output = String;
119 type Error = ToolExecutionError;
120
121 fn description(&self) -> String {
122 format!(
123 "List the files under a directory, sorted, one path per line below it. Skips files \
124 ignored by git, target directories and, unless hidden is set or the glob names \
125 one, hidden files. With depth, directories below that depth are listed once as \
126 `dir/`. At most {MAX_LINES} lines or {} KB. Use it to see what files a directory \
127 holds, not `ls` or `find` in `shell`.",
128 MAX_BYTES / 1024
129 )
130 }
131
132 fn parameters(&self) -> serde_json::Value {
133 args_schema::<ListArgs>()
134 }
135
136 fn annotations(&self) -> ToolAnnotations {
137 ToolAnnotations::default()
138 .with_read_only(true)
139 .with_open_world(false)
140 }
141
142 fn footprint(&self, args: &ListArgs) -> Touches {
143 Touches::Reads(Scope::Paths(vec![
144 self.workspace
145 .resolve(self.base.as_deref(), args.path.as_deref().unwrap_or(".")),
146 ]))
147 }
148
149 async fn call(
150 &self,
151 context: &mut ToolContext,
152 args: ListArgs,
153 ) -> Result<String, ToolExecutionError> {
154 let root = self.workspace.place(
155 self.base.as_deref(),
156 self.confine,
157 args.path.as_deref().unwrap_or("."),
158 )?;
159 let workspace = self.workspace.clone();
160 let (text, listed, cut) = blocking(move || list(&*workspace, &root, &args)).await?;
161 context.insert_result(listed)?;
162 if let Some(cut) = cut {
163 context.insert_result(cut)?;
164 }
165 Ok(text)
166 }
167}
168
169fn list(
172 workspace: &dyn Workspace,
173 root: &Path,
174 args: &ListArgs,
175) -> Result<(String, FilesListed, Option<OutputCut>), ToolExecutionError> {
176 let files = listed(workspace, root, args.glob.as_deref(), args.listing())?;
177 let depth = args.depth.unwrap_or(usize::MAX).max(1);
178 let entries: BTreeSet<String> = files
179 .iter()
180 .filter_map(|file| {
181 let below = file.strip_prefix(root).ok()?;
182 let parts: Vec<_> = below.iter().collect();
183 Some(match parts.get(..depth) {
184 Some(kept) if kept.len() < parts.len() => {
185 format!("{}/", kept.iter().collect::<PathBuf>().display())
186 }
187 _ if below.as_os_str().is_empty() => file.to_string_lossy().into_owned(),
188 _ => below.to_string_lossy().into_owned(),
189 })
190 })
191 .collect();
192 let total = entries.len();
193 let mut text = String::new();
194 let mut kept = Vec::new();
195 for entry in entries {
196 if kept.len() == MAX_LINES || text.len() + entry.len() + 1 > MAX_BYTES {
197 break;
198 }
199 text.push_str(&entry);
200 text.push('\n');
201 kept.push(entry);
202 }
203 let cut = (kept.len() < total).then(|| {
204 text.push_str(&format!(
205 "[{} more entries; narrow the listing with path, depth or glob]\n",
206 total - kept.len()
207 ));
208 OutputCut {
209 kept: KeptEnd::Start,
210 kept_lines: kept.len(),
211 total_lines: total,
212 spill: None,
213 }
214 });
215 if total == 0 {
216 text.push_str("No files.\n");
217 }
218 let listed = FilesListed {
219 root: root.to_string_lossy().into_owned(),
220 entries: kept,
221 };
222 Ok((text, listed, cut))
223}
224
225#[cfg(test)]
226mod tests;