use std::path::{Path, PathBuf};
use caliban_agent_core::ToolError;
#[derive(Debug, Clone)]
pub struct WorkspaceRoot {
root: PathBuf,
restrict_to_root: bool,
}
impl WorkspaceRoot {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
let root = root.into();
let root = std::fs::canonicalize(&root).unwrap_or(root);
Self {
root,
restrict_to_root: false,
}
}
pub fn current_dir() -> std::io::Result<Self> {
let cwd = std::env::current_dir()?;
Ok(Self::new(cwd))
}
#[must_use]
pub fn restricted(mut self) -> Self {
self.restrict_to_root = true;
self
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn is_restricted(&self) -> bool {
self.restrict_to_root
}
pub fn resolve(&self, input: &str) -> Result<PathBuf, ToolError> {
if input.is_empty() {
return Err(ToolError::invalid_input("empty path"));
}
let candidate: PathBuf = if input == "~" {
dirs::home_dir().ok_or_else(|| {
ToolError::invalid_input("~ used but home directory is unavailable")
})?
} else if let Some(rest) = input.strip_prefix("~/") {
let mut home = dirs::home_dir().ok_or_else(|| {
ToolError::invalid_input("~/ used but home directory is unavailable")
})?;
home.push(rest);
home
} else {
PathBuf::from(input)
};
let abs = if candidate.is_absolute() {
candidate
} else {
self.root.join(&candidate)
};
let canon = canonicalize_existing_ancestor(&abs);
if self.restrict_to_root && !canon.starts_with(&self.root) {
return Err(ToolError::invalid_input(format!(
"path {} is outside workspace root {}",
canon.display(),
self.root.display(),
)));
}
Ok(canon)
}
#[must_use]
pub fn relativize(&self, abs: &Path) -> PathBuf {
abs.strip_prefix(&self.root)
.map_or_else(|_| abs.to_path_buf(), Path::to_path_buf)
}
}
fn canonicalize_existing_ancestor(p: &Path) -> PathBuf {
let mut tail: Vec<&std::ffi::OsStr> = Vec::new();
let mut cur = p;
loop {
if let Ok(canon) = std::fs::canonicalize(cur) {
let mut full = canon;
for seg in tail.iter().rev() {
full.push(seg);
}
return full;
}
match (cur.file_name(), cur.parent()) {
(Some(name), Some(parent)) => {
tail.push(name);
cur = parent;
}
_ => return p.to_path_buf(),
}
}
}
#[must_use]
pub fn walk(root: &Path) -> ignore::Walk {
ignore::WalkBuilder::new(root)
.hidden(true)
.git_ignore(true)
.build()
}
#[must_use]
pub fn plural_suffix(count: usize, plural: &'static str) -> &'static str {
if count == 1 { "" } else { plural }
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn plural_suffix_singular_is_empty() {
assert_eq!(plural_suffix(1, "es"), "");
}
#[test]
fn plural_suffix_plural_uses_suffix() {
assert_eq!(plural_suffix(0, "s"), "s");
assert_eq!(plural_suffix(2, "es"), "es");
}
#[test]
fn walk_visits_files_under_root() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("a.txt"), b"x").unwrap();
let count = walk(tmp.path()).filter_map(Result::ok).count();
assert!(count >= 1);
}
#[test]
fn resolve_relative() {
let tmp = TempDir::new().unwrap();
let root = WorkspaceRoot::new(tmp.path());
let resolved = root.resolve("foo.txt").unwrap();
assert!(resolved.starts_with(root.root()));
}
#[test]
fn resolve_absolute_unrestricted() {
let tmp = TempDir::new().unwrap();
let root = WorkspaceRoot::new(tmp.path());
let resolved = root.resolve("/tmp").unwrap();
let _ = resolved;
}
#[test]
fn restricted_rejects_outside() {
let tmp = TempDir::new().unwrap();
let root = WorkspaceRoot::new(tmp.path()).restricted();
let err = root.resolve("/etc/passwd").unwrap_err();
assert!(matches!(err, ToolError::InvalidInput(_)));
}
#[test]
fn restricted_allows_inside() {
let tmp = TempDir::new().unwrap();
let root = WorkspaceRoot::new(tmp.path()).restricted();
let resolved = root.resolve("foo.txt").unwrap();
assert!(resolved.starts_with(root.root()));
}
#[test]
fn restricted_rejects_traversal() {
let tmp = TempDir::new().unwrap();
let inner = tmp.path().join("inner");
std::fs::create_dir_all(&inner).unwrap();
let root = WorkspaceRoot::new(&inner).restricted();
let err = root.resolve("../escape.txt").unwrap_err();
assert!(matches!(err, ToolError::InvalidInput(_)));
}
#[test]
fn empty_path_errors() {
let tmp = TempDir::new().unwrap();
let root = WorkspaceRoot::new(tmp.path());
let err = root.resolve("").unwrap_err();
assert!(matches!(err, ToolError::InvalidInput(_)));
}
#[test]
fn resolve_tilde_only() {
let tmp = TempDir::new().unwrap();
let root = WorkspaceRoot::new(tmp.path());
let resolved = root.resolve("~").unwrap();
if let Some(home) = dirs::home_dir() {
let expected = std::fs::canonicalize(&home).unwrap_or(home);
assert_eq!(resolved, expected);
}
}
#[test]
fn resolve_tilde_path() {
let tmp = TempDir::new().unwrap();
let root = WorkspaceRoot::new(tmp.path());
let resolved = root.resolve("~/foo.txt").unwrap();
if let Some(home) = dirs::home_dir() {
let canon_home = std::fs::canonicalize(&home).unwrap_or(home);
assert_eq!(resolved, canon_home.join("foo.txt"));
}
}
#[test]
fn resolve_tilde_in_restricted_mode_outside_root_rejected() {
let tmp = TempDir::new().unwrap();
let root = WorkspaceRoot::new(tmp.path()).restricted();
let err = root.resolve("~/notes.md").unwrap_err();
assert!(matches!(err, ToolError::InvalidInput(_)));
}
#[test]
fn resolve_no_tilde_unchanged() {
let tmp = TempDir::new().unwrap();
let root = WorkspaceRoot::new(tmp.path());
let resolved = root.resolve("subdir/file.txt").unwrap();
assert!(resolved.starts_with(root.root()));
assert!(resolved.ends_with("subdir/file.txt"));
}
}