agent-source-repository 0.1.0

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

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

pub(crate) fn normalize_requested_path(requested: &str) -> AsrResult<String> {
    let trimmed = requested.trim();
    if trimmed.is_empty() {
        return Err(AsrError::new("invalid_path", "Path must not be empty"));
    }
    if trimmed.contains('\0') {
        return Err(AsrError::with_path(
            "invalid_path",
            "Path must not contain NUL bytes",
            trimmed,
        ));
    }

    let normalized_input = trimmed.replace('\\', "/");
    let path = Path::new(&normalized_input);
    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::CurDir => {}
            Component::Normal(part) => {
                if part.to_string_lossy() == ".git" {
                    return Err(AsrError::with_path(
                        "invalid_path",
                        "ASR file commands do not expose Git internals",
                        trimmed,
                    ));
                }
                normalized.push(part)
            }
            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
                return Err(AsrError::with_path(
                    "invalid_path",
                    "Path must be relative and must not contain parent directory components",
                    trimmed,
                ));
            }
        }
    }
    if normalized.as_os_str().is_empty() {
        return Err(AsrError::new("invalid_path", "Path must not be empty"));
    }
    Ok(path_string(&normalized))
}

pub(crate) fn validate_top_k(top_k: usize, max_top_k: usize) -> AsrResult<()> {
    if top_k == 0 || top_k > max_top_k {
        return Err(AsrError::new(
            "invalid_top_k",
            format!("top-k must be between 1 and {max_top_k}"),
        ));
    }
    Ok(())
}

pub(crate) fn validate_context_budget(budget: usize, max_context_budget: usize) -> AsrResult<()> {
    if budget == 0 || budget > max_context_budget {
        return Err(AsrError::new(
            "invalid_budget",
            format!("budget must be between 1 and {max_context_budget}"),
        ));
    }
    Ok(())
}

pub(crate) fn validate_repo_name(name: &str) -> AsrResult<()> {
    if name.is_empty()
        || name.starts_with('.')
        || name.contains('/')
        || name.contains('\\')
        || !name
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
    {
        return Err(AsrError::new(
            "invalid_repo_name",
            "Repository name must use ASCII letters, numbers, '.', '-', or '_'",
        ));
    }
    Ok(())
}

pub(crate) fn validate_git_ref(value: &str, field: &'static str) -> AsrResult<()> {
    let trimmed = value.trim();
    let invalid = trimmed.is_empty()
        || trimmed != value
        || trimmed.len() > 256
        || trimmed.starts_with('-')
        || trimmed.contains('\0')
        || trimmed.contains('\\')
        || trimmed.contains(':')
        || trimmed.contains("..")
        || trimmed.contains("@{")
        || trimmed == "@"
        || trimmed.contains("--")
        || trimmed
            .chars()
            .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace());

    if invalid {
        return Err(AsrError::new(
            "invalid_git_ref",
            format!(
                "{field} Git ref must be a bounded commit-ish ref without whitespace, pathspecs, option prefixes, reflog selectors, or range syntax"
            ),
        ));
    }
    Ok(())
}