moonlight-cli 0.1.1

Compare command behavior against one or two reference commands.
Documentation
use crate::config::DEFAULT_MAX_BODY_CAPTURE_BYTES;
use clap::{Args, Parser, Subcommand};
use std::{env, path::PathBuf};
use uuid::Uuid;

#[derive(Debug, Parser)]
#[command(about = "Compare command output with Moonlight's shared comparison engine")]
pub(crate) struct Cli {
    #[command(subcommand)]
    pub(crate) command: CliCommand,
}

#[derive(Debug, Subcommand)]
pub(crate) enum CliCommand {
    #[command(
        about = "Run one primary/candidate comparison",
        after_help = "Examples:\n  moonlight run --primary 'printf \"{\\\"value\\\":42}\\n\"' --candidate 'printf \"{\\\"value\\\":43}\\n\"'\n  moonlight run --primary-argv '[\"printf\",\"%s\\n\",\"{\\\"value\\\":42}\"]' --candidate-argv '[\"printf\",\"%s\\n\",\"{\\\"value\\\":42}\"]' --compact"
    )]
    Run(RunArgs),
    #[command(
        about = "Run many JSONL comparison cases in one process",
        after_help = "Examples:\n  moonlight batch --input cases.jsonl --jobs 8\n  moonlight batch --input cases.jsonl --emit-runs"
    )]
    Batch(BatchArgs),
    #[command(
        alias = "ls",
        about = "List stored comparison runs newest first",
        after_help = "Examples:\n  moonlight list --limit 20\n  moonlight ls --limit 20 --offset 20 --compact"
    )]
    List(ListArgs),
    #[command(
        about = "Summarize stored comparison runs",
        after_help = "Examples:\n  moonlight stats\n  moonlight stats --compact"
    )]
    Stats(ReadArgs),
    #[command(
        about = "Show a full stored comparison run",
        after_help = "Examples:\n  moonlight show 00000000-0000-0000-0000-000000000000\n  moonlight show 00000000-0000-0000-0000-000000000000 --compact"
    )]
    Show(ShowArgs),
}

#[derive(Debug, Args)]
pub(crate) struct StorageArgs {
    #[arg(
        long,
        value_name = "PATH",
        help = "JSONL storage file. Defaults to MOONLIGHT_CLI_STORAGE_PATH or data/moonlight/cli-runs.jsonl"
    )]
    storage_path: Option<PathBuf>,
}

impl StorageArgs {
    pub(crate) fn storage_path(&self) -> PathBuf {
        self.storage_path
            .clone()
            .unwrap_or_else(default_storage_path)
    }
}

#[derive(Debug, Args)]
pub(crate) struct ReadArgs {
    #[command(flatten)]
    pub(crate) storage: StorageArgs,

    #[arg(long, help = "Print compact JSON instead of pretty JSON")]
    pub(crate) compact: bool,
}

#[derive(Debug, Args)]
pub(crate) struct ListArgs {
    #[command(flatten)]
    pub(crate) output: ReadArgs,

    #[arg(long, help = "Maximum number of newest runs to print")]
    pub(crate) limit: Option<usize>,

    #[arg(long, default_value_t = 0, help = "Number of newest runs to skip")]
    pub(crate) offset: usize,
}

#[derive(Debug, Args)]
pub(crate) struct ShowArgs {
    pub(crate) id: Uuid,

    #[command(flatten)]
    pub(crate) output: ReadArgs,
}

#[derive(Debug, Args)]
pub(crate) struct RunArgs {
    #[command(flatten)]
    pub(crate) storage: StorageArgs,

    #[arg(long, help = "Primary target shell command run through sh -lc")]
    pub(crate) primary: Option<String>,

    #[arg(
        long,
        help = "Primary target argv as a JSON string array, bypassing sh -lc"
    )]
    pub(crate) primary_argv: Option<String>,

    #[arg(long, help = "Candidate target shell command run through sh -lc")]
    pub(crate) candidate: Option<String>,

    #[arg(
        long,
        help = "Candidate target argv as a JSON string array, bypassing sh -lc"
    )]
    pub(crate) candidate_argv: Option<String>,

    #[arg(
        long,
        help = "Optional secondary reference shell command run through sh -lc"
    )]
    pub(crate) secondary: Option<String>,

    #[arg(
        long,
        help = "Optional secondary reference argv as a JSON string array"
    )]
    pub(crate) secondary_argv: Option<String>,

    #[arg(long, default_value_t = DEFAULT_MAX_BODY_CAPTURE_BYTES)]
    pub(crate) max_body_capture_bytes: usize,

    #[arg(long = "ignored-json-path")]
    pub(crate) ignored_json_paths: Vec<String>,

    #[arg(long = "ignored-header")]
    pub(crate) ignored_headers: Vec<String>,

    #[arg(long)]
    pub(crate) ignore_stderr: bool,

    #[arg(long)]
    pub(crate) serial_targets: bool,

    #[arg(long, help = "Print no run JSON to stdout")]
    pub(crate) quiet: bool,

    #[arg(long, help = "Print one-line JSON instead of pretty JSON")]
    pub(crate) compact: bool,
}

#[derive(Debug, Args)]
pub(crate) struct BatchArgs {
    #[command(flatten)]
    pub(crate) storage: StorageArgs,

    #[arg(
        long,
        default_value = "-",
        help = "JSONL batch input path, or - for stdin"
    )]
    pub(crate) input: PathBuf,

    #[arg(long, help = "Maximum number of cases to run concurrently")]
    pub(crate) jobs: Option<usize>,

    #[arg(long, help = "Suppress the final batch summary")]
    pub(crate) quiet: bool,

    #[arg(long, help = "Print compact run JSONL records as cases complete")]
    pub(crate) emit_runs: bool,

    #[arg(
        long,
        help = "Run primary, candidate, and secondary targets sequentially per case"
    )]
    pub(crate) serial_targets: bool,
}

fn default_storage_path() -> PathBuf {
    env::var_os("MOONLIGHT_CLI_STORAGE_PATH")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("data/moonlight/cli-runs.jsonl"))
}