agents-docs-manager 0.1.0

Manage AGENTS.md and Docs markdown indexes for agent-oriented repositories.
use std::fs;
use std::path::Path;

use regex::Regex;
use serde::Serialize;
use serde_json::json;

use crate::utils::error::{AppError, AppResult};
use crate::utils::index;
use crate::utils::paths::{self, validate_namespace};
use crate::utils::workspace::Workspace;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NamespaceConfig {
    pub naming_style_regex: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct InvalidNamespace {
    pub namespace: String,
    pub naming_style_regex: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct InvalidDocument {
    pub namespace: String,
    pub document: String,
    pub naming_style_regex: String,
}

impl NamespaceConfig {
    pub fn from_workspace(workspace: &Workspace) -> AppResult<Self> {
        let index = index::read(workspace)?;
        Ok(Self {
            naming_style_regex: index.naming_style_regex()?,
        })
    }
}

pub fn namespace_exists(workspace: &Workspace, namespace: &str) -> AppResult<bool> {
    validate_namespace(namespace)?;
    Ok(namespace_dir(workspace, namespace).is_dir())
}

pub fn namespace_dir(workspace: &Workspace, namespace: &str) -> std::path::PathBuf {
    workspace.documents_directory.join(namespace)
}

pub fn read(workspace: &Workspace, namespace: &str) -> AppResult<NamespaceConfig> {
    validate_namespace(namespace)?;
    NamespaceConfig::from_workspace(workspace)
}

pub fn compile_document_regex(pattern: &str) -> AppResult<Regex> {
    paths::compile_document_regex(pattern)
}

pub fn validate_document_name(
    namespace: &str,
    document_stem: &str,
    config: &NamespaceConfig,
) -> AppResult<()> {
    let regex = compile_document_regex(&config.naming_style_regex)?;
    if regex.is_match(document_stem) {
        return Ok(());
    }

    Err(AppError::validation(
        "invalid_document_name",
        "document name does not match naming_style",
        json!({
            "namespace": namespace,
            "document": document_stem,
            "naming_style_regex": config.naming_style_regex
        }),
    ))
}

pub fn validate_namespace_name_style(namespace: &str, config: &NamespaceConfig) -> AppResult<()> {
    let regex = compile_document_regex(&config.naming_style_regex)?;
    if regex.is_match(namespace) {
        return Ok(());
    }

    Err(AppError::validation(
        "invalid_namespace_name",
        "namespace name does not match naming_style",
        json!({
            "namespace": namespace,
            "naming_style_regex": config.naming_style_regex
        }),
    ))
}

pub fn list_namespaces(workspace: &Workspace) -> AppResult<Vec<String>> {
    index::list_namespaces(workspace)
}

pub fn list_document_stems(workspace: &Workspace, namespace: &str) -> AppResult<Vec<String>> {
    index::list_document_stems(workspace, namespace)
}

pub fn invalid_documents_for_regex(
    workspace: &Workspace,
    namespace: &str,
    regex_pattern: &str,
) -> AppResult<Vec<InvalidDocument>> {
    let config = NamespaceConfig {
        naming_style_regex: regex_pattern.to_string(),
    };
    let regex = compile_document_regex(&config.naming_style_regex)?;
    let mut invalid = Vec::new();

    for document in list_document_stems(workspace, namespace)? {
        if !regex.is_match(&document) {
            invalid.push(InvalidDocument {
                namespace: namespace.to_string(),
                document,
                naming_style_regex: regex_pattern.to_string(),
            });
        }
    }

    Ok(invalid)
}

pub fn all_invalid_documents(workspace: &Workspace) -> AppResult<Vec<InvalidDocument>> {
    let mut invalid = Vec::new();
    let config = NamespaceConfig::from_workspace(workspace)?;
    invalid.extend(invalid_documents_for_regex(
        workspace,
        "",
        &config.naming_style_regex,
    )?);

    for namespace in list_namespaces(workspace)? {
        let config = read(workspace, &namespace)?;
        invalid.extend(invalid_documents_for_regex(
            workspace,
            &namespace,
            &config.naming_style_regex,
        )?);
    }
    Ok(invalid)
}

pub fn all_invalid_namespaces(workspace: &Workspace) -> AppResult<Vec<InvalidNamespace>> {
    let config = NamespaceConfig::from_workspace(workspace)?;
    let regex = compile_document_regex(&config.naming_style_regex)?;
    let mut invalid = Vec::new();

    for namespace in list_namespaces(workspace)? {
        if !regex.is_match(&namespace) {
            invalid.push(InvalidNamespace {
                namespace,
                naming_style_regex: config.naming_style_regex.clone(),
            });
        }
    }

    Ok(invalid)
}

pub fn is_empty(path: &Path) -> AppResult<bool> {
    let mut entries = fs::read_dir(path).map_err(|error| {
        AppError::fs(
            "read_namespace_failed",
            "failed to read namespace directory",
            path.to_string_lossy(),
            &error,
        )
    })?;

    if let Some(entry) = entries.next() {
        entry.map_err(|error| {
            AppError::fs(
                "read_namespace_entry_failed",
                "failed to read namespace directory entry",
                path.to_string_lossy(),
                &error,
            )
        })?;
        return Ok(false);
    }

    Ok(true)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn regex_matches_complete_document_stem() {
        let config = NamespaceConfig {
            naming_style_regex: "[A-Z][A-Z0-9_]*".to_string(),
        };
        assert!(validate_document_name("Conventions", "CODE_STYLE", &config).is_ok());
        assert!(validate_document_name("Conventions", "CODE_STYLE_extra", &config).is_err());
        assert!(validate_document_name("Conventions", "code-style", &config).is_err());
    }
}