ccd-cli 1.0.0-alpha.2

Bootstrap and validate Continuous Context Development repositories
use std::path::PathBuf;
use std::process::ExitCode;

use anyhow::Result;
use clap::{ArgMatches, Args, Command, FromArgMatches, Subcommand};
use serde_json::Value;

use crate::commands::describe::CommandDescriptor;
use crate::extensions::codemap_commands;
use crate::extensions::{Extension, HealthDiagnostic};
use crate::mcp::protocol::Tool;
use crate::mcp::tools::{build_tool, ToolDef};
use crate::output::OutputFormat;
use crate::paths;
use crate::paths::state::StateLayout;

#[derive(Subcommand)]
enum CodemapCommand {
    Import(CodemapImportArgs),
    Status(CodemapStatusArgs),
    Query(CodemapQueryArgs),
}

#[derive(Args)]
struct CodemapImportArgs {
    #[arg(long, default_value = ".")]
    path: PathBuf,
    #[arg(long)]
    profile: Option<String>,
}

#[derive(Args)]
struct CodemapStatusArgs {
    #[arg(long, default_value = ".")]
    path: PathBuf,
    #[arg(long)]
    profile: Option<String>,
}

#[derive(Args)]
struct CodemapQueryArgs {
    #[arg(long, default_value = ".")]
    path: PathBuf,
    #[arg(long)]
    profile: Option<String>,
    #[arg(long)]
    prefix: Option<String>,
    #[arg(long, default_value_t = 50)]
    limit: usize,
}

const COMMAND_GROUPS: &[&str] = &["codemap"];

const MCP_TOOLS: &[ToolDef] = &[ToolDef {
    name: "ccd_codemap",
    description: "Code map: status query",
    commands: &[
        ("codemap-status", &["codemap", "status"]),
        ("codemap-query", &["codemap", "query"]),
    ],
    renames: &[],
    exclude: &[],
    extra_props: &[],
}];

pub(crate) struct CodemapExtension;

pub(crate) static CODEMAP_EXTENSION: CodemapExtension = CodemapExtension;

impl Extension for CodemapExtension {
    fn name(&self) -> &'static str {
        "codemap"
    }

    fn command_groups(&self) -> &'static [&'static str] {
        COMMAND_GROUPS
    }

    fn cli_command(&self) -> Option<Command> {
        Some(codemap_cli_command())
    }

    fn dispatch_cli(
        &self,
        subcommand_name: &str,
        matches: &ArgMatches,
        output: OutputFormat,
    ) -> Option<Result<ExitCode>> {
        if subcommand_name != "codemap" {
            return None;
        }

        Some(run_cli_from_matches(matches, output))
    }

    fn mcp_tools(&self, commands: &[CommandDescriptor]) -> Vec<Tool> {
        MCP_TOOLS
            .iter()
            .map(|tool| build_tool(tool, commands))
            .collect()
    }

    fn dispatch_mcp(&self, tool_name: &str, args: &Value) -> Option<Result<Value>> {
        if tool_name != "ccd_codemap" {
            return None;
        }

        let command = match get_required_string(args, "command") {
            Ok(command) => command,
            Err(error) => return Some(Err(error)),
        };

        let report = (|| -> Result<Value> {
            match command.as_str() {
                "codemap-status" => {
                    let path = resolve_path(args)?;
                    let profile = get_opt_str(args, "profile");
                    let report = codemap_commands::status(&path, profile.as_deref())?;
                    to_value(&report)
                }
                "codemap-query" => {
                    let path = resolve_path(args)?;
                    let prefix = get_opt_str(args, "prefix");
                    let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
                    let profile = get_opt_str(args, "profile");
                    let report = codemap_commands::query_report(
                        &path,
                        profile.as_deref(),
                        prefix.as_deref(),
                        limit,
                    )?;
                    to_value(&report)
                }
                other => Err(anyhow::anyhow!("unknown codemap MCP command: {other}")),
            }
        })();

        Some(report)
    }

    fn health_diagnostics(
        &self,
        layout: &StateLayout,
        _repo_root: &std::path::Path,
        locality_id: &str,
    ) -> Result<Vec<HealthDiagnostic>> {
        let db_path = layout.codemap_db_path(locality_id)?;
        // Codemap is opt-in: no diagnostic if the DB has not been created yet.
        if !db_path.exists() {
            return Ok(Vec::new());
        }

        match codemap_commands::count_entries_at(&db_path) {
            Ok(count) => Ok(vec![HealthDiagnostic {
                check: "codemap_db",
                severity: "info",
                file: db_path.display().to_string(),
                message: format!("Codemap database contains {count} entries."),
                details: Some(serde_json::json!({
                    "entry_count": count,
                    "commands": [
                        "ccd codemap query --prefix <path>",
                        "ccd codemap status",
                        "ccd codemap import"
                    ]
                })),
            }]),
            Err(err) => Ok(vec![HealthDiagnostic {
                check: "codemap_db",
                severity: "warning",
                file: db_path.display().to_string(),
                message: format!("Failed to read codemap database: {err}"),
                details: None,
            }]),
        }
    }
}

fn codemap_cli_command() -> Command {
    CodemapCommand::augment_subcommands(
        Command::new("codemap")
            .about("Structural code map commands")
            .subcommand_required(true)
            .arg_required_else_help(true),
    )
}

fn run_cli_from_matches(matches: &ArgMatches, output: OutputFormat) -> Result<ExitCode> {
    let command = CodemapCommand::from_arg_matches(matches)?;
    run_cli(command, output)
}

fn run_cli(command: CodemapCommand, output: OutputFormat) -> Result<ExitCode> {
    match command {
        CodemapCommand::Import(args) => {
            let report = codemap_commands::import(&args.path, args.profile.as_deref())?;
            crate::output::render_report(output, &report)
        }
        CodemapCommand::Status(args) => {
            let report = codemap_commands::status(&args.path, args.profile.as_deref())?;
            crate::output::render_report(output, &report)
        }
        CodemapCommand::Query(args) => {
            let report = codemap_commands::query_report(
                &args.path,
                args.profile.as_deref(),
                args.prefix.as_deref(),
                args.limit,
            )?;
            crate::output::render_report(output, &report)
        }
    }
}

fn resolve_path(args: &Value) -> Result<PathBuf> {
    let path = get_opt_str(args, "path").unwrap_or_else(|| ".".to_owned());
    paths::cli::resolve(&PathBuf::from(path))
}

fn get_required_string(args: &Value, key: &str) -> Result<String> {
    get_opt_str(args, key).ok_or_else(|| anyhow::anyhow!("missing required argument `{key}`"))
}

fn get_opt_str(args: &Value, key: &str) -> Option<String> {
    args.get(key).and_then(Value::as_str).map(str::to_owned)
}

fn to_value<T: serde::Serialize>(report: &T) -> Result<Value> {
    Ok(serde_json::to_value(report)?)
}

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

    #[test]
    fn codemap_extension_reports_metadata() {
        let extension: &dyn Extension = &CODEMAP_EXTENSION;
        assert_eq!(extension.name(), "codemap");
        assert_eq!(extension.command_groups(), &["codemap"]);
    }
}