regy 0.1.1

Private-by-default desktop agent for the Regy web interface
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);
    }
}