Skip to main content

rig_tools/
checkpoint.rs

1//! Checkpoints: the files under a root at one moment ([`snapshot`]), to
2//! learn what changed by a later one ([`Snapshot::changes_since`]), and
3//! [`Checkpointed`], a handler that reports what any call changed, such as
4//! a `shell` call's, as the file tools report their own.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::ffi::OsStr;
8use std::io::{self, ErrorKind, Write as _};
9use std::path::{Component, Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::sync::{Arc, Mutex, PoisonError};
12use std::time::{Duration, SystemTime};
13
14use rig_core::completion::UnsupportedOption;
15use rig_core::effect::{EffectKind, HandlerDescriptor, HandlerKey};
16use rig_core::serve::{Dispatch, ErasedHandler, Reply, Serve};
17use rig_core::tool::{
18    ContentHash, FileChange, FileState, FilesChanged, PublishedContext, Touches, content_hash,
19};
20
21use crate::fs::Previous;
22use crate::process::Launcher;
23use crate::workspace::{Listing, Stat, Workspace};
24use crate::{MAX_FILE_BYTES, blocking};
25
26/// The files under a root at one moment, to diff against a later one.
27///
28/// Taken one of two ways, and compared only with one taken the same way:
29/// - in a git work tree on this machine that does not ignore the root, as
30///   a git tree written with a private index and object directory, so the
31///   user's repository is never written to;
32/// - elsewhere, as a scan of what [`Workspace::files`] lists, with each
33///   file's size, change time and hash.
34///
35/// Either way, the content of each file up to [`MAX_FILE_BYTES`] is kept
36/// when the snapshot was taken with somewhere to keep it ([`Taking::keep`]).
37/// The files git or `files` leaves out (ignored, hidden, `target`) are not
38/// in it, unless named in [`Taking::also`].
39#[derive(Clone, Debug)]
40pub struct Snapshot(Taken);
41
42/// How a [`Snapshot`] was taken.
43#[derive(Clone, Debug)]
44enum Taken {
45    /// A git tree in a private object directory.
46    Git { git: Git, tree: String },
47    /// Each file's [`Seen`], by path, when the scan began, and the
48    /// directory its content is kept in, by hash, when it is.
49    Scan {
50        files: Arc<BTreeMap<PathBuf, Seen>>,
51        began: SystemTime,
52        blobs: Option<PathBuf>,
53    },
54}
55
56/// How to take a [`Snapshot`].
57#[derive(Clone, Debug, Default, PartialEq, Eq)]
58pub struct Taking {
59    /// A directory on this machine for a git snapshot's private index and
60    /// objects, and for a scan's kept content, under `checkpoints/`;
61    /// `None`: scan, and keep no content.
62    pub keep: Option<PathBuf>,
63    /// Paths counted whole although ignore rules, hidden names or `target`
64    /// would leave them out, such as `dist` or `.env`: relative to the root,
65    /// or absolute under it.
66    pub also: Vec<PathBuf>,
67    /// How a git snapshot starts `git`: as every other child process
68    /// starts, under the host's wrapper, in its environment.
69    pub launcher: Launcher,
70}
71
72/// What a scan saw of one file.
73#[derive(Clone, Debug, PartialEq, Eq)]
74struct Seen {
75    /// Its size.
76    size: u64,
77    /// When it last changed, when the workspace can tell.
78    modified: Option<SystemTime>,
79    /// The hash of its bytes; `None` when it is too large to read.
80    hash: Option<ContentHash>,
81    /// Its lines.
82    lines: usize,
83}
84
85impl Seen {
86    /// The file as a change's side.
87    fn state(&self) -> FileState {
88        FileState {
89            bytes: self.size,
90            lines: self.hash.is_some().then_some(self.lines),
91            hash: self.hash.clone(),
92        }
93    }
94}
95
96/// Takes a [`Snapshot`] of `root` in `workspace` as it is now.
97///
98/// With [`Taking::keep`], a local `workspace` ([`host`](Workspace::host) is
99/// `None`) and `root` in a git work tree that does not ignore it, it is a
100/// git tree: the private index makes each later snapshot cost what
101/// changed. Otherwise, or when `earlier` was a scan, it scans, hashing
102/// only the files whose size or change time differ from `earlier`'s (every
103/// file when there is none, or the workspace cannot tell the time), and
104/// with `keep` writes the content of each file it hashed to `keep`, once
105/// per hash. A file that changed just before `earlier` began is hashed
106/// again too: a write within its change time's precision after `earlier`
107/// read it would leave its size and time as they were (what git calls
108/// "racily clean"). The kept content is scratch, not forced to disk: no
109/// run after this one reads it ([`forget_kept`]).
110///
111/// Blocking: run it off a thread that must not wait ([`blocking`]).
112///
113/// # Errors
114///
115/// When git fails after `earlier` was taken with git, or `root` cannot be
116/// listed.
117pub fn snapshot(
118    workspace: &dyn Workspace,
119    root: &Path,
120    taking: &Taking,
121    earlier: Option<&Snapshot>,
122) -> io::Result<Snapshot> {
123    let keep = taking.keep.as_deref();
124    let git = match earlier.map(|it| &it.0) {
125        Some(Taken::Git { git, .. }) => Some(git.clone()),
126        Some(Taken::Scan { .. }) => None,
127        None => keep
128            .filter(|_| workspace.host().is_none())
129            .and_then(|keep| Git::find(root, keep, &taking.launcher)),
130    };
131    let also: Vec<PathBuf> = taking
132        .also
133        .iter()
134        .map(|path| root.join(path))
135        .filter(|path| {
136            path.starts_with(root) && !path.components().any(|part| part == Component::ParentDir)
137        })
138        .collect();
139    match git {
140        Some(git) => {
141            let tree = git.write_tree(&also)?;
142            Ok(Snapshot(Taken::Git { git, tree }))
143        }
144        None => {
145            let before = match earlier.map(|it| &it.0) {
146                Some(Taken::Scan { files, began, .. }) => Some((&**files, *began)),
147                _ => None,
148            };
149            let blobs = keep.map(|keep| private(keep, root).join("blobs"));
150            let began = SystemTime::now();
151            let files = scan(workspace, root, &also, before, blobs.as_deref())?;
152            let files = Arc::new(files);
153            Ok(Snapshot(Taken::Scan {
154                files,
155                began,
156                blobs,
157            }))
158        }
159    }
160}
161
162/// The private directory under `keep` for the snapshots of `dir`.
163fn private(keep: &Path, dir: &Path) -> PathBuf {
164    let name = content_hash(dir.as_os_str().as_encoded_bytes()).0;
165    keep.join("checkpoints")
166        .join(name.get(..16).unwrap_or(&name))
167}
168
169/// Removes the content scans kept under `keep` ([`Taking::keep`]): no
170/// [`Snapshot`] outlives the process that took it, so a host calls this
171/// when it starts, before taking any. Git's private objects are left, as
172/// they hold only what changed.
173///
174/// # Errors
175///
176/// When a directory there cannot be removed.
177pub fn forget_kept(keep: &Path) -> io::Result<()> {
178    let Ok(dirs) = std::fs::read_dir(keep.join("checkpoints")) else {
179        return Ok(());
180    };
181    for dir in dirs.flatten() {
182        match std::fs::remove_dir_all(dir.path().join("blobs")) {
183            Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),
184            _ => {}
185        }
186    }
187    Ok(())
188}
189
190impl Snapshot {
191    /// What changed from `earlier` to this snapshot, in path order: files
192    /// created, modified and deleted, with the [`FileState`] of each side.
193    /// A snapshot that kept content also gives each change's diff and line
194    /// counts; a scan that kept none gives a modified file's states only.
195    ///
196    /// # Errors
197    ///
198    /// When the two were taken different ways, or git fails.
199    pub fn changes_since(&self, earlier: &Snapshot) -> io::Result<Vec<FileChange>> {
200        match (&earlier.0, &self.0) {
201            (Taken::Git { tree: before, .. }, Taken::Git { git, tree }) => {
202                git.changes(before, tree)
203            }
204            (Taken::Scan { files: before, .. }, Taken::Scan { files, blobs, .. }) => {
205                Ok(scan_changes(before, files, blobs.as_deref()))
206            }
207            _ => Err(io::Error::new(
208                ErrorKind::InvalidInput,
209                "the two snapshots were taken different ways",
210            )),
211        }
212    }
213
214    /// The bytes the file `path` held when this snapshot was taken, when it
215    /// kept content ([`Taking::keep`]); `None` for a scan that kept none.
216    pub fn content(&self, path: &Path) -> Option<io::Result<Vec<u8>>> {
217        match &self.0 {
218            Taken::Git { git, tree } => Some(git.content(tree, path)),
219            Taken::Scan { files, blobs, .. } => {
220                let blobs = blobs.as_deref()?;
221                Some(match files.get(path).map(|seen| &seen.hash) {
222                    Some(Some(hash)) => std::fs::read(blobs.join(&hash.0)),
223                    Some(None) => Err(io::Error::new(
224                        ErrorKind::FileTooLarge,
225                        "the file was too large to keep",
226                    )),
227                    None => Err(ErrorKind::NotFound.into()),
228                })
229            }
230        }
231    }
232}
233
234/// The files under `root`, and every file under each of `also`, with what
235/// a scan sees of them, reusing the hash of the scan `earlier` (its files
236/// and when it began) for a file whose size and change time are the same
237/// and that had not just changed when it began ([`snapshot`]); with
238/// `blobs`, the content of each file it read is kept there, by hash, once.
239fn scan(
240    workspace: &dyn Workspace,
241    root: &Path,
242    also: &[PathBuf],
243    earlier: Option<(&BTreeMap<PathBuf, Seen>, SystemTime)>,
244    blobs: Option<&Path>,
245) -> io::Result<BTreeMap<PathBuf, Seen>> {
246    if let Some(blobs) = blobs {
247        std::fs::create_dir_all(blobs)?;
248    }
249    let mut listed = workspace.files(root, None, Listing::Visible)?;
250    for path in also {
251        listed.extend(
252            workspace
253                .files(path, None, Listing::All)
254                .unwrap_or_default(),
255        );
256    }
257    let mut seen = BTreeMap::new();
258    for path in listed {
259        let Ok(Stat::File { size, modified, .. }) = workspace.stat(&path) else {
260            continue;
261        };
262        let same = earlier.and_then(|(files, began)| {
263            let was = files.get(&path)?;
264            let settled = modified.is_some_and(|modified| settled_before(modified, began));
265            (settled && was.size == size && was.modified == modified).then_some(was)
266        });
267        let file = match same {
268            Some(was) => was.clone(),
269            None => {
270                let bytes = (size <= MAX_FILE_BYTES)
271                    .then(|| workspace.read(&path, MAX_FILE_BYTES).ok())
272                    .flatten()
273                    .map(|contents| contents.bytes);
274                let hash = bytes.as_deref().map(content_hash);
275                if let (Some(blobs), Some(bytes), Some(hash)) = (blobs, &bytes, &hash) {
276                    let blob = blobs.join(&hash.0);
277                    if !blob.exists() {
278                        crate::fs::write_scratch(&blob, bytes).ok();
279                    }
280                }
281                Seen {
282                    size,
283                    modified,
284                    hash,
285                    lines: bytes.map_or(0, |bytes| String::from_utf8_lossy(&bytes).lines().count()),
286                }
287            }
288        };
289        seen.insert(path, file);
290    }
291    Ok(seen)
292}
293
294/// Whether a file last changed at `modified` had not just changed when a
295/// scan began at `began`, so a write after the scan read it changes its
296/// time: older than `began` by more than its time's precision, two seconds
297/// for a time in whole seconds (FAT keeps even seconds, some file systems
298/// whole ones), else a tenth of a second (the kernel's clock for file
299/// times ticks every few milliseconds). Times come from this machine's
300/// clock and the workspace's file system; for a workspace on another
301/// machine, a clock ahead of this one only hashes more.
302fn settled_before(modified: SystemTime, began: SystemTime) -> bool {
303    let whole_seconds = modified
304        .duration_since(SystemTime::UNIX_EPOCH)
305        .is_ok_and(|since| since.subsec_nanos() == 0);
306    let precision = if whole_seconds {
307        Duration::from_secs(2)
308    } else {
309        Duration::from_millis(100)
310    };
311    began
312        .checked_sub(precision)
313        .is_some_and(|settled| modified < settled)
314}
315
316/// What changed from the scan `before` to the scan `after`; with `blobs`,
317/// where both kept their content, each change with its hunks and text
318/// before.
319fn scan_changes(
320    before: &BTreeMap<PathBuf, Seen>,
321    after: &BTreeMap<PathBuf, Seen>,
322    blobs: Option<&Path>,
323) -> Vec<FileChange> {
324    let paths: BTreeSet<&PathBuf> = before.keys().chain(after.keys()).collect();
325    paths
326        .into_iter()
327        .filter_map(|path| {
328            let shown = path.to_string_lossy();
329            let (was, now) = (before.get(path), after.get(path));
330            match (was, now) {
331                (Some(was), Some(now)) if was.hash.is_some() && was.hash == now.hash => {
332                    return None;
333                }
334                (Some(was), Some(now)) if was.hash.is_none() && was == now => return None,
335                (None, None) => return None,
336                _ => {}
337            }
338            let kept = |seen: Option<&Seen>| match (blobs, seen) {
339                (_, None) => Some(None),
340                (
341                    Some(blobs),
342                    Some(Seen {
343                        hash: Some(hash), ..
344                    }),
345                ) => std::fs::read(blobs.join(&hash.0)).ok().map(Some),
346                _ => None,
347            };
348            if let (Some(was_bytes), Some(now_bytes)) = (kept(was), kept(now)) {
349                return Some(change(&shown, was_bytes, now_bytes.as_deref()));
350            }
351            let mut change = match (was, now) {
352                (None, Some(now)) => FileChange::created(shown, now.lines),
353                (Some(was), None) => FileChange::deleted(shown, was.lines),
354                _ => FileChange::modified(shown, 0, 0),
355            };
356            change.before = was.map(Seen::state);
357            change.after = now.map(Seen::state);
358            Some(change)
359        })
360        .collect()
361}
362
363/// A git work tree, reached with a private index and object directory: the
364/// repository's objects are read as an alternate and never written.
365#[derive(Clone, Debug)]
366struct Git {
367    /// The work tree's top directory.
368    top: PathBuf,
369    /// The repository's git directory.
370    dir: PathBuf,
371    /// The repository's object directory, read as an alternate.
372    objects: PathBuf,
373    /// The snapshotted root as the caller gave it, which names changes.
374    root: PathBuf,
375    /// The snapshotted root, below `top` (empty for all of it).
376    below: PathBuf,
377    /// The private directory: `index` and `objects` in it.
378    keep: PathBuf,
379    /// How `git` starts.
380    launcher: Launcher,
381}
382
383impl Git {
384    /// The git work tree `root` is in, with a private directory under
385    /// `keep`; `None` when it is in none, the work tree ignores it (such as
386    /// a project under an enclosing repository's `target`), or git cannot
387    /// be run. `git` starts as `launcher` starts processes.
388    fn find(root: &Path, keep: &Path, launcher: &Launcher) -> Option<Self> {
389        let git = |args: &[&OsStr]| {
390            launcher
391                .command("git")
392                .arg("-C")
393                .arg(root)
394                .args(args)
395                .env_remove("GIT_DIR")
396                .env_remove("GIT_WORK_TREE")
397                .env_remove("GIT_INDEX_FILE")
398                .env_remove("GIT_OBJECT_DIRECTORY")
399                .stdin(Stdio::null())
400                .stderr(Stdio::null())
401                .output()
402                .ok()
403        };
404        let output = git(&[
405            "rev-parse",
406            "--show-toplevel",
407            "--absolute-git-dir",
408            "--git-path",
409            "objects",
410        ]
411        .map(OsStr::new))
412        .filter(|output| output.status.success())?;
413        let text = String::from_utf8(output.stdout).ok()?;
414        let mut lines = text.lines();
415        let (top, dir, objects) = (lines.next()?, lines.next()?, lines.next()?);
416        let top = PathBuf::from(top);
417        let canonical = std::fs::canonicalize(root).ok()?;
418        let below = canonical.strip_prefix(&top).ok()?.to_path_buf();
419        // `git add` refuses an ignored root, even with `--ignore-errors`.
420        let check = ["check-ignore", "-q", "--"].map(OsStr::new);
421        if !below.as_os_str().is_empty()
422            && git(&[check.as_slice(), &[canonical.as_os_str()]].concat())
423                .is_some_and(|output| output.status.success())
424        {
425            return None;
426        }
427        let keep = private(keep, &top);
428        Some(Self {
429            objects: canonical.join(objects),
430            root: root.to_path_buf(),
431            top,
432            dir: PathBuf::from(dir),
433            below,
434            keep,
435            launcher: launcher.clone(),
436        })
437    }
438
439    /// A git command on the work tree, with the private index and objects,
440    /// started as the launcher starts processes.
441    fn command(&self, args: &[&OsStr]) -> Command {
442        let mut command = self.launcher.command("git");
443        command
444            .arg("--git-dir")
445            .arg(&self.dir)
446            .arg("--work-tree")
447            .arg(&self.top)
448            .args([
449                "-c",
450                "core.fsmonitor=false",
451                "-c",
452                "core.untrackedCache=false",
453            ])
454            .args(args)
455            .current_dir(&self.top)
456            .env("GIT_INDEX_FILE", self.keep.join("index"))
457            .env("GIT_OBJECT_DIRECTORY", self.keep.join("objects"))
458            .env("GIT_ALTERNATE_OBJECT_DIRECTORIES", &self.objects)
459            .env("GIT_OPTIONAL_LOCKS", "0")
460            .env_remove("GIT_DIR")
461            .env_remove("GIT_WORK_TREE")
462            .stdin(Stdio::null());
463        command
464    }
465
466    /// What `args` prints, or why it failed.
467    fn run(&self, args: &[&OsStr]) -> io::Result<Vec<u8>> {
468        let output = self.command(args).output()?;
469        match output.status.success() {
470            true => Ok(output.stdout),
471            false => Err(io::Error::other(format!(
472                "git {} failed: {}",
473                args.first()
474                    .map_or(std::borrow::Cow::Borrowed(""), |it| it.to_string_lossy()),
475                String::from_utf8_lossy(&output.stderr).trim()
476            ))),
477        }
478    }
479
480    /// Adds the root's files, and every file under each of `also` that is
481    /// there, to the private index and writes its tree.
482    fn write_tree(&self, also: &[PathBuf]) -> io::Result<String> {
483        // Calls checkpointed at once share the private index, whose lock
484        // `git add` takes and fails on when another holds it.
485        static INDEX: Mutex<()> = Mutex::new(());
486        let _index = INDEX.lock().unwrap_or_else(PoisonError::into_inner);
487        std::fs::create_dir_all(self.keep.join("objects"))?;
488        let below = match self.below.as_os_str().is_empty() {
489            true => OsStr::new("."),
490            false => self.below.as_os_str(),
491        };
492        let add = ["add", "--all", "--ignore-errors", "--"].map(OsStr::new);
493        self.run(&[add.as_slice(), &[below]].concat())?;
494        let also: Vec<PathBuf> = also
495            .iter()
496            .filter(|path| path.exists())
497            .filter_map(|path| Some(self.below.join(path.strip_prefix(&self.root).ok()?)))
498            .collect();
499        if !also.is_empty() {
500            let force = ["add", "--all", "--force", "--ignore-errors", "--"].map(OsStr::new);
501            let also = also.iter().map(|path| path.as_os_str());
502            self.run(&force.into_iter().chain(also).collect::<Vec<_>>())?;
503        }
504        let tree = self.run(&[OsStr::new("write-tree")])?;
505        Ok(String::from_utf8_lossy(&tree).trim().to_owned())
506    }
507
508    /// The bytes of each object in `ids` (an id, or `tree:path`), in order,
509    /// read by one `git cat-file --batch`.
510    fn blobs(&self, ids: &[&str]) -> io::Result<Vec<Vec<u8>>> {
511        if ids.is_empty() {
512            return Ok(Vec::new());
513        }
514        let mut child = self
515            .command(&["cat-file", "--batch=%(objectsize)"].map(OsStr::new))
516            .stdin(Stdio::piped())
517            .stdout(Stdio::piped())
518            .stderr(Stdio::null())
519            .spawn()?;
520        let input: String = ids.iter().map(|id| format!("{id}\n")).collect();
521        let stdin = child.stdin.take();
522        // Written beside the read, so neither pipe fills while the other waits.
523        let output = std::thread::scope(|scope| {
524            scope.spawn(|| stdin.map(|mut stdin| stdin.write_all(input.as_bytes())));
525            child.wait_with_output()
526        })?;
527        let mut rest = output.stdout.as_slice();
528        let mut blobs = Vec::with_capacity(ids.len());
529        for id in ids {
530            // `<size>\n<bytes>\n`, or `<id> missing\n`.
531            let missing = || io::Error::new(ErrorKind::NotFound, format!("git has no {id}"));
532            let end = rest.iter().position(|byte| *byte == b'\n');
533            let header = rest.get(..end.ok_or_else(missing)?).unwrap_or_default();
534            let size: usize = String::from_utf8_lossy(header)
535                .parse()
536                .map_err(|_| missing())?;
537            let body = rest.get(header.len() + 1..).unwrap_or_default();
538            blobs.push(body.get(..size).ok_or_else(missing)?.to_vec());
539            rest = body.get(size + 1..).unwrap_or_default();
540        }
541        Ok(blobs)
542    }
543
544    /// The bytes `path`, under the root as given or the work tree's top,
545    /// held in `tree`.
546    fn content(&self, tree: &str, path: &Path) -> io::Result<Vec<u8>> {
547        let below = match path.strip_prefix(&self.root) {
548            Ok(rest) => self.below.join(rest),
549            Err(_) => path
550                .strip_prefix(&self.top)
551                .map_err(|_| {
552                    io::Error::new(ErrorKind::InvalidInput, "the path is outside the work tree")
553                })?
554                .to_path_buf(),
555        };
556        let spec = format!("{tree}:{}", below.to_string_lossy());
557        self.run(&["cat-file", "blob", &spec].map(OsStr::new))
558    }
559
560    /// The file git names `path` (below `top`), spelled under the root as
561    /// given.
562    fn shown(&self, path: &str) -> String {
563        match Path::new(path).strip_prefix(&self.below) {
564            Ok(rest) => self.root.join(rest),
565            Err(_) => self.top.join(path),
566        }
567        .to_string_lossy()
568        .into_owned()
569    }
570
571    /// What changed from the tree `before` to the tree `after`.
572    fn changes(&self, before: &str, after: &str) -> io::Result<Vec<FileChange>> {
573        let raw = self.run(
574            &[
575                "diff-tree",
576                "-r",
577                "-z",
578                "--no-renames",
579                "--raw",
580                before,
581                after,
582            ]
583            .map(OsStr::new),
584        )?;
585        let text = String::from_utf8_lossy(&raw);
586        let mut fields = text.split('\0');
587        let mut changed = Vec::new();
588        while let (Some(header), Some(path)) = (fields.next(), fields.next()) {
589            // `:old-mode new-mode old-id new-id status`
590            let mut parts = header.split(' ').skip(2);
591            if let (Some(old), Some(new)) = (parts.next(), parts.next())
592                && old != new
593            {
594                changed.push((path, old, new));
595            }
596        }
597        // An id of zeros is no file on that side.
598        let present = |id: &&str| id.bytes().any(|byte| byte != b'0');
599        let sides = changed.iter().flat_map(|(_, old, new)| [*old, *new]);
600        let mut blobs = self
601            .blobs(&sides.filter(present).collect::<Vec<_>>())?
602            .into_iter();
603        let mut side = |id: &str| present(&id).then(|| blobs.next()).flatten();
604        let changes = changed.into_iter().map(|(path, old, new)| {
605            let (was, now) = (side(old), side(new));
606            change(&self.shown(path), was, now.as_deref())
607        });
608        Ok(changes.collect())
609    }
610}
611
612/// The change from `before` to `after` (`None`: no file) of the file
613/// `path`, with its hunks and text before when both are small enough to
614/// diff.
615fn change(path: &str, before: Option<Vec<u8>>, after: Option<&[u8]>) -> FileChange {
616    let small =
617        |bytes: Option<&[u8]>| bytes.is_none_or(|bytes| bytes.len() as u64 <= MAX_FILE_BYTES);
618    if small(before.as_deref()) && small(after) {
619        return Previous::change(before.map(Previous::of), path, after);
620    }
621    let was = before.as_deref().map(FileState::of);
622    let now = after.map(FileState::of);
623    let lines = |state: &Option<FileState>| state.as_ref().and_then(|it| it.lines).unwrap_or(0);
624    let mut change = match (&was, &now) {
625        (_, None) => FileChange::deleted(path, lines(&was)),
626        (None, Some(_)) => FileChange::created(path, lines(&now)),
627        (Some(_), Some(_)) => FileChange::modified(path, 0, 0),
628    };
629    change.before = was;
630    change.after = now;
631    change
632}
633
634/// A handler that takes a [`Snapshot`] just before and just after its
635/// inner handler serves a call, and publishes what changed as
636/// [`FilesChanged`] beside the call's answer, unless the call published its
637/// own: so a call of any tool that may write anything, such as `shell`,
638/// reports what it changed.
639///
640/// What it reports is what changed while the call ran, by whatever wrote
641/// it: a process outside the call that wrote in that time, such as the
642/// user's editor, is in it. A change made between two calls is in neither.
643///
644/// `newest`, shared between the calls one host checkpoints with the same
645/// `root`, is the newest snapshot any of them took: each call hands it to [`snapshot`] as
646/// `earlier`, so a scan hashes again only what changed and git's private
647/// index stays warm, but never takes it as its "before". `first`, shared
648/// the same way, keeps the first "before" taken, the files as they were
649/// before the first of those calls, so the host can tell what all of them
650/// changed or restore a file. `before`, this call's own, keeps the
651/// snapshot taken just before it ran, so the host can tell what it alone
652/// changed later on or read a file as it found it. Snapshots run on a
653/// thread of their own; one that fails is logged, and the call publishes
654/// nothing. A call that streams its answer is served as it is, with
655/// nothing published.
656#[derive(Clone, Debug)]
657pub struct Checkpointed {
658    /// The handler that serves the call.
659    pub inner: ErasedHandler,
660    /// Where the files are.
661    pub workspace: Arc<dyn Workspace>,
662    /// The directory whose files are compared.
663    pub root: PathBuf,
664    /// How each snapshot is taken.
665    pub taking: Taking,
666    /// The newest snapshot taken by any call it shares this with: only
667    /// reused as `earlier`, never as a "before".
668    pub newest: Arc<Mutex<Option<Snapshot>>>,
669    /// The snapshot before the first call, once one was taken.
670    pub first: Arc<Mutex<Option<Snapshot>>>,
671    /// The snapshot taken just before this call ran, once taken.
672    pub before: Arc<Mutex<Option<Snapshot>>>,
673}
674
675impl Checkpointed {
676    /// A snapshot now, against `earlier`, off this thread.
677    async fn take(&self, earlier: Option<Snapshot>) -> Option<(Option<Snapshot>, Snapshot)> {
678        let (workspace, root, taking) = (
679            self.workspace.clone(),
680            self.root.clone(),
681            self.taking.clone(),
682        );
683        let taken = blocking(move || {
684            let now = snapshot(&*workspace, &root, &taking, earlier.as_ref())
685                .map_err(|error| rig_core::tool::ToolExecutionError::other(error.to_string()))?;
686            Ok((earlier, now))
687        })
688        .await;
689        taken
690            .inspect_err(|error| {
691                tracing::warn!(root = %self.root.display(), "no checkpoint of the call: {error}");
692            })
693            .ok()
694    }
695}
696
697impl Serve for Checkpointed {
698    type Family = rig_core::effect::family::Dynamic;
699
700    fn descriptor(&self) -> HandlerDescriptor {
701        self.inner.descriptor()
702    }
703
704    fn key(&self) -> HandlerKey {
705        self.inner.key().clone()
706    }
707
708    async fn serve(&self, kind: EffectKind, dispatch: Dispatch) -> Reply {
709        let published = dispatch.scope::<PublishedContext>();
710        let newest = self
711            .newest
712            .lock()
713            .unwrap_or_else(PoisonError::into_inner)
714            .clone();
715        let before = self.take(newest).await.map(|(_, now)| now);
716        if let Some(before) = &before {
717            self.first
718                .lock()
719                .unwrap_or_else(PoisonError::into_inner)
720                .get_or_insert_with(|| before.clone());
721            *self.newest.lock().unwrap_or_else(PoisonError::into_inner) = Some(before.clone());
722            *self.before.lock().unwrap_or_else(PoisonError::into_inner) = Some(before.clone());
723        }
724        let reply = self.inner.handle(kind, dispatch).await;
725        if !matches!(reply, Reply::Outcome(_)) {
726            return reply;
727        }
728        let Some((Some(before), after)) = self.take(before).await else {
729            return reply;
730        };
731        let changes = after
732            .changes_since(&before)
733            .inspect_err(|error| {
734                tracing::warn!(root = %self.root.display(), "no changes of the call: {error}");
735            })
736            .unwrap_or_default();
737        *self.newest.lock().unwrap_or_else(PoisonError::into_inner) = Some(after);
738        if let Some(published) = published {
739            let own = published
740                .result_context()
741                .and_then(|context| context.get::<FilesChanged>().ok().flatten());
742            if own.is_none() && !changes.is_empty() {
743                published.insert(FilesChanged(changes)).ok();
744            }
745        }
746        reply
747    }
748
749    fn footprint(&self, kind: &EffectKind) -> Option<Touches> {
750        self.inner.footprint(kind)
751    }
752
753    fn check(&self, kind: &EffectKind) -> Vec<UnsupportedOption> {
754        self.inner.check(kind)
755    }
756}
757
758#[cfg(test)]
759mod tests;