agents-docs-manager 0.1.0

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

use serde_json::json;

use crate::utils::error::{AppError, AppResult};
use crate::utils::index::{self, DocumentEntry};
use crate::utils::workspace;

const START_MARKER: &str = "<!-- adm:docs-index:start -->";
const END_MARKER: &str = "<!-- adm:docs-index:end -->";

pub fn sync_index() -> AppResult<String> {
    let workspace = workspace::find()?;
    let documents = index::scan_documents(&workspace)?;
    let block = render_index_block(&workspace.documents_directory_name, &documents);
    let path = workspace.agents_file.clone();
    let relative_path = workspace::relative_path(&workspace, &path);

    let existing = if path.exists() {
        Some(fs::read_to_string(&path).map_err(|error| {
            AppError::fs(
                "read_index_file_failed",
                "failed to read index file",
                relative_path.clone(),
                &error,
            )
        })?)
    } else {
        None
    };

    let content = merge_index_block(existing.as_deref(), &block, &relative_path)?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|error| {
            AppError::fs(
                "create_index_directory_failed",
                "failed to create index file directory",
                workspace::relative_path(&workspace, parent),
                &error,
            )
        })?;
    }

    fs::write(&path, content).map_err(|error| {
        AppError::fs(
            "write_index_file_failed",
            "failed to write index file",
            relative_path.clone(),
            &error,
        )
    })?;

    Ok(relative_path)
}

fn render_index_block(documents_directory: &str, documents: &[DocumentEntry]) -> String {
    let mut output = String::new();
    output.push_str(START_MARKER);
    output.push_str("\n## Docs Index\n\n");
    output.push_str(&format!("Source: `{documents_directory}/`\n\n"));

    let root_documents = documents
        .iter()
        .filter(|document| document.namespace.is_empty())
        .collect::<Vec<_>>();
    let mut namespaces = BTreeMap::<&str, Vec<&DocumentEntry>>::new();
    for document in documents
        .iter()
        .filter(|document| !document.namespace.is_empty())
    {
        namespaces
            .entry(document.namespace.as_str())
            .or_default()
            .push(document);
    }

    if root_documents.is_empty() && namespaces.is_empty() {
        output.push_str("No documents found.\n\n");
    }

    if !root_documents.is_empty() {
        output.push_str("### Root Documents\n\n");
        push_document_lines(&mut output, &root_documents);
    }

    for (namespace, namespace_documents) in namespaces {
        output.push_str(&format!("### {namespace}\n\n"));
        push_document_lines(&mut output, &namespace_documents);
    }

    output.push_str(END_MARKER);
    output.push('\n');
    output
}

fn push_document_lines(output: &mut String, documents: &[&DocumentEntry]) {
    for document in documents {
        output.push_str(&format!(
            "- `{}` - {} - {}\n",
            document.path, document.title, document.description
        ));
    }
    output.push('\n');
}

fn merge_index_block(
    existing: Option<&str>,
    block: &str,
    relative_path: &str,
) -> AppResult<String> {
    let Some(existing) = existing else {
        return Ok(block.to_string());
    };

    let start = existing.find(START_MARKER);
    let end = existing.find(END_MARKER);

    match (start, end) {
        (None, None) => {
            let existing = existing.trim_end();
            if existing.is_empty() {
                Ok(block.to_string())
            } else {
                Ok(format!("{existing}\n\n{block}"))
            }
        }
        (Some(start), Some(end)) if start <= end => {
            let after_start = end + END_MARKER.len();
            let after = existing[after_start..]
                .strip_prefix('\n')
                .unwrap_or(&existing[after_start..]);
            Ok(format!("{}{}{}", &existing[..start], block, after))
        }
        _ => Err(AppError::validation(
            "invalid_index_file_markers",
            "index file contains invalid adm sync markers",
            json!({
                "path": relative_path,
                "start_marker": START_MARKER,
                "end_marker": END_MARKER
            }),
        )),
    }
}

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

    #[test]
    fn replaces_existing_managed_block() {
        let existing =
            "Manual\n\n<!-- adm:docs-index:start -->\nold\n<!-- adm:docs-index:end -->\n\nFooter\n";
        let merged = merge_index_block(Some(existing), "new\n", "AGENTS.md").unwrap();

        assert_eq!(merged, "Manual\n\nnew\n\nFooter\n");
    }

    #[test]
    fn appends_when_existing_file_has_no_managed_block() {
        let merged = merge_index_block(Some("Manual\n"), "index\n", "AGENTS.md").unwrap();

        assert_eq!(merged, "Manual\n\nindex\n");
    }
}