agent-source-repository 0.1.0

Agent Source Repository local context registry for coding agents
Documentation
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use super::{git, path_string, AsrError, AsrResult};

#[derive(Debug, Clone)]
pub struct AsrPaths {
    pub home: PathBuf,
    pub db_path: PathBuf,
    pub repos_local: PathBuf,
    pub repos_mirrors: PathBuf,
    pub index: PathBuf,
    pub exact_index: PathBuf,
    pub cache: PathBuf,
}

impl AsrPaths {
    pub fn resolve() -> AsrResult<Self> {
        let home = match env::var_os("ASR_HOME") {
            Some(value) if !value.is_empty() => absolutize_home_path(PathBuf::from(value))?,
            _ => dirs::home_dir()
                .map(|home| home.join(".asr"))
                .ok_or_else(|| AsrError::new("home_not_found", "Unable to resolve ASR_HOME"))?,
        };

        Ok(Self::from_home(home))
    }

    fn from_home(home: PathBuf) -> Self {
        Self {
            db_path: home.join("asr.sqlite"),
            repos_local: home.join("repos").join("local"),
            repos_mirrors: home.join("repos").join("mirrors"),
            index: home.join("index"),
            exact_index: home.join("index").join("exact"),
            cache: home.join("cache"),
            home,
        }
    }

    pub(crate) fn ensure_dirs(&self) -> AsrResult<Vec<String>> {
        let dirs = [
            self.home.as_path(),
            self.repos_local.as_path(),
            self.repos_mirrors.as_path(),
            self.index.as_path(),
            self.exact_index.as_path(),
            self.cache.as_path(),
        ];
        let mut created = Vec::new();
        for dir in dirs {
            if !dir.exists() {
                created.push(path_string(dir));
            }
            fs::create_dir_all(dir).map_err(|err| {
                AsrError::with_path(
                    "asr_home_create_failed",
                    format!("Failed to create ASR directory: {err}"),
                    path_string(dir),
                )
            })?;
        }
        created.sort();
        Ok(created)
    }

    pub(crate) fn create_all(&self) -> AsrResult<Vec<String>> {
        validate_asr_home_not_in_git_worktree(&self.home)?;
        self.ensure_dirs()
    }
}

fn absolutize_home_path(path: PathBuf) -> AsrResult<PathBuf> {
    if path.is_absolute() {
        return Ok(path);
    }
    let cwd = env::current_dir().map_err(|err| {
        AsrError::new(
            "asr_home_unreadable",
            format!("Unable to resolve relative ASR_HOME from current directory: {err}"),
        )
    })?;
    Ok(cwd.join(path))
}

fn validate_asr_home_not_in_git_worktree(home: &Path) -> AsrResult<()> {
    let Some(existing_parent) = nearest_existing_parent(home) else {
        return Ok(());
    };
    let Ok(git_root) = git::canonical_git_root(&existing_parent) else {
        return Ok(());
    };
    let canonical_parent = existing_parent.canonicalize().map_err(|err| {
        AsrError::with_path(
            "asr_home_unreadable",
            format!("ASR_HOME parent is unreadable: {err}"),
            path_string(&existing_parent),
        )
    })?;
    let suffix = home
        .strip_prefix(&existing_parent)
        .unwrap_or_else(|_| Path::new(""));
    let projected_home = canonical_parent.join(suffix);
    if projected_home.starts_with(&git_root) {
        return Err(AsrError::with_path(
            "asr_home_inside_git_repo",
            "ASR_HOME must not be located inside any Git worktree",
            path_string(&projected_home),
        ));
    }
    Ok(())
}

fn nearest_existing_parent(path: &Path) -> Option<PathBuf> {
    let mut current = Some(path);
    while let Some(candidate) = current {
        if candidate.exists() {
            return Some(candidate.to_path_buf());
        }
        current = candidate.parent();
    }
    None
}