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}