Skip to main content

agentlink_domain/
workspace.rs

1//! The filesystem port.
2//!
3//! The domain never touches `std::fs`. It talks to this trait, which an adapter
4//! implements against a real rooted directory (`agentlink-fs`) or against an
5//! in-memory map (tests). Every path crossing the boundary is a [`RelPath`], so
6//! the adapter owns all of the native-path, separator and privilege mess, and
7//! the planner stays a pure function of observable state.
8
9use crate::model::{Entry, LinkSupport, NodeKind, Via};
10use crate::path::RelPath;
11
12pub type FsResult<T> = Result<T, FsError>;
13
14/// A filesystem operation that failed, with enough context to be actionable.
15#[derive(Debug, thiserror::Error)]
16#[error("failed to {operation} `{path}`")]
17pub struct FsError {
18    pub operation: &'static str,
19    pub path: String,
20    #[source]
21    pub source: std::io::Error,
22}
23
24impl FsError {
25    pub fn new(operation: &'static str, path: &RelPath, source: std::io::Error) -> Self {
26        Self {
27            operation,
28            path: path.to_string(),
29            source,
30        }
31    }
32}
33
34/// A rooted view of a workspace directory.
35pub trait Workspace {
36    /// Inspects an entry *without* following a trailing link.
37    ///
38    /// Returns `None` when nothing exists at `path`. A dangling link still
39    /// returns `Some`, because for our purposes the link itself is the entry
40    /// that occupies the path.
41    fn probe(&self, path: &RelPath) -> FsResult<Option<Entry>>;
42
43    /// Reads a UTF-8 file, following links.
44    fn read(&self, path: &RelPath) -> FsResult<String>;
45
46    /// Writes a UTF-8 file, creating parent directories as needed.
47    fn write(&self, path: &RelPath, contents: &str) -> FsResult<()>;
48
49    /// Creates a directory and all missing parents.
50    fn create_dir_all(&self, path: &RelPath) -> FsResult<()>;
51
52    /// Creates a link at `target` resolving to `canonical`, creating `target`'s
53    /// parent directories as needed.
54    ///
55    /// The adapter decides how `canonical` is encoded: symlinks store a
56    /// workspace-relative path so the workspace stays movable, while Windows
57    /// junctions require an absolute one.
58    fn link(&self, via: Via, node: NodeKind, canonical: &RelPath, target: &RelPath)
59    -> FsResult<()>;
60
61    /// Removes a link, never recursing into its contents.
62    ///
63    /// Implementations must refuse to delete a concrete directory: destroying
64    /// user content is the one failure mode this tool cannot afford.
65    fn remove_link(&self, path: &RelPath, node: NodeKind) -> FsResult<()>;
66
67    /// Removes a regular file.
68    fn remove_file(&self, path: &RelPath) -> FsResult<()>;
69
70    /// Removes a directory, failing if it is not empty.
71    ///
72    /// Deliberately never recursive. Adoption needs to clear an empty canonical
73    /// directory before moving content onto it; nothing in agentlink has a reason
74    /// to delete a populated one.
75    fn remove_empty_dir(&self, path: &RelPath) -> FsResult<()>;
76
77    /// Moves an entry, creating the destination's parents as needed.
78    fn rename(&self, from: &RelPath, to: &RelPath) -> FsResult<()>;
79
80    /// Whether `path` is a directory containing no entries.
81    fn is_empty_dir(&self, path: &RelPath) -> FsResult<bool>;
82
83    /// Which link primitives this host currently permits.
84    fn support(&self) -> LinkSupport;
85}