Skip to main content

agentlink_domain/
model.rs

1//! Core vocabulary: what can be shared, and how it gets put in place.
2
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6
7use crate::path::RelPath;
8
9/// A class of configuration that agents can share.
10///
11/// Adding a variant is the main axis of growth for this project; see
12/// `docs/adr/0004-resource-kinds.md` for the criteria a new resource must meet.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum ResourceKind {
16    /// Project-wide instructions. Canonically `AGENTS.md`.
17    Instructions,
18    /// Agent Skills directories, each holding a `SKILL.md`. Canonically
19    /// `.agents/skills`.
20    Skills,
21}
22
23impl ResourceKind {
24    /// Every resource kind, in stable display order.
25    pub const ALL: &'static [ResourceKind] = &[Self::Instructions, Self::Skills];
26
27    pub const fn as_str(self) -> &'static str {
28        match self {
29            Self::Instructions => "instructions",
30            Self::Skills => "skills",
31        }
32    }
33
34    /// Whether the canonical resource is a file or a directory.
35    ///
36    /// This decides which linking primitives are even applicable: directories can
37    /// use junctions on Windows, files cannot.
38    pub const fn node(self) -> NodeKind {
39        match self {
40            Self::Instructions => NodeKind::File,
41            Self::Skills => NodeKind::Dir,
42        }
43    }
44}
45
46impl fmt::Display for ResourceKind {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        f.write_str(self.as_str())
49    }
50}
51
52impl std::str::FromStr for ResourceKind {
53    type Err = String;
54
55    fn from_str(s: &str) -> Result<Self, Self::Err> {
56        Self::ALL
57            .iter()
58            .copied()
59            .find(|kind| kind.as_str() == s)
60            .ok_or_else(|| format!("unknown resource `{s}`"))
61    }
62}
63
64/// Whether a filesystem entry is a file or a directory.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "kebab-case")]
67pub enum NodeKind {
68    File,
69    Dir,
70}
71
72/// How a provider obtains a resource.
73///
74/// Declared per capability in a provider manifest. The planner turns a strategy
75/// into a concrete [`Via`] once it knows what the host filesystem supports.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum Strategy {
79    /// The tool already reads the canonical path. Nothing is written, ever.
80    ///
81    /// This is the best possible outcome and the reason the canonical layout was
82    /// chosen to match the emerging standards rather than invent a new one.
83    Native,
84    /// Same format, different location: materialise a filesystem link so both
85    /// paths resolve to one inode.
86    Link,
87    /// Same format, but write a small stub that references the canonical file
88    /// using the tool's own include syntax. Used where linking is impossible.
89    Import,
90}
91
92impl Strategy {
93    pub const fn as_str(self) -> &'static str {
94        match self {
95            Self::Native => "native",
96            Self::Link => "link",
97            Self::Import => "import",
98        }
99    }
100}
101
102impl fmt::Display for Strategy {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        f.write_str(self.as_str())
105    }
106}
107
108/// The concrete mechanism used to materialise a capability.
109///
110/// Note the deliberate absence of hard links: they are indistinguishable from
111/// regular files after the fact, and any editor performing an atomic save
112/// (write-temp-then-rename) silently breaks the association, leaving two files
113/// that diverge with no way to detect it. See `docs/adr/0003-link-primitives.md`.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "kebab-case")]
116pub enum Via {
117    /// A symbolic link storing a workspace-relative target.
118    Symlink,
119    /// A Windows directory junction. Needs no privileges, but stores an absolute
120    /// target, so it must be rebuilt if the workspace moves.
121    Junction,
122    /// A stub file containing a tool-specific include directive.
123    Import,
124}
125
126impl Via {
127    pub const fn as_str(self) -> &'static str {
128        match self {
129            Self::Symlink => "symlink",
130            Self::Junction => "junction",
131            Self::Import => "import",
132        }
133    }
134
135    /// Whether this mechanism produces a real filesystem link, and therefore
136    /// propagates edits, renames and deletions for free.
137    pub const fn is_link(self) -> bool {
138        matches!(self, Self::Symlink | Self::Junction)
139    }
140}
141
142impl fmt::Display for Via {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        f.write_str(self.as_str())
145    }
146}
147
148/// Which link primitives the host filesystem actually allows right now.
149///
150/// Probed once per run rather than assumed from the target triple: on Windows
151/// symlink creation depends on Developer Mode or an elevated process, so two
152/// machines with the same OS can differ.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub struct LinkSupport {
155    pub symlink_file: bool,
156    pub symlink_dir: bool,
157    pub junction: bool,
158}
159
160impl LinkSupport {
161    /// A host where every primitive works, as on a typical Unix system.
162    pub const FULL: Self = Self {
163        symlink_file: true,
164        symlink_dir: true,
165        junction: false,
166    };
167
168    /// A host where no link can be created; everything must fall back.
169    pub const NONE: Self = Self {
170        symlink_file: false,
171        symlink_dir: false,
172        junction: false,
173    };
174
175    /// Windows without symlink privileges: junctions still work for directories.
176    pub const JUNCTION_ONLY: Self = Self {
177        symlink_file: false,
178        symlink_dir: false,
179        junction: true,
180    };
181
182    /// The preferred mechanism for linking a node of the given kind, if any.
183    ///
184    /// Symlinks win when available because they are portable, relative and
185    /// unambiguous to detect. Junctions are the directory-only fallback.
186    pub const fn best_for(self, node: NodeKind) -> Option<Via> {
187        match node {
188            NodeKind::File if self.symlink_file => Some(Via::Symlink),
189            NodeKind::Dir if self.symlink_dir => Some(Via::Symlink),
190            // Junctions link directories only, which is exactly the case that
191            // matters most: on Windows they need no elevation at all.
192            NodeKind::Dir if self.junction => Some(Via::Junction),
193            _ => None,
194        }
195    }
196}
197
198/// A filesystem entry as observed without following a trailing link.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct Entry {
201    pub node: NodeKind,
202    /// Present when the entry is a symlink or junction.
203    pub link: Option<LinkTarget>,
204}
205
206impl Entry {
207    pub fn file() -> Self {
208        Self {
209            node: NodeKind::File,
210            link: None,
211        }
212    }
213
214    pub fn dir() -> Self {
215        Self {
216            node: NodeKind::Dir,
217            link: None,
218        }
219    }
220
221    pub fn link(node: NodeKind, target: LinkTarget) -> Self {
222        Self {
223            node,
224            link: Some(target),
225        }
226    }
227
228    /// Whether this entry holds real content rather than pointing elsewhere.
229    pub fn is_concrete(&self) -> bool {
230        self.link.is_none()
231    }
232}
233
234/// Where a link points, as resolved by the filesystem adapter.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum LinkTarget {
237    /// Resolves to a path inside the workspace.
238    Inside(RelPath),
239    /// Resolves outside the workspace, or could not be expressed relatively.
240    /// Kept as a display string only — the domain never acts on it.
241    Outside(String),
242}
243
244impl fmt::Display for LinkTarget {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        match self {
247            Self::Inside(path) => write!(f, "{path}"),
248            Self::Outside(raw) => f.write_str(raw),
249        }
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn resource_node_kinds_drive_link_choice() {
259        assert_eq!(ResourceKind::Instructions.node(), NodeKind::File);
260        assert_eq!(ResourceKind::Skills.node(), NodeKind::Dir);
261    }
262
263    #[test]
264    fn symlinks_are_preferred_when_available() {
265        assert_eq!(
266            LinkSupport::FULL.best_for(NodeKind::Dir),
267            Some(Via::Symlink)
268        );
269        assert_eq!(
270            LinkSupport::FULL.best_for(NodeKind::File),
271            Some(Via::Symlink)
272        );
273    }
274
275    #[test]
276    fn windows_without_privileges_still_links_directories() {
277        // This is the case that makes the whole design viable on Windows: the
278        // highest-value resource (skills) is a directory, and junctions have
279        // never required elevation.
280        assert_eq!(
281            LinkSupport::JUNCTION_ONLY.best_for(NodeKind::Dir),
282            Some(Via::Junction)
283        );
284        // Files have no link primitive here, so they must fall back to `import`.
285        assert_eq!(LinkSupport::JUNCTION_ONLY.best_for(NodeKind::File), None);
286    }
287
288    #[test]
289    fn no_support_yields_no_mechanism() {
290        assert_eq!(LinkSupport::NONE.best_for(NodeKind::Dir), None);
291        assert_eq!(LinkSupport::NONE.best_for(NodeKind::File), None);
292    }
293
294    #[test]
295    fn only_real_links_propagate_edits() {
296        assert!(Via::Symlink.is_link());
297        assert!(Via::Junction.is_link());
298        assert!(!Via::Import.is_link());
299    }
300}