use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Command, Stdio};
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_disables_color_for_captured_output_and_honors_explicit_controls() {
let path = unique_path("color-list");
let captured = run_cli_without_no_color(&path, &["list"]);
assert!(captured.status.success());
let stdout = String::from_utf8_lossy(&captured.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_escapes_terminal_control_characters_from_names_and_errors() {
let path = unique_path("terminal-control-escaping");
fs::write(
&path,
r#"
[[dice]]
name = "\u001b[31mowned"
faces = [1]
"#,
)
.unwrap();
let die_name = "\u{1b}[31mowned";
let list = run_cli_with_no_color_env(&path, &["list"]);
assert!(list.status.success());
let stdout = String::from_utf8_lossy(&list.stdout);
assert!(!stdout.contains('\u{1b}'));
assert!(stdout.contains(r"\u{1b}[31mowned"));
let roll = run_cli_with_no_color_env(&path, &["roll", "-x", die_name]);
assert!(roll.status.success());
let stdout = String::from_utf8_lossy(&roll.stdout);
assert!(!stdout.contains('\u{1b}'));
assert!(stdout.contains(r"\u{1b}[31mowned"));
let unknown = run_cli_with_no_color_env(&path, &["\u{1b}[2Junknown"]);
assert!(!unknown.status.success());
let stderr = String::from_utf8_lossy(&unknown.stderr);
assert!(!stderr.contains('\u{1b}'));
assert!(stderr.contains(r"\u{1b}[2Junknown"));
let control_path = PathBuf::from(format!("/tmp/rdice-{}[2J.toml", '\u{1b}'));
let config_path = run_cli_with_no_color_env(&control_path, &["config", "path"]);
assert!(config_path.status.success());
let stdout = String::from_utf8_lossy(&config_path.stdout);
assert!(!stdout.contains('\u{1b}'));
assert!(stdout.contains(r"\u{1b}[2J.toml"));
let _ = fs::remove_file(path);
}
#[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 <analysis-option...> <dice-expr...>"));
}
#[test]
fn cli_help_and_config_path_do_not_load_a_damaged_config() {
let path = unique_path("damaged-config-help");
fs::write(&path, "this is not valid TOML = [").unwrap();
for args in [
vec![],
vec!["help"],
vec!["--help"],
vec!["-h"],
vec!["help", "roll"],
vec!["roll", "--help"],
vec!["help", "config"],
vec!["config", "--help"],
] {
let output = run_cli(&path, &args);
assert!(output.status.success(), "args {args:?} should show help");
assert!(String::from_utf8_lossy(&output.stdout).contains("Usage:"));
}
let version = run_cli(&path, &["--version"]);
assert!(version.status.success());
assert!(String::from_utf8_lossy(&version.stdout).contains("rdice 0.2.0"));
let output = run_cli(&path, &["config", "path"]);
assert!(output.status.success());
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
path.display().to_string()
);
let _ = fs::remove_file(path);
}
#[test]
fn cli_help_flags_are_position_independent_and_extra_topics_fail_before_config_loading() {
let path = unique_path("position-independent-help");
fs::write(&path, "this is not valid TOML = [").unwrap();
for args in [
vec!["roll", "d6", "--help"],
vec!["list", "unused", "-h"],
vec!["config", "check", "--help"],
] {
let output = run_cli(&path, &args);
assert!(output.status.success(), "{args:?} should show help");
assert!(String::from_utf8_lossy(&output.stdout).contains("Usage:"));
assert!(output.stderr.is_empty());
}
let extra_topic = run_cli(&path, &["help", "roll", "extra"]);
assert!(!extra_topic.status.success());
let stderr = String::from_utf8_lossy(&extra_topic.stderr);
assert!(stderr.contains("help accepts exactly one topic"));
assert!(!stderr.contains("Invalid config"));
let _ = fs::remove_file(path);
}
#[test]
fn cli_top_level_help_separates_analysis_guidance_and_lists_the_short_version_flag() {
let path = unique_path("complete-help");
let output = run_cli(&path, &["--help"]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains(" -V, --version"));
assert!(stdout.contains("\nTop-level analysis:\n"));
assert!(!stdout.contains("\n Top-level analysis"));
}
#[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_rejects_an_empty_explicit_config_path_consistently() {
for args in [
vec!["config", "path"],
vec!["config", "check"],
vec!["config", "edit"],
] {
let output = Command::new(bin_path())
.env("RDICE_CONFIG_PATH", "")
.args(&args)
.output()
.expect("failed to run rdice");
assert!(
!output.status.success(),
"{args:?} should reject an empty path"
);
assert!(output.stdout.is_empty());
assert!(
String::from_utf8_lossy(&output.stderr).contains("RDICE_CONFIG_PATH cannot be empty")
);
}
}
#[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_config_edit_treats_empty_editor_variables_as_unset_without_panicking() {
let visual_empty_path = unique_path("config-edit-empty-visual");
let visual_empty = Command::new(bin_path())
.env("RDICE_CONFIG_PATH", &visual_empty_path)
.env("VISUAL", "")
.env("EDITOR", "true")
.args(["config", "edit"])
.output()
.expect("failed to run rdice");
assert!(visual_empty.status.success());
let editor_empty_path = unique_path("config-edit-empty-editor");
let editor_empty = Command::new(bin_path())
.env("RDICE_CONFIG_PATH", &editor_empty_path)
.env_remove("VISUAL")
.env("EDITOR", "")
.env("PATH", "")
.args(["config", "edit"])
.output()
.expect("failed to run rdice");
assert!(!editor_empty.status.success());
let stderr = String::from_utf8_lossy(&editor_empty.stderr);
assert!(stderr.contains("Failed to launch editor"));
assert!(!stderr.contains("panicked"));
let _ = fs::remove_file(visual_empty_path);
let _ = fs::remove_file(editor_empty_path);
}
#[cfg(unix)]
#[test]
fn cli_config_edit_accepts_an_editor_executable_path_with_spaces() {
use std::os::unix::fs::PermissionsExt;
let directory = unique_path("editor path with spaces");
fs::create_dir(&directory).unwrap();
let editor = directory.join("test editor");
fs::write(&editor, "#!/bin/sh\nexit 0\n").unwrap();
let mut permissions = fs::metadata(&editor).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&editor, permissions).unwrap();
let config_path = directory.join("config.toml");
let output = run_cli_with_editor(
&config_path,
editor.to_str().expect("temporary path should be UTF-8"),
&["config", "edit"],
);
assert!(output.status.success());
assert!(config_path.exists());
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn cli_config_check_validates_without_rolling() {
let valid_path = unique_path("config-check-valid");
fs::write(
&valid_path,
r#"
[[dice]]
name = "coin"
faces = ["heads", "tails"]
"#,
)
.unwrap();
let valid = run_cli(&valid_path, &["config", "check"]);
assert!(valid.status.success());
assert!(String::from_utf8_lossy(&valid.stdout).contains("Config OK:"));
let invalid_path = unique_path("config-check-invalid");
fs::write(&invalid_path, "not valid TOML = [").unwrap();
let invalid = run_cli(&invalid_path, &["config", "check"]);
assert!(!invalid.status.success());
assert!(String::from_utf8_lossy(&invalid.stderr).contains("Invalid config"));
let _ = fs::remove_file(valid_path);
let _ = fs::remove_file(invalid_path);
}
#[test]
fn cli_config_check_distinguishes_a_missing_optional_config() {
let path = unique_path("config-check-missing");
let output = run_cli(&path, &["config", "check"]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Config not found; using built-in dice only:"));
assert!(stdout.contains(&path.display().to_string()));
assert!(!stdout.contains("Config OK:"));
}
#[test]
fn cli_config_check_rejects_unknown_fields_instead_of_ignoring_typos() {
for (label, contents) in [
(
"top-level",
r#"
dices = [{ name = "coin", faces = [1, 2] }]
"#,
),
(
"die-field",
r#"
[[dice]]
name = "coin"
faces = [1, 2]
fcaes = [3, 4]
"#,
),
] {
let path = unique_path(&format!("config-unknown-{label}"));
fs::write(&path, contents).unwrap();
let output = run_cli(&path, &["config", "check"]);
assert!(!output.status.success(), "{label} typo should fail");
assert!(output.stdout.is_empty());
assert!(
String::from_utf8_lossy(&output.stderr).contains("unknown field"),
"{label} typo should identify the unknown field"
);
let _ = fs::remove_file(path);
}
}
#[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("D<N> (dynamic numeric): N >= 2"));
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_exits_successfully_when_a_downstream_pipe_closes_early() {
let path = unique_path("broken-pipe");
let mut child = Command::new(bin_path())
.env("RDICE_CONFIG_PATH", &path)
.args(["roll", "-x", "10000d6"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to run rdice");
let mut stdout = BufReader::new(child.stdout.take().expect("stdout should be piped"));
let mut first_line = String::new();
stdout
.read_line(&mut first_line)
.expect("failed to read the first roll");
assert!(first_line.starts_with("#1 D6:"));
drop(stdout);
let output = child.wait_with_output().expect("failed to wait for rdice");
assert!(output.status.success());
assert!(output.stderr.is_empty());
}
#[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_expanded_output_quotes_and_escapes_text_faces() {
let path = unique_path("expanded-text-escaping");
fs::write(
&path,
r#"
[[dice]]
name = "line"
faces = ["first\nsecond"]
"#,
)
.unwrap();
let output = run_cli(&path, &["roll", "-x", "line"]);
assert!(output.status.success());
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"#1 line: \"first\\nsecond\"\n"
);
let _ = fs::remove_file(path);
}
#[test]
fn cli_roll_rejects_conflicting_output_modes() {
let path = unique_path("conflicting-output-modes");
let output = run_cli(&path, &["roll", "-f", "-x", "d6"]);
assert!(!output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr)
.contains("folded and expanded output modes cannot be combined")
);
}
#[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_roll_does_not_print_a_result_when_requested_analysis_fails() {
let path = unique_path("roll-analysis-atomic-output");
fs::write(
&path,
r#"
[[dice]]
name = "unstable"
faces = [9223372036854775807, 1]
"#,
)
.unwrap();
let output = run_cli(&path, &["roll", "-E", "unstable"]);
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("Arithmetic overflow"));
let _ = fs::remove_file(path);
}
#[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_requires_at_least_one_die_for_roll_and_analysis() {
let path = unique_path("modifier-only");
for args in [
vec!["roll", "5", "-3"],
vec!["-E", "5", "-3"],
vec!["-R", "5", "-3"],
] {
let output = run_cli(&path, &args);
assert!(!output.status.success(), "args {args:?} should fail");
assert!(
String::from_utf8_lossy(&output.stderr).contains("at least one die"),
"args {args:?} should explain the missing die"
);
}
}
#[test]
fn cli_suggests_the_explicit_roll_command_for_a_bare_expression() {
let path = unique_path("missing-roll-command");
let output = run_cli(&path, &["2d6"]);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("insert 'roll' before the dice expression"));
}
#[test]
fn cli_does_not_echo_shell_metacharacters_in_a_bare_expression_suggestion() {
let path = unique_path("safe-missing-roll-suggestion");
fs::write(
&path,
r#"
[[dice]]
name = "coin;echo_probe"
faces = [1]
"#,
)
.unwrap();
let output = run_cli(&path, &["coin;echo_probe"]);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("insert 'roll' before the dice expression"));
assert!(!stderr.contains("coin;echo_probe"));
let _ = fs::remove_file(path);
}
#[test]
fn cli_reports_unknown_config_subcommands_in_context() {
let path = unique_path("unknown-config-subcommand");
let output = run_cli(&path, &["config", "wat"]);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("unknown config subcommand: wat"));
}
#[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("must have between 2 and 4294967295 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_custom_names_that_its_expression_syntax_cannot_reference() {
for (index, name) in ["5", "2coin", "-coin", "--ev"].into_iter().enumerate() {
let path = unique_path(&format!("unreachable-name-{index}"));
fs::write(
&path,
format!(
r#"
[[dice]]
name = "{name}"
faces = [1]
"#
),
)
.unwrap();
let output = run_cli(&path, &["list"]);
assert!(!output.status.success(), "name {name:?} should be rejected");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("cannot be referenced by CLI dice expressions"));
assert!(stderr.contains(name));
let _ = fs::remove_file(path);
}
}
#[test]
fn cli_reports_zero_roll_count_as_an_expression_error() {
let path = unique_path("zero-roll-count");
let output = run_cli(&path, &["roll", "0d6"]);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("dice count must be at least 1"));
assert!(!stderr.contains("Die must have at least 1 face"));
}
#[test]
fn cli_rejects_expression_expansion_above_core_limit() {
let path = unique_path("oversized-expression");
let output = run_cli(&path, &["roll", "10001d6"]);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Requested 10001 dice, but the limit is 10000"));
}
#[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"));
}