use std::{
fs,
path::{Path, PathBuf},
};
use crate::domain::{
errors::{AgentError, AgentResult, ErrorCode},
policy::CommandPolicy,
protocol::DirectoryEntry,
};
#[derive(Debug, Clone)]
pub struct DirectoryBrowser {
home_dir: PathBuf,
policy: CommandPolicy,
}
impl DirectoryBrowser {
pub fn new(home_dir: impl Into<PathBuf>, policy: CommandPolicy) -> Self {
let home_dir: PathBuf = home_dir.into();
let home_dir = fs::canonicalize(&home_dir).unwrap_or(home_dir);
Self { home_dir, policy }
}
pub fn home_dir(&self) -> &Path {
&self.home_dir
}
pub fn validate(&self, path: &Path) -> AgentResult<PathBuf> {
if path.as_os_str().is_empty() {
return Err(AgentError::new(ErrorCode::PathDenied, "path is empty"));
}
let canonical = fs::canonicalize(path).map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
AgentError::new(
ErrorCode::DirectoryNotFound,
format!("{}: not found", path.display()),
)
} else {
AgentError::new(ErrorCode::PathDenied, format!("{}: {err}", path.display()))
}
})?;
if !canonical.is_dir() {
return Err(AgentError::new(
ErrorCode::NotADirectory,
format!("{}: not a directory", canonical.display()),
));
}
if canonical == self.home_dir
|| canonical.starts_with(&self.home_dir)
|| self.policy.is_under_allowed_workdir(&canonical)
{
Ok(canonical)
} else {
Err(AgentError::new(
ErrorCode::PathDenied,
format!(
"{}: not under home or allowed workdirs",
canonical.display()
),
))
}
}
pub fn list(&self, path: &Path) -> AgentResult<Vec<DirectoryEntry>> {
let canonical = self.validate(path)?;
let mut entries: Vec<DirectoryEntry> = Vec::new();
let read = fs::read_dir(&canonical).map_err(|err| {
AgentError::new(
ErrorCode::PathDenied,
format!("failed to read {}: {err}", canonical.display()),
)
})?;
for entry in read {
let entry = match entry {
Ok(entry) => entry,
Err(_) => continue,
};
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if !file_type.is_dir() || file_type.is_symlink() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if name.is_empty() || !is_visible_entry(&name) {
continue;
}
let has_children = has_child_entries(&entry.path());
entries.push(DirectoryEntry {
name,
path: entry.path().to_string_lossy().into_owned(),
is_dir: true,
has_children,
});
}
entries.sort_by(|left, right| {
right
.is_dir
.cmp(&left.is_dir)
.then(left.name.cmp(&right.name))
});
Ok(entries)
}
}
fn is_visible_entry(name: &str) -> bool {
if !name.starts_with('.') {
return true;
}
matches!(name, ".agents" | ".gemini")
}
fn has_child_entries(path: &Path) -> bool {
fs::read_dir(path)
.map(|iter| {
iter.flatten()
.any(|entry| is_visible_entry(&entry.file_name().to_string_lossy()))
})
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::policy::CommandPolicy;
use std::fs;
fn write(dir: &Path, name: &str) {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join(name), b"").unwrap();
}
#[test]
fn validate_accepts_paths_under_home() {
let home = tempfile::tempdir().unwrap();
let browser = DirectoryBrowser::new(home.path(), CommandPolicy::new(vec![], vec![]));
assert!(browser.validate(home.path()).is_ok());
}
#[test]
fn validate_rejects_path_outside_home() {
let home = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
let browser = DirectoryBrowser::new(home.path(), CommandPolicy::new(vec![], vec![]));
let err = browser.validate(other.path()).unwrap_err();
assert_eq!(err.code(), ErrorCode::PathDenied);
}
#[test]
fn validate_rejects_files() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("f.txt");
fs::write(&file, b"hi").unwrap();
let browser = DirectoryBrowser::new(dir.path(), CommandPolicy::new(vec![], vec![]));
let err = browser.validate(&file).unwrap_err();
assert_eq!(err.code(), ErrorCode::NotADirectory);
}
#[test]
fn list_returns_directories_alphabetically() {
let home = tempfile::tempdir().unwrap();
write(home.path(), "zeta.txt");
fs::create_dir(home.path().join("alpha")).unwrap();
fs::create_dir(home.path().join("beta")).unwrap();
let browser = DirectoryBrowser::new(home.path(), CommandPolicy::new(vec![], vec![]));
let entries = browser.list(home.path()).unwrap();
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "beta"]);
}
#[test]
fn list_omits_files_from_the_directory_tree() {
let home = tempfile::tempdir().unwrap();
fs::create_dir(home.path().join("project")).unwrap();
write(home.path(), "README.md");
let browser = DirectoryBrowser::new(home.path(), CommandPolicy::new(vec![], vec![]));
let entries = browser.list(home.path()).unwrap();
let names: Vec<&str> = entries.iter().map(|entry| entry.name.as_str()).collect();
assert_eq!(names, vec!["project"]);
}
#[test]
fn list_hides_hidden_entries_except_allowed() {
let home = tempfile::tempdir().unwrap();
fs::create_dir_all(home.path().join(".ssh")).unwrap();
fs::create_dir_all(home.path().join(".agents")).unwrap();
fs::create_dir_all(home.path().join("visible")).unwrap();
let browser = DirectoryBrowser::new(home.path(), CommandPolicy::new(vec![], vec![]));
let names: Vec<String> = browser
.list(home.path())
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
assert!(names.contains(&"visible".to_string()));
assert!(names.contains(&".agents".to_string()));
assert!(!names.contains(&".ssh".to_string()));
}
#[test]
fn list_marks_has_children_for_non_empty_dirs() {
let home = tempfile::tempdir().unwrap();
let populated = home.path().join("populated");
let empty = home.path().join("empty");
fs::create_dir_all(&populated).unwrap();
write(&populated, "f.txt");
fs::create_dir_all(&empty).unwrap();
let browser = DirectoryBrowser::new(home.path(), CommandPolicy::new(vec![], vec![]));
let entries = browser.list(home.path()).unwrap();
let populated_entry = entries.iter().find(|e| e.name == "populated").unwrap();
let empty_entry = entries.iter().find(|e| e.name == "empty").unwrap();
assert!(populated_entry.has_children);
assert!(!empty_entry.has_children);
assert!(populated_entry.is_dir);
}
}