rdice-cli 0.1.2

Command-line dice roller powered by rdice-core
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

fn bin_path() -> &'static str {
    env!("CARGO_BIN_EXE_rdice")
}

fn unique_path(label: &str) -> PathBuf {
    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    std::env::temp_dir().join(format!("rdice-{label}-{stamp}.toml"))
}

fn run_cli(config_path: &PathBuf, args: &[&str]) -> std::process::Output {
    Command::new(bin_path())
        .env("RDICE_CONFIG_PATH", config_path)
        .args(args)
        .output()
        .expect("failed to run rdice")
}

fn run_cli_without_no_color(config_path: &PathBuf, args: &[&str]) -> std::process::Output {
    Command::new(bin_path())
        .env("RDICE_CONFIG_PATH", config_path)
        .env_remove("NO_COLOR")
        .args(args)
        .output()
        .expect("failed to run rdice")
}

fn run_cli_with_no_color_env(config_path: &PathBuf, args: &[&str]) -> std::process::Output {
    Command::new(bin_path())
        .env("RDICE_CONFIG_PATH", config_path)
        .env("NO_COLOR", "1")
        .args(args)
        .output()
        .expect("failed to run rdice")
}

fn run_cli_with_editor(config_path: &PathBuf, editor: &str, args: &[&str]) -> std::process::Output {
    Command::new(bin_path())
        .env("RDICE_CONFIG_PATH", config_path)
        .env("EDITOR", editor)
        .env_remove("VISUAL")
        .args(args)
        .output()
        .expect("failed to run rdice")
}

#[test]
fn cli_list_uses_color_by_default_and_can_disable_it() {
    let path = unique_path("color-list");

    let colored = run_cli_without_no_color(&path, &["list"]);
    assert!(colored.status.success());
    let stdout = String::from_utf8_lossy(&colored.stdout);
    assert!(stdout.contains("\x1b["));
    assert!(stdout.contains("D20 (builtin):"));

    let no_color_flag = run_cli_without_no_color(&path, &["--no-color", "list"]);
    assert!(no_color_flag.status.success());
    let stdout = String::from_utf8_lossy(&no_color_flag.stdout);
    assert!(!stdout.contains("\x1b["));

    let no_color_env = run_cli_with_no_color_env(&path, &["list"]);
    assert!(no_color_env.status.success());
    let stdout = String::from_utf8_lossy(&no_color_env.stdout);
    assert!(!stdout.contains("\x1b["));
}

#[test]
fn cli_without_args_shows_help() {
    let path = unique_path("help");
    let output = run_cli(&path, &[]);
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Usage:"));
    assert!(stdout.contains(
        "rdice roll [-f|--folded] [-x|--expanded] [-E|--ev] [-R|--range] <dice-expr...>"
    ));
    assert!(stdout.contains("rdice [-E|--ev] [-R|--range] <dice-expr...>"));
}

#[test]
fn cli_config_path_uses_explicit_environment_variable() {
    let path = unique_path("config-path");
    let output = run_cli(&path, &["config", "path"]);
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert_eq!(stdout.trim(), path.display().to_string());
}

#[test]
fn cli_config_edit_creates_config_and_runs_editor() {
    let path = unique_path("config-edit");
    let output = run_cli_with_editor(&path, "true", &["config", "edit"]);
    assert!(output.status.success());
    let contents = fs::read_to_string(&path).unwrap();
    assert!(contents.contains("# rdice config"));
    assert!(contents.contains("[[dice]]"));
}

#[test]
fn cli_list_compacts_large_builtin_numeric_dice() {
    let path = unique_path("list-builtins");
    let output = run_cli(&path, &["list"]);
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("D20 (builtin): [1, 2, 3"));
    assert!(stdout.contains("D100 (builtin): [1..100]"));
    assert!(!stdout.contains("97, 98, 99, 100"));
}

#[test]
fn cli_roll_parses_mixed_expressions() {
    let path = unique_path("expr-roll");
    let output = run_cli(&path, &["roll", "3d6", "2d20", "5", "-3"]);
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("D6 x3:"));
    assert!(stdout.contains("D20 x2:"));
    assert!(stdout.contains("modifier: +5"));
    assert!(stdout.contains("modifier: -3"));
    assert!(stdout.contains("total:"));
}

#[test]
fn cli_roll_supports_expanded_numbered_output_and_dynamic_numeric_dice() {
    let path = unique_path("expanded-roll");
    let output = run_cli(&path, &["roll", "--expanded", "4d13"]);
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("#1 D13:"));
    assert!(stdout.contains("#2 D13:"));
    assert!(stdout.contains("#3 D13:"));
    assert!(stdout.contains("#4 D13:"));
    assert!(stdout.contains("total:"));
}

#[test]
fn cli_roll_supports_expanded_short_flag() {
    let path = unique_path("expanded-short-roll");
    let output = run_cli(&path, &["roll", "-x", "2d13"]);
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("#1 D13:"));
    assert!(stdout.contains("#2 D13:"));
    assert!(stdout.contains("total:"));
}

#[test]
fn cli_roll_can_append_expected_value_and_range() {
    let path = unique_path("roll-analysis");
    let output = run_cli(&path, &["roll", "-E", "-R", "3d6", "5", "-3"]);
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("D6 x3:"));
    assert!(stdout.contains("modifier: +5"));
    assert!(stdout.contains("modifier: -3"));
    assert!(stdout.contains("expected: 12.5"));
    assert!(stdout.contains("range: 5..20"));
}

#[test]
fn cli_top_level_analysis_flags_do_not_roll() {
    let path = unique_path("top-level-analysis");
    let output = run_cli(&path, &["-E", "-R", "3d6", "5", "-3"]);
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("expected: 12.5"));
    assert!(stdout.contains("range: 5..20"));
    assert!(!stdout.contains("D6 x3:"));
    assert!(!stdout.contains("modifier:"));
}

#[test]
fn cli_folded_single_group_outputs_only_group_sum() {
    let path = unique_path("folded-roll");
    let output = run_cli(&path, &["roll", "4d6"]);
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("D6 x4:"));
    assert!(!stdout.contains("total:"));
    assert!(!stdout.contains("#1 D6:"));
}

#[test]
fn cli_folded_output_preserves_first_seen_die_order() {
    let path = unique_path("folded-order");
    let output = run_cli(&path, &["roll", "2d20", "3d6", "d20"]);
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    let d20_index = stdout.find("D20 x3:").unwrap();
    let d6_index = stdout.find("D6 x3:").unwrap();
    assert!(d20_index < d6_index);
}

#[test]
fn cli_roll_rejects_numeric_dice_with_less_than_two_faces() {
    let path = unique_path("invalid-numeric-die");
    let output = run_cli(&path, &["roll", "d1"]);
    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(stderr.contains("Numeric dice must have at least 2 faces"));
}

#[test]
fn cli_loads_custom_dice_from_config() {
    let path = unique_path("custom-config");
    fs::write(
        &path,
        r#"
[[dice]]
name = "coin"
faces = ["heads", "tails"]

[[dice]]
name = "fate"
faces = [-1, 0, 1]
"#,
    )
    .unwrap();

    let list = run_cli(&path, &["list"]);
    assert!(list.status.success());
    let stdout = String::from_utf8_lossy(&list.stdout);
    assert!(stdout.contains("✽coin (custom): [heads, tails]"));
    assert!(stdout.contains("✽fate (custom): [-1, 0, 1]"));

    let roll = run_cli(&path, &["roll", "coin", "2fate"]);
    assert!(roll.status.success());
    let stdout = String::from_utf8_lossy(&roll.stdout);
    assert!(stdout.contains("✽coin:"));
    assert!(stdout.contains("✽fate x2:"));
}

#[test]
fn cli_rejects_removed_legacy_commands() {
    let path = unique_path("legacy");
    let output = run_cli(&path, &["repl"]);
    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(stderr.contains("unknown command: repl"));
}