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;