ccd-cli 1.0.0-beta.1

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

use serde::Serialize;

use super::behavior::{self, CommandBehavior};
use crate::cli_command;
use crate::extensions;
use crate::memory::provider as memory_provider;
use crate::output::CommandReport;
use crate::runtime_api;

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CommandTier {
    Core,
    Advanced,
    Platform,
}

#[derive(Serialize)]
pub struct DescribeReport {
    command: &'static str,
    ok: bool,
    version: String,
    about: Option<String>,
    runtime_api: runtime_api::RuntimeApiView,
    active_extensions: Vec<ActiveExtensionView>,
    memory_provider: memory_provider::MemoryProviderView,
    global_args: Vec<ArgDescriptor>,
    pub(crate) commands: Vec<CommandDescriptor>,
}

#[derive(Serialize)]
struct ActiveExtensionView {
    name: &'static str,
    command_groups: Vec<&'static str>,
}

#[derive(Serialize)]
pub(crate) struct CommandDescriptor {
    pub(crate) name: String,
    pub(crate) about: Option<String>,
    pub(crate) tier: CommandTier,
    pub(crate) args: Vec<ArgDescriptor>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) behavior: Option<CommandBehavior>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub(crate) subcommands: Vec<CommandDescriptor>,
}

#[derive(Serialize)]
pub(crate) struct ArgDescriptor {
    pub(crate) name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) short: Option<char>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) long: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) aliases: Option<Vec<String>>,
    pub(crate) required: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) default: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) possible_values: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) help: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) value_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) value_delimiter: Option<char>,
}

fn is_visible_arg(arg: &clap::Arg) -> bool {
    !arg.is_hide_set() && arg.get_id() != "help" && arg.get_id() != "version"
}

fn push_unique(target: &mut Vec<String>, value: &str) {
    if !target.iter().any(|existing| existing == value) {
        target.push(value.to_owned());
    }
}

fn describe_arg(arg: &clap::Arg) -> ArgDescriptor {
    let default = {
        let vals: Vec<&str> = arg
            .get_default_values()
            .iter()
            .filter_map(|v| v.to_str())
            .collect();
        if vals.is_empty() {
            None
        } else {
            Some(vals.join(","))
        }
    };
    let possible_values = {
        let pv = arg.get_possible_values();
        if pv.is_empty() {
            None
        } else {
            let mut values = Vec::new();
            for value in pv {
                for name in value.get_name_and_aliases() {
                    push_unique(&mut values, name);
                }
            }
            Some(values)
        }
    };
    let aliases = {
        let values = arg
            .get_all_aliases()
            .unwrap_or_default()
            .into_iter()
            .map(ToOwned::to_owned)
            .collect::<Vec<_>>();
        if values.is_empty() {
            None
        } else {
            Some(values)
        }
    };
    ArgDescriptor {
        name: arg.get_id().to_string(),
        short: arg.get_short(),
        long: arg.get_long().map(|s| s.to_owned()),
        aliases,
        required: arg.is_required_set(),
        default,
        possible_values,
        help: arg.get_help().map(|s| s.to_string()),
        value_name: arg.get_value_names().and_then(|names| {
            let v: Vec<&str> = names.iter().map(|n| n.as_str()).collect();
            if v.is_empty() {
                None
            } else {
                Some(v.join(","))
            }
        }),
        value_delimiter: arg.get_value_delimiter(),
    }
}

fn describe_command(cmd: &clap::Command, parents: &[String]) -> CommandDescriptor {
    let mut path = parents.to_vec();
    path.push(cmd.get_name().to_owned());
    let path_refs: Vec<&str> = path.iter().map(|segment| segment.as_str()).collect();
    let args: Vec<ArgDescriptor> = cmd
        .get_arguments()
        .filter(|a| is_visible_arg(a))
        .map(describe_arg)
        .collect();
    let subcommands: Vec<CommandDescriptor> = cmd
        .get_subcommands()
        .filter(|s| s.get_name() != "help" && !s.is_hide_set())
        .map(|sub| describe_command(sub, &path))
        .collect();
    CommandDescriptor {
        name: cmd.get_name().to_owned(),
        about: cmd.get_about().map(|s| s.to_string()),
        tier: tier_for_command_path(&path_refs),
        args,
        behavior: behavior::for_command_path(&path_refs),
        subcommands,
    }
}

fn tier_for_command_path(path: &[&str]) -> CommandTier {
    match path {
        ["attach" | "init" | "start" | "status" | "checkpoint" | "radar-state" | "doctor"
        | "sync"] => CommandTier::Core,
        // More-specific session-state arms must stay ahead of ["session-state", ..].
        ["session-state", "start" | "clear"] => CommandTier::Core,
        ["context-check"]
        | ["policy-check"]
        | ["runtime-state", ..]
        | ["escalation-state", ..]
        | ["session-state", ..]
        | ["pod", ..] => CommandTier::Platform,
        _ => CommandTier::Advanced,
    }
}

pub fn run() -> DescribeReport {
    let cmd = cli_command();
    let version = cmd.get_version().unwrap_or("unknown").to_owned();
    let about = cmd.get_about().map(|s| s.to_string());

    let global_args: Vec<ArgDescriptor> = cmd
        .get_arguments()
        .filter(|a| is_visible_arg(a))
        .map(describe_arg)
        .collect();

    let commands: Vec<CommandDescriptor> = cmd
        .get_subcommands()
        .filter(|s| s.get_name() != "help" && !s.is_hide_set())
        .map(|sub| describe_command(sub, &[]))
        .collect();
    let active_extensions = extensions::registered()
        .into_iter()
        .map(|extension| ActiveExtensionView {
            name: extension.name(),
            command_groups: extension.command_groups().to_vec(),
        })
        .collect();

    DescribeReport {
        command: "describe",
        ok: true,
        version,
        about,
        runtime_api: runtime_api::describe_runtime_api(),
        active_extensions,
        memory_provider: memory_provider::inspect_current_dir_for_describe(),
        global_args,
        commands,
    }
}

impl CommandReport for DescribeReport {
    fn exit_code(&self) -> ExitCode {
        ExitCode::SUCCESS
    }

    fn render_text(&self) {
        println!(
            "ccd {}{}",
            self.version,
            self.about.as_deref().unwrap_or("")
        );
        println!();
        render_tier_section("Core Commands", CommandTier::Core, &self.commands);
        render_tier_section("Advanced Commands", CommandTier::Advanced, &self.commands);
        render_tier_section("Platform Commands", CommandTier::Platform, &self.commands);
        println!();
        println!("Run `ccd describe --output json` for full structured metadata.");
    }
}

fn render_tier_section(title: &str, tier: CommandTier, commands: &[CommandDescriptor]) {
    if !commands.iter().any(|cmd| subtree_contains_tier(cmd, tier)) {
        return;
    }
    println!("{title}:");
    for cmd in commands {
        render_command_text_for_tier(cmd, tier, 1);
    }
    println!();
}

fn subtree_contains_tier(cmd: &CommandDescriptor, tier: CommandTier) -> bool {
    cmd.tier == tier
        || cmd
            .subcommands
            .iter()
            .any(|sub| subtree_contains_tier(sub, tier))
}

fn render_command_text_for_tier(cmd: &CommandDescriptor, tier: CommandTier, depth: usize) {
    let mut matching_children = Vec::new();
    for sub in &cmd.subcommands {
        if subtree_contains_tier(sub, tier) {
            matching_children.push(sub);
        }
    }

    let should_render_self = cmd.tier == tier;
    if !should_render_self && matching_children.is_empty() {
        return;
    }

    // Mixed-tier command families render the parent as scaffolding in each
    // section that has matching descendants, printing tier-matching
    // descendants with any needed intermediate scaffolding nodes.
    let indent = "  ".repeat(depth);
    let about = cmd.about.as_deref().unwrap_or("");
    if about.is_empty() {
        println!("{indent}{}", cmd.name);
    } else {
        println!("{indent}{:<24}{about}", cmd.name);
    }
    for sub in matching_children {
        render_command_text_for_tier(sub, tier, depth + 1);
    }
}

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

    #[test]
    fn describe_reflects_registered_extensions() {
        let report = run();
        let names = report
            .commands
            .iter()
            .map(|command| command.name.as_str())
            .collect::<Vec<_>>();

        assert!(!names.contains(&"backlog"));
    }
}