Skip to main content

rig_core/tool/
footprint.rs

1//! What a tool call may read and change ([`Touches`]), so a host runs the
2//! calls of one reply that touch different things side by side, and holds
3//! back only those that conflict.
4//!
5//! ```
6//! use std::path::PathBuf;
7//! use rig_core::tool::{Scope, Touches};
8//!
9//! let paths = |path: &str| Scope::Paths(vec![PathBuf::from(path)]);
10//! let edit = Touches::Writes(paths("/work/src/a.rs"));
11//! assert!(!edit.conflicts_with(&Touches::Reads(paths("/work/src/b.rs"))));
12//! assert!(edit.conflicts_with(&Touches::Reads(paths("/work/src"))));
13//! assert!(edit.conflicts_with(&Touches::Independent));
14//! assert!(!Touches::Reads(Scope::Anything).conflicts_with(&Touches::Independent));
15//! ```
16
17use std::path::{Component, PathBuf};
18
19use serde::{Deserialize, Serialize};
20
21use crate::completion::ToolAnnotations;
22
23/// What a tool call may read and change, so calls that touch different
24/// things run side by side.
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum Touches {
28    /// Reads what the scope names and changes nothing: run again after a
29    /// restart.
30    Reads(Scope),
31    /// Reads and may change what the scope names.
32    Writes(Scope),
33    /// Changes nothing the other calls of its reply see, such as handing
34    /// work to another agent. It may read anything, so it waits behind an
35    /// earlier writer; it is never run again after a restart.
36    Independent,
37}
38
39/// What a footprint names: these paths (absolute; a directory covers what
40/// is under it), or anything at all. A guard compares paths as they are,
41/// so a host makes a footprint [`normalized`](Touches::normalized) before
42/// any guard sees it; the rig kernel does so for every tool call.
43///
44/// Normalizing folds `.` and `..` by the text alone and follows no link,
45/// so a tool declares canonical paths: the files it will open once links
46/// are resolved, as rig-tools' file tools do through their workspace's
47/// `canonical`. A guard that must not be fooled by a link a tool did not
48/// resolve canonicalizes the paths it compares itself.
49#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum Scope {
52    /// These files and directories, canonical and absolute.
53    Paths(Vec<PathBuf>),
54    /// Anything at all.
55    Anything,
56}
57
58impl Scope {
59    /// Whether the two name something in common: a path is, or is under, a
60    /// path of the other, compared component by component; `Anything`
61    /// meets everything.
62    pub fn overlaps(&self, other: &Scope) -> bool {
63        match (self, other) {
64            (Self::Paths(ours), Self::Paths(theirs)) => ours.iter().any(|ours| {
65                theirs
66                    .iter()
67                    .any(|theirs| ours.starts_with(theirs) || theirs.starts_with(ours))
68            }),
69            (Self::Anything, _) | (_, Self::Anything) => true,
70        }
71    }
72
73    /// The scope with each path's `.` and `..` folded away, by the path's
74    /// text alone, so `/work/src/../../etc` names `/etc`, as a guard
75    /// comparing paths must see it. A relative path names no place a guard
76    /// can check, so a scope with one is [`Anything`](Self::Anything).
77    pub fn normalized(self) -> Scope {
78        match self {
79            Self::Paths(paths) => paths
80                .into_iter()
81                .map(normal)
82                .collect::<Option<Vec<_>>>()
83                .map_or(Self::Anything, Self::Paths),
84            Self::Anything => Self::Anything,
85        }
86    }
87}
88
89/// `path` with its `.` and `..` folded away, or `None` when it is relative.
90/// `..` at the root stays at the root.
91fn normal(path: PathBuf) -> Option<PathBuf> {
92    if !path.is_absolute() {
93        return None;
94    }
95    let mut normal = PathBuf::new();
96    for component in path.components() {
97        match component {
98            Component::ParentDir => {
99                normal.pop();
100            }
101            Component::CurDir => {}
102            Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
103                normal.push(component);
104            }
105        }
106    }
107    Some(normal)
108}
109
110impl Touches {
111    /// Whether a call touching `self` must not run beside one touching
112    /// `other`: one of them writes what the other reads or writes.
113    /// [`Independent`](Self::Independent) counts as `Reads(Anything)` here.
114    pub fn conflicts_with(&self, other: &Touches) -> bool {
115        let (ours, we_write) = self.parts();
116        let (theirs, they_write) = other.parts();
117        (we_write || they_write) && ours.overlaps(theirs)
118    }
119
120    /// Whether it only reads: [`Reads`](Self::Reads), and nothing else.
121    pub fn reads_only(&self) -> bool {
122        matches!(self, Self::Reads(_))
123    }
124
125    /// The footprint with its scope [`normalized`](Scope::normalized).
126    pub fn normalized(self) -> Touches {
127        match self {
128            Self::Reads(scope) => Self::Reads(scope.normalized()),
129            Self::Writes(scope) => Self::Writes(scope.normalized()),
130            Self::Independent => Self::Independent,
131        }
132    }
133
134    /// From the annotations a tool's own code declares, such as a
135    /// [`Tool`](super::Tool)'s: `Reads(Anything)` when it says it only
136    /// reads, else `Writes(Anything)`. For annotations from elsewhere, such
137    /// as an MCP server's, [`from_hints`](Self::from_hints).
138    pub fn from_annotations(annotations: &ToolAnnotations) -> Self {
139        match annotations.read_only() {
140            true => Self::Reads(Scope::Anything),
141            false => Self::Writes(Scope::Anything),
142        }
143    }
144
145    /// From annotations rig cannot check, such as those an MCP server
146    /// sends: a tool that says it only reads is
147    /// [`Independent`](Self::Independent), so its calls still run beside
148    /// readers but are never run again after a restart; any other is
149    /// `Writes(Anything)`. Only a local declaration makes a call
150    /// [`Reads`](Self::Reads).
151    pub fn from_hints(annotations: &ToolAnnotations) -> Self {
152        match annotations.read_only() {
153            true => Self::Independent,
154            false => Self::Writes(Scope::Anything),
155        }
156    }
157
158    /// What it names, and whether it writes there.
159    fn parts(&self) -> (&Scope, bool) {
160        match self {
161            Self::Reads(scope) => (scope, false),
162            Self::Writes(scope) => (scope, true),
163            Self::Independent => (&Scope::Anything, false),
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests;