gruezi 0.1.1

Service Discovery & Distributed Key-Value Store
Documentation
use crate::config::DEFAULT_HA_BIND;
use clap::{
    Arg, ArgAction, Command,
    builder::styling::{AnsiColor, Effects, Styles},
};

pub mod built_info {
    #![allow(clippy::doc_markdown)]
    include!(concat!(env!("OUT_DIR"), "/built.rs"));
}

/// Creates and configures the CLI command structure
#[must_use]
pub fn new() -> Command {
    let styles = Styles::styled()
        .header(AnsiColor::Yellow.on_default() | Effects::BOLD)
        .usage(AnsiColor::Green.on_default() | Effects::BOLD)
        .literal(AnsiColor::Blue.on_default() | Effects::BOLD)
        .placeholder(AnsiColor::Green.on_default());

    let git_hash = built_info::GIT_COMMIT_HASH.unwrap_or("unknown");
    let long_version: &'static str =
        Box::leak(format!("{} - {}", env!("CARGO_PKG_VERSION"), git_hash).into_boxed_str());

    Command::new("gruezi")
        .version(env!("CARGO_PKG_VERSION"))
        .long_version(long_version)
        .author(env!("CARGO_PKG_AUTHORS"))
        .about(env!("CARGO_PKG_DESCRIPTION"))
        .long_about(
            "Gruezi provides two operational modes:\n\
             \n\
             - `ha`: 2-node high availability using a UDP-based failover protocol on port 9375\n\
             - `kv`: 3+ node consensus-backed key-value service with API traffic on port 9376 and peer traffic on port 9377\n\
             \n\
             Configuration is loaded from `--config`, then `GRUEZI_CONFIG`, then `/etc/gruezi/gruezi.yaml` if present.\n\
             \n\
             The CLI is intended to expose both local control and remote management workflows over time.",
        )
        .styles(styles)
        .arg(
            Arg::new("verbose")
                .short('v')
                .long("verbose")
                .help("Enable verbose CLI output")
                .long_help(
                    "Enable verbose logging for debugging purposes.\n\n\
                     Can be specified multiple times to increase verbosity:\n  \
                     -v    = INFO level\n  \
                     -vv   = DEBUG level\n  \
                     -vvv  = TRACE level\n\n\
                     Note: Verbose output is sent to stderr via the RUST_LOG environment variable.",
                )
                .action(ArgAction::Count),
        )
        .subcommand(start_command())
        .subcommand(status_command())
        .subcommand(peers_command())
        .subcommand_required(true)
        .arg_required_else_help(true)
}

fn start_command() -> Command {
    Command::new("start")
        .about("Start the gruezi service")
        .long_about(
            "Start `gruezi` using either YAML configuration or the direct HA CLI flags.\n\
             \n\
             Preferred path:\n\
             - use `--config` with a YAML file\n\
             - or rely on `GRUEZI_CONFIG`\n\
             - or place the default file at `/etc/gruezi/gruezi.yaml`\n\
             \n\
             Direct flags remain available as an incremental HA-only path. In that case,\n\
             `--bind` defaults to the HA control port on `0.0.0.0:9375`.",
        )
        .arg(
            Arg::new("config")
                .short('c')
                .long("config")
                .help("Path to a YAML configuration file")
                .long_help(
                    "Path to a YAML configuration file.\n\n\
                     When this is provided, `gruezi` ignores the direct `--bind`, `--peers`, and `--node-id` flags.\n\
                     The config file can describe either `mode: ha` or `mode: kv`.",
                )
                .value_name("FILE")
                .conflicts_with_all(["bind", "peers", "node-id"]),
        )
        .arg(
            Arg::new("bind")
                .short('b')
                .long("bind")
                .help("Address to bind the HA listener to")
                .long_help(
                    "Address to bind the HA listener to when using the direct CLI path.\n\n\
                     This is primarily for `mode: ha` style startup without a YAML file.\n\
                     The default is `0.0.0.0:9375`, which is the draft HA peer port.",
                )
                .value_name("ADDRESS")
                .default_value(DEFAULT_HA_BIND),
        )
        .arg(
            Arg::new("peers")
                .short('p')
                .long("peers")
                .help("Comma-separated list of peer addresses")
                .long_help(
                    "Comma-separated list of peer addresses for the direct CLI startup path.\n\n\
                     For HA mode this should identify the remote peer using the HA control port, for example `10.0.0.2:9375`.",
                )
                .value_name("PEERS"),
        )
        .arg(
            Arg::new("node-id")
                .short('n')
                .long("node-id")
                .help("Unique node identifier")
                .long_help(
                    "Unique node identifier for the local node when using the direct CLI startup path.\n\n\
                     YAML-based HA configuration requires `node.id` explicitly.",
                )
                .value_name("ID"),
        )
}

fn status_command() -> Command {
    Command::new("status")
        .about("Show cluster status")
        .long_about(
            "Show status for the local node or a remote node.\n\n\
             Over time this command is expected to target the common API and management port on `9376/tcp` rather than the HA or Raft peer ports directly.",
        )
        .arg(
            Arg::new("node")
                .short('n')
                .long("node")
                .help("Query a specific node or API endpoint")
                .long_help(
                    "Query a specific node or API endpoint.\n\n\
                     This should point to the management/API address, not the HA UDP peer port.",
                )
                .value_name("ADDRESS"),
        )
        .arg(
            Arg::new("watch")
                .short('w')
                .long("watch")
                .help("Refresh status continuously until interrupted")
                .long_help(
                    "Refresh status continuously until interrupted.\n\n\
                     This is intended for live HA observation and correlation with packet captures.",
                )
                .action(ArgAction::SetTrue),
        )
        .arg(
            Arg::new("interval-ms")
                .long("interval-ms")
                .help("Polling interval in milliseconds for --watch")
                .long_help(
                    "Polling interval in milliseconds for `--watch`.\n\n\
                     The default is 1000ms.",
                )
                .value_name("MILLISECONDS")
                .value_parser(clap::value_parser!(u64).range(1..))
                .default_value("1000"),
        )
}

fn peers_command() -> Command {
    Command::new("peers")
        .about("List cluster peers")
        .long_about(
            "List peers known to the current node.\n\n\
             This command is intended for both HA and KV workflows and should eventually reflect peer information gathered through the shared management/API layer.",
        )
        .arg(
            Arg::new("format")
                .short('f')
                .long("format")
                .help("Render output as table, json, or yaml")
                .long_help(
                    "Render peer output in one of the supported formats.\n\n\
                     `table` is intended for operators, while `json` and `yaml` are better for automation.",
                )
                .value_name("FORMAT")
                .value_parser(["table", "json", "yaml"])
                .default_value("table"),
        )
}

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

    #[test]
    fn verify_cli() {
        new().debug_assert();
    }

    #[test]
    fn test_start_command() {
        let app = new();
        let matches = app.try_get_matches_from(vec!["gruezi", "start"]);
        assert!(matches.is_ok());
    }

    #[test]
    fn test_start_command_with_config() {
        let app = new();
        let matches = app.try_get_matches_from(vec!["gruezi", "start", "--config", "gruezi.yml"]);
        assert!(matches.is_ok());
    }

    #[test]
    fn test_status_command() {
        let app = new();
        let matches = app.try_get_matches_from(vec!["gruezi", "status"]);
        assert!(matches.is_ok());
    }

    #[test]
    fn test_status_watch_command() {
        let app = new();
        let matches =
            app.try_get_matches_from(vec!["gruezi", "status", "--watch", "--interval-ms", "500"]);
        assert!(matches.is_ok());
    }

    #[test]
    fn test_peers_command() {
        let app = new();
        let matches = app.try_get_matches_from(vec!["gruezi", "peers"]);
        assert!(matches.is_ok());
    }

    #[test]
    fn test_verbose_flag() -> Result<()> {
        let app = new();
        let matches = app.try_get_matches_from(vec!["gruezi", "-vvv", "status"])?;
        assert_eq!(matches.get_count("verbose"), 3);
        Ok(())
    }
}