Skip to main content

rig_tools/
workspace.rs

1//! The [`Workspace`] the tools act on: the disk they read and write and
2//! where their commands run. [`Local`] is this machine; another, such as
3//! a [`Remote`](crate::remote::Remote) reached over SSH, is a type of its
4//! own that every tool and the shell then go through.
5
6use std::ffi::{OsStr, OsString};
7use std::fmt;
8use std::fs::{self, File};
9use std::io::{self, ErrorKind, Read as _};
10use std::path::{Component, Path, PathBuf};
11use std::sync::Arc;
12use std::time::SystemTime;
13
14use ignore::WalkBuilder;
15use ignore::overrides::OverrideBuilder;
16use rig_core::tool::{ContentHash, ToolExecutionError, content_hash};
17
18use crate::fs::{Previous, io_error, write_atomic};
19use crate::process::{Launcher, Process, Spawn};
20pub use crate::watch::Watch;
21use crate::{MAX_BYTES, MAX_FILE_BYTES};
22
23/// The disk and the commands the tools act on: this machine's ([`Local`]),
24/// or another, such as one reached over SSH. Paths are the workspace's own:
25/// a tool's base, the paths it resolves and the facts it publishes
26/// ([`FilesRead`](rig_core::tool::FilesRead),
27/// [`FilesChanged`](rig_core::tool::FilesChanged)) name files in it.
28///
29/// Synchronous, as the rest of rig-tools: the tools call it inside
30/// [`blocking`](crate::blocking). Code on a thread that must not wait, such
31/// as an app's main thread, calls it inline only when [`host`](Self::host)
32/// is `None`, and otherwise off that thread.
33///
34/// A workspace of your own implements every method; [`Local`] is the
35/// reference for what each does.
36pub trait Workspace: Send + Sync + fmt::Debug + 'static {
37    /// Where it is when it is not this machine, such as `ssh://dev@box`;
38    /// `None` for this machine.
39    fn host(&self) -> Option<&str> {
40        None
41    }
42
43    /// What `path` is, its links followed: a file (its size, whether it is
44    /// read-only, when it was last changed), a directory, something else, or
45    /// nothing.
46    fn stat(&self, path: &Path) -> io::Result<Stat>;
47
48    /// Up to `limit` bytes of the regular file `path`, and its whole size.
49    /// Anything but a regular file is refused without being opened: opening
50    /// a FIFO or a device can block forever.
51    fn read(&self, path: &Path, limit: u64) -> io::Result<Contents>;
52
53    /// The whole content of each of `paths` that is a regular file of at
54    /// most `limit` bytes, in order: `None` for any other path, and for one
55    /// that could not be read. What a search reads; a workspace where each
56    /// call costs a round trip, such as a remote one, reads them in one.
57    fn read_files(&self, paths: &[PathBuf], limit: u64) -> Vec<Option<Vec<u8>>> {
58        paths
59            .iter()
60            .map(|path| read_file(self, path, limit))
61            .collect()
62    }
63
64    /// Lines `first` (from 1) on, at most `count` of them, of the regular
65    /// text file `path`, with how many lines it has and the hash of all of
66    /// it. Refused, as `read_text` refuses,
67    /// when it is no regular file, larger than [`MAX_FILE_BYTES`] (a
68    /// [`FileTooLarge`](ErrorKind::FileTooLarge) error) or not UTF-8 (an
69    /// [`InvalidData`](ErrorKind::InvalidData) error); a workspace that
70    /// sends only the lines asked of a file larger than
71    /// [`MAX_BYTES`], such as a remote one, checks only those for UTF-8.
72    ///
73    /// By default the whole file is [`read`](Self::read) and cut here.
74    fn excerpt(&self, path: &Path, first: usize, count: usize) -> io::Result<Excerpt> {
75        excerpt_of(self, path, first, count)
76    }
77
78    /// Replaces the file `path` with `bytes` in one step, creating its
79    /// directory, keeping its permissions, and refusing a read-only file or
80    /// anything but a regular file (see [`write_atomic`]).
81    fn write(&self, path: &Path, bytes: &[u8]) -> io::Result<()>;
82
83    /// [`write`](Self::write)s `bytes` to `path` and says what the file
84    /// held before, as [`read`](Self::read) with `limit` reads it: a
85    /// [`NotFound`](ErrorKind::NotFound) error when there was no file, the
86    /// file's own when it could not be read. What a tool that writes a
87    /// file diffs against; a workspace where each call costs a round trip,
88    /// such as a remote one, does both in one.
89    ///
90    /// By default it reads, then writes.
91    fn replace(&self, path: &Path, bytes: &[u8], limit: u64) -> io::Result<io::Result<Contents>> {
92        let before = self.read(path, limit);
93        self.write(path, bytes)?;
94        Ok(before)
95    }
96
97    /// [`write`](Self::write)s `bytes` to the regular file `path` only
98    /// while it holds the text whose [`content_hash`] is `held`: `None`
99    /// when it did. Otherwise nothing is written and it says what the file
100    /// holds instead, as [`read`](Self::read) with a limit of
101    /// [`MAX_FILE_BYTES`] reads it. What an edit made from text seen
102    /// before writes with; a workspace where each call costs a round trip,
103    /// such as a remote one, checks and writes in one.
104    ///
105    /// By default it reads, then writes.
106    fn write_if(
107        &self,
108        path: &Path,
109        bytes: &[u8],
110        held: &ContentHash,
111    ) -> io::Result<Option<Contents>> {
112        let now = self.read(path, MAX_FILE_BYTES)?;
113        if now.size > MAX_FILE_BYTES || content_hash(&now.bytes) != *held {
114            return Ok(Some(now));
115        }
116        self.write(path, bytes)?;
117        Ok(None)
118    }
119
120    /// Removes the file `path`; a directory is refused.
121    fn remove(&self, path: &Path) -> io::Result<()>;
122
123    /// Moves the file `from` to `to`, creating `to`'s directory and
124    /// replacing a file there.
125    fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
126
127    /// `path` made absolute, with the links of its longest existing part
128    /// followed (the file's own too, when it exists) and `..` folded, so two
129    /// spellings of one file come out alike. A footprint is made with it,
130    /// which may be on a thread that must not wait: a remote workspace may
131    /// answer from a cache, or fold `..` only.
132    fn canonical(&self, path: &Path) -> PathBuf;
133
134    /// `path` relative to `base`, as `resolve` joins them:
135    /// `base.join(path)`, so an absolute `path` stays as it is. A workspace
136    /// whose paths can name another machine, such as
137    /// [`Hosts`](crate::remote::Hosts), keeps such a path whole too.
138    fn join(&self, base: &Path, path: &Path) -> PathBuf {
139        base.join(path)
140    }
141
142    /// The regular files under `root` (or `root` itself when it is a file),
143    /// in walk order, as `root` joined with their path below it: those
144    /// `listing` keeps ([`Listing`]), and only those matching `glob`, such
145    /// as `*.rs`, when it is given. A malformed glob is an
146    /// [`InvalidInput`](ErrorKind::InvalidInput) error that says so.
147    fn files(&self, root: &Path, glob: Option<&str>, listing: Listing) -> io::Result<Vec<PathBuf>>;
148
149    /// Starts `program` with `args` in `dir` (`None`: the launcher's own
150    /// directory) under `launcher`'s policy: its wrapper and variables.
151    fn spawn(
152        &self,
153        launcher: &Launcher,
154        dir: Option<&Path>,
155        program: &OsStr,
156        args: &[OsString],
157        how: Spawn,
158    ) -> io::Result<Process>;
159
160    /// Watches `root`, a file or a directory (with `recursive`, every
161    /// directory under it too), from now on: a batch of the paths created,
162    /// changed or removed each time something changed, of the files
163    /// [`files`](Self::files) would list with `listing`.
164    /// Dropping the [`Watch`] stops it. To miss nothing, list the files
165    /// once after the watch has started.
166    ///
167    /// By default it looks at what [`files`](Self::files) lists every
168    /// second, on a thread of its own, comparing each file's
169    /// size and change time (its hash when [`stat`](Self::stat) cannot tell
170    /// the time). [`Local`] hears of changes from the system instead.
171    fn watch(self: Arc<Self>, root: &Path, recursive: bool, listing: Listing) -> io::Result<Watch> {
172        crate::watch::poll(self, root, recursive, listing)
173    }
174}
175
176/// Which files [`Workspace::files`] lists, and a watch reports.
177#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
178pub enum Listing {
179    /// What a person browsing the project sees, as ripgrep lists it: no
180    /// file the ignore rules (`.gitignore`, `.ignore`, the git excludes)
181    /// leave out, no hidden file or directory (named with a leading `.`),
182    /// no `target` directory.
183    #[default]
184    Visible,
185    /// Every file the ignore rules keep, hidden ones such as `.github/` and
186    /// `.env.example` included; `.git` left out.
187    Hidden,
188    /// Every regular file.
189    All,
190}
191
192impl Listing {
193    /// The listing a search or listing for `glob` uses: `Visible` becomes
194    /// `Hidden` when the glob names a hidden file or directory, such as
195    /// `.github/**` or `**/.env*`, so asking for one finds it.
196    pub fn for_glob(self, glob: Option<&str>) -> Self {
197        let names_hidden = glob.is_some_and(|glob| {
198            glob.split(['/', '\\'])
199                .any(|part| part.starts_with('.') && part != "." && part != "..")
200        });
201        match self {
202            Self::Visible if names_hidden => Self::Hidden,
203            other => other,
204        }
205    }
206
207    /// Whether this listing keeps `below`, a path below the listed root, by
208    /// its names alone (the ignore rules aside): `Visible` none of whose
209    /// parts is hidden or `target`, `Hidden` none of whose parts is `.git`.
210    pub fn keeps(self, below: &Path) -> bool {
211        let mut parts = below.iter();
212        match self {
213            Self::Visible => !parts.any(|part| part == "target" || is_hidden(Path::new(part))),
214            Self::Hidden => !parts.any(|part| part == ".git"),
215            Self::All => true,
216        }
217    }
218}
219
220/// What [`Workspace::read`] read.
221#[derive(Clone, Debug, Default, PartialEq, Eq)]
222pub struct Contents {
223    /// At most the asked number of bytes, from the file's start.
224    pub bytes: Vec<u8>,
225    /// The file's whole size, more than `bytes` holds when it was cut.
226    pub size: u64,
227}
228
229/// What [`Workspace::excerpt`] read.
230#[derive(Clone, Debug, PartialEq, Eq)]
231pub struct Excerpt {
232    /// The lines asked, each with its line end as in the file.
233    pub text: String,
234    /// How many lines the file has, as [`str::lines`] counts them.
235    pub total: usize,
236    /// The hash of the whole file.
237    pub hash: ContentHash,
238    /// The whole file's text, when it is at most [`MAX_BYTES`]: sending
239    /// that much costs about what a call does, and an edit after the read
240    /// starts from it instead of fetching the file.
241    pub whole: Option<String>,
242}
243
244impl Excerpt {
245    /// Lines `first` (from 1) on, at most `count` of them, of `text`, the
246    /// whole file, kept whole too when it is small.
247    pub fn of(text: &str, first: usize, count: usize) -> Self {
248        let start = text
249            .split_inclusive('\n')
250            .take(first.saturating_sub(1))
251            .map(str::len)
252            .sum();
253        let rest = text.get(start..).unwrap_or_default();
254        let len = rest.split_inclusive('\n').take(count).map(str::len).sum();
255        Self {
256            text: rest.get(..len).unwrap_or_default().to_owned(),
257            total: text.lines().count(),
258            hash: content_hash(text.as_bytes()),
259            whole: (text.len() <= MAX_BYTES).then(|| text.to_owned()),
260        }
261    }
262}
263
264/// What a path is, as [`Workspace::stat`] says.
265#[derive(Clone, Copy, Debug, PartialEq, Eq)]
266pub enum Stat {
267    /// A regular file.
268    File {
269        /// Its size in bytes.
270        size: u64,
271        /// Whether it may not be written.
272        read_only: bool,
273        /// When its content last changed, when the workspace can tell:
274        /// how a scan or a polling watch sees an edit that kept the size.
275        modified: Option<SystemTime>,
276    },
277    /// A directory.
278    Dir,
279    /// Something else, such as a FIFO or a device: the tools neither read
280    /// nor write it.
281    Other,
282    /// Nothing is there.
283    Missing,
284}
285
286impl dyn Workspace {
287    /// `path` as the file tools name it: joined onto `base` when relative,
288    /// then made [`canonical`](Workspace::canonical).
289    pub fn resolve(&self, base: Option<&Path>, path: &str) -> PathBuf {
290        self.canonical(&base.map_or_else(
291            || PathBuf::from(path),
292            |base| self.join(base, path.as_ref()),
293        ))
294    }
295
296    /// `path` `resolve`d against `base`, as a file tool
297    /// opens it; with `confine`, refused before any other I/O when it lies
298    /// outside `base` (`None`: the working directory).
299    ///
300    /// ```
301    /// use rig_tools::workspace::{Local, Workspace};
302    ///
303    /// let local: &dyn Workspace = &Local;
304    /// let base = std::fs::canonicalize(".")?;
305    /// assert_eq!(local.place(Some(&base), true, "src/../a.rs")?, base.join("a.rs"));
306    /// assert!(local.place(Some(&base), true, "../outside.rs").is_err());
307    /// assert!(local.place(Some(&base), false, "../outside.rs").is_ok());
308    /// # Ok::<(), Box<dyn std::error::Error>>(())
309    /// ```
310    pub fn place(
311        &self,
312        base: Option<&Path>,
313        confine: bool,
314        path: &str,
315    ) -> Result<PathBuf, ToolExecutionError> {
316        let resolved = self.resolve(base, path);
317        if confine {
318            let root = self.resolve(base, ".");
319            if !resolved.starts_with(&root) {
320                return Err(ToolExecutionError::permission_denied(format!(
321                    "{path} is outside {}, the only directory this tool works in",
322                    root.display()
323                )));
324            }
325        }
326        Ok(resolved)
327    }
328
329    /// The regular file `path` as text, in one [`read`](Workspace::read):
330    /// refused when it is no regular file or larger than
331    /// [`MAX_FILE_BYTES`].
332    pub fn read_text(&self, path: &Path) -> Result<String, ToolExecutionError> {
333        let shown = path.to_string_lossy();
334        self.read(path, MAX_FILE_BYTES)
335            .and_then(text_of)
336            .map_err(|error| text_error(&shown, error))
337    }
338
339    /// What the file `path` holds before a tool changes it, in one
340    /// [`read`](Workspace::read): `None` when there is none; empty, with
341    /// only its size, when it is too large to read; empty, with no state,
342    /// when it is no regular file or could not be read.
343    pub(crate) fn previous(&self, path: &Path) -> Option<Previous> {
344        Previous::read(self.read(path, MAX_FILE_BYTES))
345    }
346}
347
348/// The whole text of a file read with a limit of [`MAX_FILE_BYTES`]:
349/// refused when it is larger or not UTF-8.
350pub(crate) fn text_of(contents: Contents) -> io::Result<String> {
351    if contents.size > MAX_FILE_BYTES {
352        return Err(too_large(contents.size));
353    }
354    String::from_utf8(contents.bytes).map_err(|_| not_text())
355}
356
357/// The error for a file of `size` bytes, more than [`MAX_FILE_BYTES`].
358pub(crate) fn too_large(size: u64) -> io::Error {
359    io::Error::new(
360        ErrorKind::FileTooLarge,
361        format!(
362            "{} MB; the tools open files up to {} MB",
363            size / (1024 * 1024),
364            MAX_FILE_BYTES / (1024 * 1024)
365        ),
366    )
367}
368
369/// The error for bytes that are not UTF-8 text.
370pub(crate) fn not_text() -> io::Error {
371    io::Error::new(ErrorKind::InvalidData, "stream did not contain valid UTF-8")
372}
373
374/// A model-visible error for a failed text read of `shown`
375/// (`read_text`, [`Workspace::excerpt`]).
376pub(crate) fn text_error(shown: &str, error: io::Error) -> ToolExecutionError {
377    match error.kind() {
378        ErrorKind::NotFound => ToolExecutionError::not_found(format!("{shown}: no such file")),
379        ErrorKind::InvalidInput | ErrorKind::IsADirectory => {
380            ToolExecutionError::invalid_args(format!("{shown} is not a regular file"))
381        }
382        ErrorKind::FileTooLarge => ToolExecutionError::invalid_args(format!("{shown} is {error}")),
383        _ => io_error(shown, error),
384    }
385}
386
387/// [`Workspace::excerpt`] as it is by default: the whole file read and cut
388/// here.
389pub(crate) fn excerpt_of<W: Workspace + ?Sized>(
390    workspace: &W,
391    path: &Path,
392    first: usize,
393    count: usize,
394) -> io::Result<Excerpt> {
395    let text = text_of(workspace.read(path, MAX_FILE_BYTES)?)?;
396    Ok(Excerpt::of(&text, first, count))
397}
398
399/// `path`'s whole content when it is a regular file of at most `limit`
400/// bytes, as [`Workspace::read_files`] reads each by default.
401pub(crate) fn read_file<W: Workspace + ?Sized>(
402    workspace: &W,
403    path: &Path,
404    limit: u64,
405) -> Option<Vec<u8>> {
406    let small = matches!(workspace.stat(path), Ok(Stat::File { size, .. }) if size <= limit);
407    small
408        .then(|| workspace.read(path, limit).ok())
409        .flatten()
410        .map(|contents| contents.bytes)
411}
412
413/// This machine: its file system and its processes.
414#[derive(Clone, Copy, Debug, Default)]
415pub struct Local;
416
417impl Workspace for Local {
418    fn stat(&self, path: &Path) -> io::Result<Stat> {
419        match fs::metadata(path) {
420            Ok(meta) if meta.is_file() => Ok(Stat::File {
421                size: meta.len(),
422                read_only: meta.permissions().readonly(),
423                modified: meta.modified().ok(),
424            }),
425            Ok(meta) if meta.is_dir() => Ok(Stat::Dir),
426            Ok(_) => Ok(Stat::Other),
427            Err(error) if error.kind() == ErrorKind::NotFound => Ok(Stat::Missing),
428            Err(error) => Err(error),
429        }
430    }
431
432    fn read(&self, path: &Path, limit: u64) -> io::Result<Contents> {
433        if !fs::metadata(path)?.is_file() {
434            return Err(io::Error::new(
435                ErrorKind::InvalidInput,
436                "not a regular file",
437            ));
438        }
439        let file = File::open(path)?;
440        let size = file.metadata()?.len();
441        let mut bytes = Vec::new();
442        file.take(limit).read_to_end(&mut bytes)?;
443        Ok(Contents { bytes, size })
444    }
445
446    fn write(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
447        write_atomic(path, bytes)
448    }
449
450    fn remove(&self, path: &Path) -> io::Result<()> {
451        fs::remove_file(path)
452    }
453
454    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
455        if let Some(parent) = to.parent().filter(|parent| !parent.as_os_str().is_empty()) {
456            fs::create_dir_all(parent)?;
457        }
458        match fs::rename(from, to) {
459            Err(error) if error.kind() == ErrorKind::CrossesDevices => {
460                fs::copy(from, to)?;
461                fs::remove_file(from)
462            }
463            moved => moved,
464        }
465    }
466
467    fn canonical(&self, path: &Path) -> PathBuf {
468        let joined = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
469        let real = joined.ancestors().find_map(|ancestor| {
470            let real = fs::canonicalize(ancestor).ok()?;
471            Some((real, joined.strip_prefix(ancestor).ok()?))
472        });
473        let (mut out, rest) = real.unwrap_or((PathBuf::new(), &joined));
474        for component in rest.components() {
475            match component {
476                Component::ParentDir => {
477                    out.pop();
478                }
479                Component::CurDir => {}
480                other => out.push(other),
481            }
482        }
483        out
484    }
485
486    fn files(&self, root: &Path, glob: Option<&str>, listing: Listing) -> io::Result<Vec<PathBuf>> {
487        let mut walk = WalkBuilder::new(root);
488        match listing {
489            Listing::All => walk.standard_filters(false),
490            Listing::Visible | Listing::Hidden => walk.hidden(false).filter_entry(move |entry| {
491                entry.depth() == 0 || listing.keeps(Path::new(entry.file_name()))
492            }),
493        };
494        if let Some(glob) = glob {
495            let overrides = OverrideBuilder::new(root)
496                .add(glob)
497                .and_then(|builder| builder.build())
498                .map_err(|error| {
499                    io::Error::new(ErrorKind::InvalidInput, format!("bad glob: {error}"))
500                })?;
501            walk.overrides(overrides);
502        }
503        // Only regular files: symbolic links are not followed.
504        Ok(walk
505            .build()
506            .flatten()
507            .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()))
508            .map(ignore::DirEntry::into_path)
509            .collect())
510    }
511
512    fn spawn(
513        &self,
514        launcher: &Launcher,
515        dir: Option<&Path>,
516        program: &OsStr,
517        args: &[OsString],
518        how: Spawn,
519    ) -> io::Result<Process> {
520        match dir {
521            Some(dir) => launcher.clone().in_dir(dir).spawn(program, args, how),
522            None => launcher.spawn(program, args, how),
523        }
524    }
525
526    /// The system's file events under `root`, filtered as
527    /// [`files`](Workspace::files) lists with `listing`, gathered into a
528    /// batch until no event came for 50 ms. A `root` that does not exist
529    /// yet is looked at every second, as the default does.
530    fn watch(self: Arc<Self>, root: &Path, recursive: bool, listing: Listing) -> io::Result<Watch> {
531        crate::watch::notified(root, recursive, listing)
532    }
533}
534
535/// Whether `path` is hidden: its name starts with a `.`, such as `.env` or
536/// `.github/`.
537pub(crate) fn is_hidden(path: &Path) -> bool {
538    path.file_name()
539        .is_some_and(|name| name.as_encoded_bytes().starts_with(b"."))
540}
541
542#[cfg(test)]
543pub(crate) mod tests;