#![forbid(unsafe_code)]
#![warn(missing_docs, missing_debug_implementations)]
use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
use agentlink_domain::model::{Entry, LinkSupport, LinkTarget, NodeKind, Via};
use agentlink_domain::path::RelPath;
use agentlink_domain::workspace::{FsError, FsResult, Workspace};
#[derive(Debug, Clone)]
pub struct RootedWorkspace {
root: PathBuf,
support: LinkSupport,
}
impl RootedWorkspace {
pub fn open(root: impl Into<PathBuf>) -> io::Result<Self> {
let root = root.into();
let root = fs::canonicalize(&root).map_or(root, |resolved| strip_verbatim(&resolved));
Ok(Self {
root,
support: probe_support(),
})
}
pub fn with_support(root: impl Into<PathBuf>, support: LinkSupport) -> Self {
Self {
root: root.into(),
support,
}
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn native(&self, path: &RelPath) -> PathBuf {
let mut native = self.root.clone();
for segment in path.segments() {
native.push(segment);
}
native
}
pub fn stale_junctions(&self, candidates: &[RelPath]) -> Vec<(RelPath, String)> {
candidates
.iter()
.filter_map(|path| match self.probe(path) {
Ok(Some(Entry {
link: Some(LinkTarget::Outside(target)),
..
})) => Some((path.clone(), target)),
_ => None,
})
.collect()
}
fn interpret_target(&self, link: &RelPath, raw: &Path) -> LinkTarget {
let resolved = if raw.is_absolute() {
strip_verbatim(raw)
} else {
let mut base = match link.parent() {
Some(parent) => self.native(&parent),
None => self.root.clone(),
};
base.push(raw);
base
};
let normalised = normalise(&resolved);
let root = normalise(&strip_verbatim(&self.root));
match normalised.strip_prefix(&root) {
Ok(relative) => {
let text = relative.to_string_lossy().replace('\\', "/");
match RelPath::new(&text) {
Ok(rel) => LinkTarget::Inside(rel),
Err(_) => LinkTarget::Outside(normalised.to_string_lossy().into_owned()),
}
}
Err(_) => LinkTarget::Outside(normalised.to_string_lossy().into_owned()),
}
}
}
impl Workspace for RootedWorkspace {
fn probe(&self, path: &RelPath) -> FsResult<Option<Entry>> {
let native = self.native(path);
let meta = match fs::symlink_metadata(&native) {
Ok(meta) => meta,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(FsError::new("inspect", path, err)),
};
if !meta.file_type().is_symlink() {
return Ok(Some(if meta.is_dir() {
Entry::dir()
} else {
Entry::file()
}));
}
let raw =
fs::read_link(&native).map_err(|err| FsError::new("read the link at", path, err))?;
let node = link_node_kind(&native, &meta);
Ok(Some(Entry::link(node, self.interpret_target(path, &raw))))
}
fn read(&self, path: &RelPath) -> FsResult<String> {
fs::read_to_string(self.native(path)).map_err(|err| FsError::new("read", path, err))
}
fn write(&self, path: &RelPath, contents: &str) -> FsResult<()> {
if let Some(parent) = path.parent() {
self.create_dir_all(&parent)?;
}
fs::write(self.native(path), contents).map_err(|err| FsError::new("write", path, err))
}
fn create_dir_all(&self, path: &RelPath) -> FsResult<()> {
fs::create_dir_all(self.native(path))
.map_err(|err| FsError::new("create the directory", path, err))
}
fn link(
&self,
via: Via,
node: NodeKind,
canonical: &RelPath,
target: &RelPath,
) -> FsResult<()> {
if let Some(parent) = target.parent() {
self.create_dir_all(&parent)?;
}
let link_path = self.native(target);
match via {
Via::Symlink => {
let relative = canonical.relative_to_dir(target.parent().as_ref());
create_symlink(&relative, &link_path, node)
.map_err(|err| FsError::new("create a symlink at", target, err))
}
Via::Junction => {
create_junction(&self.native(canonical), &link_path)
.map_err(|err| FsError::new("create a junction at", target, err))
}
Via::Import => Err(FsError::new(
"create a link at",
target,
io::Error::new(
io::ErrorKind::InvalidInput,
"`import` writes a file and must not be routed through link()",
),
)),
}
}
fn remove_link(&self, path: &RelPath, node: NodeKind) -> FsResult<()> {
let native = self.native(path);
let meta = fs::symlink_metadata(&native)
.map_err(|err| FsError::new("inspect the link at", path, err))?;
if !meta.file_type().is_symlink() {
return Err(FsError::new(
"remove the link at",
path,
io::Error::new(
io::ErrorKind::InvalidInput,
"refusing to remove: this path holds real content, not a link",
),
));
}
remove_link_native(&native, node, &meta)
.map_err(|err| FsError::new("remove the link at", path, err))
}
fn remove_file(&self, path: &RelPath) -> FsResult<()> {
fs::remove_file(self.native(path)).map_err(|err| FsError::new("remove", path, err))
}
fn remove_empty_dir(&self, path: &RelPath) -> FsResult<()> {
fs::remove_dir(self.native(path))
.map_err(|err| FsError::new("remove the directory", path, err))
}
fn rename(&self, from: &RelPath, to: &RelPath) -> FsResult<()> {
if let Some(parent) = to.parent() {
self.create_dir_all(&parent)?;
}
fs::rename(self.native(from), self.native(to))
.map_err(|err| FsError::new("move", from, err))
}
fn is_empty_dir(&self, path: &RelPath) -> FsResult<bool> {
let mut entries =
fs::read_dir(self.native(path)).map_err(|err| FsError::new("list", path, err))?;
Ok(entries.next().is_none())
}
fn support(&self) -> LinkSupport {
self.support
}
}
fn strip_verbatim(path: &Path) -> PathBuf {
let text = path.to_string_lossy();
match text.strip_prefix(r"\\?\") {
Some(rest) => PathBuf::from(rest),
None => path.to_path_buf(),
}
}
fn normalise(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(windows)]
fn link_node_kind(_native: &Path, meta: &fs::Metadata) -> NodeKind {
use std::os::windows::fs::FileTypeExt;
if meta.file_type().is_symlink_dir() {
NodeKind::Dir
} else {
NodeKind::File
}
}
#[cfg(not(windows))]
fn link_node_kind(native: &Path, _meta: &fs::Metadata) -> NodeKind {
match fs::metadata(native) {
Ok(meta) if meta.is_dir() => NodeKind::Dir,
_ => NodeKind::File,
}
}
#[cfg(windows)]
fn create_symlink(relative_target: &str, link: &Path, node: NodeKind) -> io::Result<()> {
use std::os::windows::fs::{symlink_dir, symlink_file};
let target = PathBuf::from(relative_target.replace('/', "\\"));
match node {
NodeKind::Dir => symlink_dir(target, link),
NodeKind::File => symlink_file(target, link),
}
}
#[cfg(not(windows))]
fn create_symlink(relative_target: &str, link: &Path, _node: NodeKind) -> io::Result<()> {
std::os::unix::fs::symlink(relative_target, link)
}
#[cfg(windows)]
fn create_junction(absolute_target: &Path, link: &Path) -> io::Result<()> {
junction::create(absolute_target, link)
}
#[cfg(not(windows))]
fn create_junction(_absolute_target: &Path, _link: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"junctions exist only on Windows",
))
}
#[cfg(windows)]
fn remove_link_native(native: &Path, _node: NodeKind, meta: &fs::Metadata) -> io::Result<()> {
use std::os::windows::fs::FileTypeExt;
if meta.file_type().is_symlink_dir() {
fs::remove_dir(native)
} else {
fs::remove_file(native)
}
}
#[cfg(not(windows))]
fn remove_link_native(native: &Path, _node: NodeKind, _meta: &fs::Metadata) -> io::Result<()> {
fs::remove_file(native)
}
#[cfg(windows)]
fn probe_support() -> LinkSupport {
LinkSupport {
symlink_file: can_symlink(),
symlink_dir: can_symlink(),
junction: true,
}
}
#[cfg(not(windows))]
fn probe_support() -> LinkSupport {
LinkSupport::FULL
}
#[cfg(windows)]
fn can_symlink() -> bool {
use std::os::windows::fs::symlink_file;
use std::time::{SystemTime, UNIX_EPOCH};
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
.wrapping_add(u128::from(std::process::id()));
let probe = std::env::temp_dir().join(format!("agentlink-symlink-probe-{nonce}"));
let allowed = symlink_file("agentlink-probe-target", &probe).is_ok();
let _ = fs::remove_file(&probe);
allowed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalise_resolves_parent_segments_lexically() {
assert_eq!(normalise(Path::new("a/b/../c")), PathBuf::from("a/c"));
assert_eq!(normalise(Path::new("./a/./b")), PathBuf::from("a/b"));
}
#[test]
fn strip_verbatim_removes_the_windows_prefix() {
assert_eq!(
strip_verbatim(Path::new(r"\\?\C:\repo")),
PathBuf::from(r"C:\repo")
);
assert_eq!(
strip_verbatim(Path::new("/home/repo")),
PathBuf::from("/home/repo")
);
}
}