agents-docs-manager 0.1.1

Manage AGENTS.md and Docs markdown indexes for agent-oriented repositories.
use std::io::{self, Write};

use serde_json::Value;

use crate::utils::error::AppError;
use crate::utils::workspace::{InitOutput, WorkspaceRules};

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandOutput {
    RawText(String),
    WorkspaceInit(InitOutput),
    WorkspaceRules(WorkspaceRules),
    Namespaces(Vec<NamespaceRow>),
    NamespaceDocuments(Vec<NamespaceDocuments>),
    Documents(Vec<DocumentRow>),
    Fix(Vec<FixRow>),
    Path(String),
    NamespaceName(String),
    Tree(String),
    CheckOk,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NamespaceRow {
    pub namespace: String,
    pub path: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocumentRow {
    pub document: String,
    pub path: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NamespaceDocuments {
    pub namespace: String,
    pub documents: Vec<DocumentRow>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FixRow {
    pub old_path: String,
    pub new_path: String,
}

pub fn write_success_and_exit(output: &CommandOutput) -> ! {
    let text = format_success(output);
    write_text_and_exit(&text, 0)
}

pub fn write_error_and_exit(error: &AppError) -> ! {
    let mut output = format!("error: {}: {}\n", error.code, error.message);
    append_error_details(&mut output, "details", &error.details);
    write_text_and_exit(&output, error.exit_code)
}

fn write_text_and_exit(output: &str, exit_code: i32) -> ! {
    let mut stdout = io::stdout().lock();
    let result = stdout.write_all(output.as_bytes());

    if result.is_err() {
        std::process::exit(2);
    }

    std::process::exit(exit_code);
}

fn format_success(output: &CommandOutput) -> String {
    match output {
        CommandOutput::RawText(text) => text.clone(),
        CommandOutput::WorkspaceInit(init) => {
            format!(
                "index_path {}\ndoc_dir {}\nnaming_style {}\n",
                init.index_path, init.documents_directory, init.naming_style
            )
        }
        CommandOutput::WorkspaceRules(rules) => format_workspace_rules(rules),
        CommandOutput::Namespaces(namespaces) => {
            let lines = namespaces
                .iter()
                .map(|namespace| format!("{} {}", namespace.namespace, namespace.path))
                .collect::<Vec<_>>();
            format_lines(&lines)
        }
        CommandOutput::NamespaceDocuments(namespaces) => format_namespace_docs(namespaces),
        CommandOutput::Documents(documents) => {
            let lines = documents
                .iter()
                .map(|document| format!("{} {}", document.document, document.path))
                .collect::<Vec<_>>();
            format_lines(&lines)
        }
        CommandOutput::Fix(migrations) => {
            if migrations.is_empty() {
                "OK\n".to_string()
            } else {
                let lines = migrations
                    .iter()
                    .map(|migration| format!("{} -> {}", migration.old_path, migration.new_path))
                    .collect::<Vec<_>>();
                format_lines(&lines)
            }
        }
        CommandOutput::Path(path) => format!("{path}\n"),
        CommandOutput::NamespaceName(namespace) => format!("{namespace}\n"),
        CommandOutput::Tree(tree) => ensure_newline(tree),
        CommandOutput::CheckOk => "OK\n".to_string(),
    }
}

fn format_workspace_rules(rules: &WorkspaceRules) -> String {
    let mut output = String::new();
    output.push_str(&format!("agents_file {}\n", rules.agents_file));
    output.push_str(&format!(
        "documents_directory {}\n",
        rules.documents_directory
    ));
    output.push_str(&format!("index_file {}\n", rules.index_file));
    output.push_str(&format!("repository {}\n", rules.repository));
    for (index, rule) in rules.rules.iter().enumerate() {
        output.push_str(&format!("rules[{index}].content {}\n", rule.content));
        output.push_str(&format!("rules[{index}].path {}\n", rule.path));
    }
    output
}

fn format_namespace_docs(namespaces: &[NamespaceDocuments]) -> String {
    let mut lines = Vec::new();
    for namespace in namespaces {
        lines.push(format!("{}/", namespace.namespace));
        for document in &namespace.documents {
            lines.push(format!("  {} {}", document.document, document.path));
        }
    }

    format_lines(&lines)
}

fn format_lines(lines: &[String]) -> String {
    if lines.is_empty() {
        String::new()
    } else {
        lines.join("\n") + "\n"
    }
}

fn append_error_details(output: &mut String, path: &str, value: &Value) {
    match value {
        Value::Object(map) => {
            let mut keys = map.keys().collect::<Vec<_>>();
            keys.sort();
            for key in keys {
                append_error_details(output, &format!("{path}.{key}"), &map[key]);
            }
        }
        Value::Array(values) => {
            for (index, child) in values.iter().enumerate() {
                append_error_details(output, &format!("{path}[{index}]"), child);
            }
        }
        Value::String(value) => output.push_str(&format!("{path}: {value}\n")),
        Value::Bool(value) => output.push_str(&format!("{path}: {value}\n")),
        Value::Number(value) => output.push_str(&format!("{path}: {value}\n")),
        Value::Null => {}
    }
}

fn ensure_newline(value: &str) -> String {
    if value.ends_with('\n') {
        value.to_string()
    } else {
        format!("{value}\n")
    }
}