Skip to main content

rig_tools/
list.rs

1//! The `list` tool.
2
3use 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/// Lists the files under a directory, as far down as asked, honouring
20/// `.gitignore` ([`Listing::Visible`], or [`Listing::Hidden`] when asked or
21/// when the glob names a hidden path): one path per line below the listed directory, and each
22/// directory deeper than the listing goes once, as `dir/`. Publishes what
23/// it listed ([`FilesListed`]) and, when the list was cut, [`OutputCut`].
24#[derive(Clone, Debug)]
25pub struct List {
26    /// Where the files are listed.
27    pub workspace: Arc<dyn Workspace>,
28    /// The directory a relative path is listed from; `None` is the
29    /// workspace's working directory.
30    pub base: Option<PathBuf>,
31    /// Whether a path that resolves outside `base` is refused, before
32    /// anything is listed.
33    pub confine: bool,
34}
35
36impl Default for List {
37    /// Lists this machine's files, from this process's working directory.
38    fn default() -> Self {
39        Self {
40            workspace: Arc::new(Local),
41            base: None,
42            confine: false,
43        }
44    }
45}
46
47/// Arguments of [`List`].
48#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, JsonSchema)]
49#[serde(deny_unknown_fields)]
50pub struct ListArgs {
51    /// The directory to list; default the current directory.
52    pub path: Option<String>,
53    /// How many levels down to list: 1 lists the directory's own entries;
54    /// default every level.
55    pub depth: Option<usize>,
56    /// Only files matching this glob, such as `*.rs`.
57    pub glob: Option<String>,
58    /// Also hidden files and directories (named with a leading `.`), such
59    /// as `.github/`; files git ignores stay out.
60    #[serde(default)]
61    pub hidden: bool,
62}
63
64impl ListArgs {
65    /// List `path`.
66    pub fn new(path: impl Into<String>) -> Self {
67        Self {
68            path: Some(path.into()),
69            ..Self::default()
70        }
71    }
72
73    /// The same listing, `depth` levels down.
74    pub fn depth(mut self, depth: usize) -> Self {
75        self.depth = Some(depth);
76        self
77    }
78
79    /// The same listing, only of files matching `glob`.
80    pub fn glob(mut self, glob: impl Into<String>) -> Self {
81        self.glob = Some(glob.into());
82        self
83    }
84
85    /// The same listing, hidden files included.
86    pub fn hidden(mut self) -> Self {
87        self.hidden = true;
88        self
89    }
90
91    /// Which files it lists.
92    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/// What a [`List`] call listed, as it published it beside the text.
102#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
103pub struct FilesListed {
104    /// The listed directory, [resolved](Workspace).
105    pub root: String,
106    /// The entries shown, below `root`, in order: files, and directories
107    /// deeper than the listing with a trailing `/`.
108    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
169/// Lists `root` in `workspace` as [`List`] does: the text, what it shows,
170/// and how it was cut.
171fn 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;