Skip to main content

rig_tools/
fs.rs

1//! File helpers the tools share: an atomic write, capped text reads, JSON
2//! files, and the [`FileChange`] a tool that writes publishes. The reads and
3//! the path rules here are this machine's ([`Local`]); a tool reads through
4//! its [`Workspace`].
5
6use std::fs::{self, File};
7use std::io::{self, ErrorKind, Write as _};
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::Duration;
11
12use rig_core::tool::{FileChange, FileState, Hunk, HunkLine, Lines, ToolExecutionError};
13use serde::Serialize;
14use serde::de::DeserializeOwned;
15
16use crate::workspace::{Contents, Listing, Local, Stat, Workspace};
17
18/// Reads `path` as text after checking, without opening it, that it is a
19/// regular file of at most [`MAX_FILE_BYTES`](crate::MAX_FILE_BYTES):
20/// opening a FIFO or a device can block forever. [`Local`]'s
21/// `read_text`.
22pub fn read_text(path: &str) -> Result<String, ToolExecutionError> {
23    local().read_text(Path::new(path))
24}
25
26/// The start of a file read by [`read_prefix`].
27pub struct Prefix {
28    /// At most the asked number of bytes, as text, without a byte-order
29    /// mark.
30    pub text: String,
31    /// The file's size, more than `text` holds when it was cut.
32    pub size: u64,
33}
34
35impl Prefix {
36    /// Whether the file is longer than [`text`](Self::text).
37    pub fn cut(&self) -> bool {
38        self.size > self.text.len() as u64
39    }
40
41    /// `contents`, read with a limit of `cap` bytes, as text without a
42    /// byte-order mark. A character cut at the end is dropped; bad bytes
43    /// inside are replaced.
44    pub fn of(contents: Contents, cap: usize) -> Self {
45        let Contents { bytes, size } = contents;
46        let mut text = match String::from_utf8(bytes) {
47            Ok(text) => text,
48            Err(error) => {
49                let valid = error.utf8_error().valid_up_to();
50                let mut bytes = error.into_bytes();
51                if size > cap as u64 && bytes.len().saturating_sub(valid) < 4 {
52                    bytes.truncate(valid);
53                }
54                String::from_utf8_lossy(&bytes).into_owned()
55            }
56        };
57        if let Some(rest) = text.strip_prefix('\u{feff}') {
58            text = rest.to_owned();
59        }
60        Self { text, size }
61    }
62}
63
64/// Reads at most `cap` bytes of the regular file at `path` on this machine
65/// as a [`Prefix`].
66pub fn read_prefix(path: &Path, cap: usize) -> io::Result<Prefix> {
67    Ok(Prefix::of(Local.read(path, cap as u64)?, cap))
68}
69
70/// Replaces the file at `path` with `contents` in one step, creating its
71/// directory: a temporary file beside it is written, synced and renamed
72/// over it, so a crash or a full disk never leaves it half written. A
73/// symlink's target is replaced and the link kept, and a dangling link's
74/// target is created; an existing file keeps its permissions. A read-only
75/// file is refused, as is anything but a regular file, because opening a
76/// FIFO or a device for writing can block forever.
77///
78/// Unlike writing in place, the rename needs write access to the directory,
79/// gives the file a new inode (a hard link to the old one keeps the old
80/// text) and makes this process the file's owner. A crash between create
81/// and rename can leave a `.name.<pid>-<n>.tmp` beside the file.
82pub fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
83    let (target, permissions) = match fs::metadata(path) {
84        Ok(meta) if !meta.is_file() => {
85            return Err(io::Error::new(
86                ErrorKind::InvalidInput,
87                "not a regular file",
88            ));
89        }
90        Ok(meta) if meta.permissions().readonly() => {
91            return Err(io::Error::new(
92                ErrorKind::PermissionDenied,
93                "the file is read-only; leave it unchanged or ask the user",
94            ));
95        }
96        Ok(meta) => (fs::canonicalize(path)?, Some(meta.permissions())),
97        Err(error) if error.kind() == ErrorKind::NotFound => (link_target(path), None),
98        Err(error) => return Err(error),
99    };
100    if let Some(parent) = target
101        .parent()
102        .filter(|parent| !parent.as_os_str().is_empty())
103    {
104        fs::create_dir_all(parent)?;
105    }
106    let name = target
107        .file_name()
108        .ok_or_else(|| io::Error::new(ErrorKind::InvalidInput, "names no file"))?;
109    let temporary = temporary_beside(&target, name);
110    let written = File::create_new(&temporary).and_then(|mut file| {
111        if let Some(permissions) = permissions {
112            file.set_permissions(permissions)?;
113        }
114        file.write_all(contents)?;
115        file.sync_all()?;
116        fs::rename(&temporary, &target)
117    });
118    written.inspect_err(|_| {
119        fs::remove_file(&temporary).ok();
120    })
121}
122
123/// Writes `contents` to the file `path` names in one step, as
124/// [`write_atomic`] does, but without forcing it to disk, and with none of
125/// its checks: for scratch that no run after a crash reads, such as a
126/// checkpoint's kept content. A reader never sees it half written.
127pub(crate) fn write_scratch(path: &Path, contents: &[u8]) -> io::Result<()> {
128    let name = path
129        .file_name()
130        .ok_or_else(|| io::Error::new(ErrorKind::InvalidInput, "names no file"))?;
131    let temporary = temporary_beside(path, name);
132    let written = File::create_new(&temporary)
133        .and_then(|mut file| file.write_all(contents))
134        .and_then(|()| fs::rename(&temporary, path));
135    written.inspect_err(|_| {
136        fs::remove_file(&temporary).ok();
137    })
138}
139
140/// A temporary file's path beside `target`, whose file name is `name`:
141/// `.name.<pid>-<n>.tmp`, unique among this process's writes.
142fn temporary_beside(target: &Path, name: &std::ffi::OsStr) -> PathBuf {
143    /// Tells apart the temporary files of writes running at once.
144    static NEXT: AtomicU64 = AtomicU64::new(0);
145    target.with_file_name(format!(
146        ".{}.{}-{}.tmp",
147        name.to_string_lossy(),
148        std::process::id(),
149        NEXT.fetch_add(1, Ordering::Relaxed)
150    ))
151}
152
153/// The JSON file at `path` as a `T`, or `None` when it is missing or is
154/// not one.
155pub fn read_json<T: DeserializeOwned>(path: &Path) -> Option<T> {
156    serde_json::from_slice(&fs::read(path).ok()?).ok()
157}
158
159/// Replaces the file at `path` with `value` as pretty JSON, by
160/// [`write_atomic`].
161pub fn write_json(path: &Path, value: &impl Serialize) -> io::Result<()> {
162    write_atomic(path, &serde_json::to_vec_pretty(value)?)
163}
164
165/// Where a missing `path` is created: the end of its chain of dangling
166/// symlinks, or `path` itself when it is no link.
167fn link_target(path: &Path) -> PathBuf {
168    /// Linux's own limit on links followed in one lookup.
169    const MAX_HOPS: usize = 40;
170    let mut target = path.to_path_buf();
171    for _ in 0..MAX_HOPS {
172        let Ok(link) = fs::read_link(&target) else {
173            break;
174        };
175        target = target.parent().unwrap_or(Path::new("")).join(link);
176    }
177    target
178}
179
180/// A model-visible error for an I/O failure on `path`.
181pub fn io_error(path: &str, error: io::Error) -> ToolExecutionError {
182    let message = format!("{path}: {error}");
183    match error.kind() {
184        ErrorKind::NotFound => ToolExecutionError::not_found(message),
185        ErrorKind::PermissionDenied => ToolExecutionError::permission_denied(message),
186        _ => ToolExecutionError::other(message),
187    }
188}
189
190/// The files `workspace` lists under `root` ([`Workspace::files`]), for a
191/// tool to show: a missing `root` is a not-found error, so the model can tell
192/// a wrong path from an empty one, and a malformed glob is an
193/// invalid-arguments error.
194pub(crate) fn listed(
195    workspace: &dyn Workspace,
196    root: &Path,
197    glob: Option<&str>,
198    listing: Listing,
199) -> Result<Vec<PathBuf>, ToolExecutionError> {
200    let shown = root.to_string_lossy();
201    let missing = || {
202        let missing = io::Error::new(ErrorKind::NotFound, "no such file or directory");
203        io_error(&shown, missing)
204    };
205    let files = workspace
206        .files(root, glob, listing)
207        .map_err(|error| match error.kind() {
208            ErrorKind::InvalidInput => ToolExecutionError::invalid_args(error.to_string()),
209            ErrorKind::NotFound => missing(),
210            _ => io_error(&shown, error),
211        })?;
212    // Only an empty listing asks whether `root` is there at all.
213    if files.is_empty()
214        && workspace
215            .stat(root)
216            .map_err(|error| io_error(&shown, error))?
217            == Stat::Missing
218    {
219        return Err(missing());
220    }
221    Ok(files)
222}
223
224/// `path` as the file tools open it: made absolute against `base` (`None`:
225/// this process's working directory), with every symbolic link of its
226/// longest existing part followed (the file's own too, when it exists) and
227/// `..` folded. Two spellings of one file resolve alike, so this is how the
228/// facts the tools publish ([`FilesRead`](rig_core::tool::FilesRead),
229/// [`FileChange::path`]) and their footprints name files. A path that
230/// cannot be made absolute (no working directory) stays relative.
231///
232/// ```
233/// use std::path::Path;
234/// use rig_tools::fs::resolve;
235///
236/// let here = std::fs::canonicalize(".")?;
237/// assert_eq!(resolve(None, "src/../Cargo.toml"), here.join("Cargo.toml"));
238/// assert_eq!(resolve(Some(&here), "gone/../new.rs"), here.join("new.rs"));
239/// assert_eq!(resolve(Some(Path::new("/nowhere")), "/nowhere/a/../b"), Path::new("/nowhere/b"));
240/// # Ok::<(), std::io::Error>(())
241/// ```
242pub fn resolve(base: Option<&Path>, path: &str) -> PathBuf {
243    local().resolve(base, path)
244}
245
246/// `path` [`resolve`]d against `base`, as a file tool opens it; with
247/// `confine`, refused before any I/O when it lies outside `base` (`None`:
248/// the working directory). The rule the file tools follow, for code that
249/// must decide as they would, such as a preview of a write.
250///
251/// ```
252/// use std::path::Path;
253/// use rig_tools::fs::place;
254///
255/// let base = std::fs::canonicalize(".")?;
256/// assert_eq!(place(Some(&base), true, "src/../a.rs")?, base.join("a.rs"));
257/// assert!(place(Some(&base), true, "../outside.rs").is_err());
258/// assert!(place(Some(&base), false, "../outside.rs").is_ok());
259/// # Ok::<(), Box<dyn std::error::Error>>(())
260/// ```
261pub fn place(
262    base: Option<&Path>,
263    confine: bool,
264    path: &str,
265) -> Result<PathBuf, ToolExecutionError> {
266    local().place(base, confine, path)
267}
268
269/// This machine, as a [`Workspace`].
270fn local() -> &'static dyn Workspace {
271    &Local
272}
273
274/// What turning `before` into `after` did to the file at `path` (`None`:
275/// there was no file before, or there is none after): its lines added and
276/// removed, counted by a line diff, that diff as hunks with 3 lines of
277/// context, the text before, and the [`FileState`] of each text: the fact a
278/// tool that writes files publishes
279/// ([`FilesChanged`](rig_core::tool::FilesChanged)).
280///
281/// ```
282/// use rig_core::tool::ChangeKind;
283/// use rig_tools::fs::file_change;
284///
285/// let created = file_change("a.rs", None, Some("one\ntwo\n"));
286/// assert_eq!((created.kind, created.added, created.removed), (ChangeKind::Created, 2, 0));
287/// let changed = file_change("a.rs", Some("one\ntwo\n"), Some("one\n2\nthree\n"));
288/// assert_eq!((changed.kind, changed.added, changed.removed), (ChangeKind::Modified, 2, 1));
289/// assert_eq!(
290///     changed.unified(),
291///     "--- a.rs\n+++ a.rs\n@@ -1,2 +1,3 @@\n one\n-two\n+2\n+three\n"
292/// );
293/// assert_eq!(changed.after_text().as_deref(), Some("one\n2\nthree\n"));
294/// assert_eq!(changed.before.and_then(|before| before.lines), Some(2));
295/// let deleted = file_change("a.rs", Some("one\n"), None);
296/// assert_eq!((deleted.kind, deleted.removed), (ChangeKind::Deleted, 1));
297/// assert_eq!(deleted.unified(), "--- a.rs\n+++ /dev/null\n@@ -1 +0,0 @@\n-one\n");
298/// ```
299pub fn file_change(path: &str, before: Option<&str>, after: Option<&str>) -> FileChange {
300    let mut change = diff(path, before, after);
301    change.before_text = before.map(str::to_owned);
302    change.before = before.map(|text| FileState::of(text.as_bytes()));
303    change.after = after.map(|text| FileState::of(text.as_bytes()));
304    change
305}
306
307/// [`file_change`] without the text before and the states, for a caller
308/// that has them.
309pub(crate) fn diff(path: &str, before: Option<&str>, after: Option<&str>) -> FileChange {
310    // Past the deadline the diff is still exact, only less minimal.
311    let diff = similar::TextDiff::configure()
312        .timeout(Duration::from_millis(100))
313        .diff_lines(before.unwrap_or_default(), after.unwrap_or_default());
314    let hunks: Vec<Hunk> = diff
315        .grouped_ops(3)
316        .iter()
317        .filter_map(|ops| hunk(&diff, ops))
318        .collect();
319    let lines = hunks.iter().flat_map(|hunk| &hunk.lines);
320    let counted = |sign| lines.clone().filter(|line| line.sign() == sign).count();
321    let (added, removed) = (counted('+'), counted('-'));
322    let mut change = match (before, after) {
323        (_, None) => FileChange::deleted(path, removed),
324        (None, Some(_)) => FileChange::created(path, added),
325        (Some(_), Some(_)) => FileChange::modified(path, added, removed),
326    };
327    change.hunks = hunks;
328    change
329}
330
331/// The hunk `ops`, a group of [`similar::TextDiff::grouped_ops`], spans.
332fn hunk(diff: &similar::TextDiff<'_, '_, '_, str>, ops: &[similar::DiffOp]) -> Option<Hunk> {
333    let (first, last) = (ops.first()?, ops.last()?);
334    let span = |start: usize, end: usize| Lines {
335        start: start + 1,
336        count: end.saturating_sub(start),
337    };
338    let lines = ops
339        .iter()
340        .flat_map(|op| diff.iter_changes(op))
341        .map(|change| {
342            let text = change.value().to_owned();
343            match change.tag() {
344                similar::ChangeTag::Equal => HunkLine::Same(text),
345                similar::ChangeTag::Delete => HunkLine::Removed(text),
346                similar::ChangeTag::Insert => HunkLine::Added(text),
347            }
348        });
349    Some(Hunk {
350        old: span(first.old_range().start, last.old_range().end),
351        new: span(first.new_range().start, last.new_range().end),
352        lines: lines.collect(),
353    })
354}
355
356/// What a file held before a tool changed it.
357#[derive(Default)]
358pub(crate) struct Previous {
359    /// Its text, any bytes that are not UTF-8 replaced, to diff; empty when
360    /// it was not read.
361    pub(crate) text: String,
362    /// Whether [`text`](Self::text) is the file exactly: it was read and is
363    /// UTF-8.
364    pub(crate) exact: bool,
365    /// The file, when it is a regular file.
366    pub(crate) state: Option<FileState>,
367}
368
369impl Previous {
370    /// A file that held `bytes`.
371    pub(crate) fn of(bytes: Vec<u8>) -> Self {
372        let state = Some(FileState::of(&bytes));
373        let (text, exact) = match String::from_utf8(bytes) {
374            Ok(text) => (text, true),
375            Err(error) => (String::from_utf8_lossy(error.as_bytes()).into(), false),
376        };
377        Self { text, exact, state }
378    }
379
380    /// What a [`read`](Workspace::read) of a file with a limit of
381    /// [`MAX_FILE_BYTES`](crate::MAX_FILE_BYTES) found: `None` when there
382    /// is no file; one with only its size when it is larger; one with no
383    /// state when it is no regular file or could not be read.
384    pub(crate) fn read(read: io::Result<Contents>) -> Option<Self> {
385        match read {
386            Err(error) if error.kind() == ErrorKind::NotFound => None,
387            Ok(contents) if contents.size > crate::MAX_FILE_BYTES => {
388                Some(Self::unread(contents.size))
389            }
390            Ok(contents) => Some(Self::of(contents.bytes)),
391            Err(_) => Some(Self::default()),
392        }
393    }
394
395    /// A file of `size` bytes that was not read.
396    pub(crate) fn unread(size: u64) -> Self {
397        Self {
398            text: String::new(),
399            exact: false,
400            state: Some(FileState::unread(size)),
401        }
402    }
403
404    /// The change from this file (`None`: none) to `after` (`None`: removed):
405    /// a [`file_change`] with this file's own state and text (none unless
406    /// [`exact`](Self::exact)), and the state of the bytes `after` holds.
407    pub(crate) fn change(this: Option<Self>, path: &str, after: Option<&[u8]>) -> FileChange {
408        let text = after.map(String::from_utf8_lossy);
409        let before = this.as_ref().map(|it| it.text.as_str());
410        let mut change = diff(path, before, text.as_deref());
411        change.after = after.map(FileState::of);
412        if let Some(this) = this {
413            change.before = this.state;
414            change.before_text = this.exact.then_some(this.text);
415        }
416        change
417    }
418}
419
420#[cfg(test)]
421mod tests;