mod config;
mod error;
mod output;
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::Command;
use rdice_core::{DiceEngine, DiceError, DieId, ParsedRoll, parse_roll_exprs};
use crate::error::{CliError, Result};
use crate::output::{OutputStyle, RollOutputMode};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct AnalysisOptions {
expected: bool,
range: bool,
}
impl AnalysisOptions {
fn any(self) -> bool {
self.expected || self.range
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RollArgs {
mode: RollOutputMode,
analysis: AnalysisOptions,
expr: Vec<String>,
}
fn main() {
let raw_args: Vec<String> = std::env::args().skip(1).collect();
let stdout_style = OutputStyle::new(output::color_enabled_from(
&raw_args,
std::io::stdout().is_terminal(),
));
let stderr_style = OutputStyle::new(output::color_enabled_from(
&raw_args,
std::io::stderr().is_terminal(),
));
if let Err(err) = run(raw_args, stdout_style) {
if err.is_broken_pipe() {
return;
}
output::print_error(&err, stderr_style);
std::process::exit(1);
}
}
fn run(raw_args: Vec<String>, style: OutputStyle) -> Result<()> {
let args = parse_global_args(raw_args)?;
match args.as_slice() {
[] => return print_help(),
[arg] if arg == "help" || arg == "--help" || arg == "-h" => return print_help(),
[arg] if arg == "--version" || arg == "-V" => return print_version(),
[help, command] if help == "help" => {
return print_command_help(command);
}
[help, ..] if help == "help" => {
return Err(DiceError::InvalidArguments(
"help accepts exactly one topic: roll, list, or config".to_string(),
)
.into());
}
_ => {}
}
if let Some(command @ ("roll" | "list" | "config")) = args.first().map(String::as_str)
&& args
.iter()
.skip(1)
.any(|arg| arg == "--help" || arg == "-h")
{
return print_command_help(command);
}
let config_path = default_config_path()?;
if args.first().is_some_and(|command| command == "config") {
match args.as_slice() {
[_] => {
return output::print_user_line(&config_path.display().to_string());
}
[_, subcommand] if subcommand == "path" => {
return output::print_user_line(&config_path.display().to_string());
}
[_, subcommand] if subcommand == "edit" => return edit_config(&config_path),
[_, subcommand] if subcommand == "check" => {}
[_, subcommand, ..] if matches!(subcommand.as_str(), "path" | "edit" | "check") => {
return Err(DiceError::InvalidArguments(format!(
"config {subcommand} does not accept additional arguments"
))
.into());
}
[_, subcommand, ..] => {
return Err(DiceError::InvalidArguments(format!(
"unknown config subcommand: {subcommand}"
))
.into());
}
_ => unreachable!("config command has at least one argument"),
}
}
if matches!(args.as_slice(), [command, ..] if command == "list" && args.len() > 1) {
return Err(
DiceError::InvalidArguments("list does not accept arguments".to_string()).into(),
);
}
let mut engine = DiceEngine::new();
config::load_custom_dice(&config_path, &mut engine)?;
if matches!(args.as_slice(), [command, subcommand] if command == "config" && subcommand == "check")
{
if config_path.exists() {
output::print_user_line(&format!("Config OK: {}", config_path.display()))?;
} else {
output::print_user_line(&format!(
"Config not found; using built-in dice only: {}",
config_path.display()
))?;
}
return Ok(());
}
if !starts_with_known_command(&args)
&& let Some((analysis_options, expr)) = parse_top_level_analysis_args(&args)?
{
let parsed = parse_expr_args(&expr)?;
let die_ids = resolve_dice(&engine, &parsed)?;
let analysis = engine.analyze_roll(&die_ids, &parsed.modifiers)?;
output::print_analysis(
&analysis,
analysis_options.expected,
analysis_options.range,
style,
)?;
return Ok(());
}
match args.as_slice() {
[] => unreachable!("empty arguments return before configuration loading"),
[command] if command == "list" => {
output::print_dice(&engine, style)
}
[command, args @ ..] if command == "roll" && !args.is_empty() => {
let roll_args = parse_roll_args(args)?;
let parsed = parse_expr_args(&roll_args.expr)?;
let die_ids = resolve_dice(&engine, &parsed)?;
let analysis = roll_args
.analysis
.any()
.then(|| engine.analyze_roll(&die_ids, &parsed.modifiers))
.transpose()?;
let result = engine.roll_dice(&die_ids)?;
output::print_roll_result(&result, &parsed.modifiers, roll_args.mode, style)?;
if let Some(analysis) = analysis {
output::print_analysis(
&analysis,
roll_args.analysis.expected,
roll_args.analysis.range,
style,
)?;
}
Ok(())
}
[command, ..] if command == "roll" => Err(DiceError::InvalidArguments(
"roll requires at least one dice expression".to_string(),
)
.into()),
[command, ..] => match parse_expr_args(&args)
.and_then(|parsed| resolve_dice(&engine, &parsed).map(|_| parsed))
{
Ok(_) => Err(DiceError::InvalidArguments(
"rolling requires the explicit 'roll' command; insert 'roll' before the dice expression"
.to_string(),
)
.into()),
Err(_) => {
Err(DiceError::InvalidArguments(format!("unknown command: {command}")).into())
}
},
}
}
fn parse_global_args(args: Vec<String>) -> Result<Vec<String>> {
let mut parsed = Vec::new();
for arg in args {
match arg.as_str() {
"--no-color" => {}
_ => parsed.push(arg),
}
}
Ok(parsed)
}
fn edit_config(config_path: &PathBuf) -> Result<()> {
ensure_config_file(config_path)?;
let editor = default_editor();
let status = editor_command(&editor)
.arg(config_path)
.status()
.map_err(|source| CliError::EditorLaunch {
editor: editor.clone(),
source,
})?;
if !status.success() {
return Err(CliError::EditorExit { editor, status });
}
Ok(())
}
fn ensure_config_file(config_path: &PathBuf) -> Result<()> {
if let Some(parent) = config_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent)?;
}
if !config_path.exists() {
fs::write(config_path, default_config_template())?;
}
Ok(())
}
fn default_editor() -> String {
["VISUAL", "EDITOR"]
.into_iter()
.find_map(|name| {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
.unwrap_or_else(|| default_fallback_editor().to_string())
}
fn editor_command(editor: &str) -> Command {
if Path::new(editor).is_file() {
return Command::new(editor);
}
let mut parts = editor.split_whitespace();
let command = parts.next().expect("editor is never empty");
let mut editor_command = Command::new(command);
editor_command.args(parts);
editor_command
}
fn default_fallback_editor() -> &'static str {
if cfg!(windows) { "notepad" } else { "vi" }
}
fn default_config_template() -> &'static str {
r#"# rdice config
#
# Define custom dice here. Numeric dice use [n]d[m] directly in commands,
# so you only need custom dice for text faces or non-standard face lists.
#
# [[dice]]
# name = "coin"
# faces = ["heads", "tails"]
#
# [[dice]]
# name = "fate"
# faces = [-1, 0, 1]
"#
}
fn parse_roll_args(args: &[String]) -> Result<RollArgs> {
let mut mode = RollOutputMode::Folded;
let mut explicit_mode = None;
let mut analysis = AnalysisOptions::default();
let mut expr = Vec::new();
for arg in args {
match arg.as_str() {
"-f" | "--folded" => {
if explicit_mode == Some(RollOutputMode::Expanded) {
return Err(DiceError::InvalidArguments(
"folded and expanded output modes cannot be combined".to_string(),
)
.into());
}
mode = RollOutputMode::Folded;
explicit_mode = Some(mode);
}
"-x" | "--expanded" => {
if explicit_mode == Some(RollOutputMode::Folded) {
return Err(DiceError::InvalidArguments(
"folded and expanded output modes cannot be combined".to_string(),
)
.into());
}
mode = RollOutputMode::Expanded;
explicit_mode = Some(mode);
}
"-E" | "--ev" => analysis.expected = true,
"-R" | "--range" => analysis.range = true,
value if value.starts_with('-') && value.parse::<i64>().is_err() => {
return Err(
DiceError::InvalidArguments(format!("unknown roll option: {value}")).into(),
);
}
_ => expr.push(arg.clone()),
}
}
if expr.is_empty() {
return Err(DiceError::InvalidArguments(
"roll requires at least one dice expression".to_string(),
)
.into());
}
Ok(RollArgs {
mode,
analysis,
expr,
})
}
fn parse_top_level_analysis_args(
args: &[String],
) -> Result<Option<(AnalysisOptions, Vec<String>)>> {
let mut analysis = AnalysisOptions::default();
let mut expr = Vec::new();
for arg in args {
match arg.as_str() {
"-E" | "--ev" => analysis.expected = true,
"-R" | "--range" => analysis.range = true,
value if value.starts_with('-') && value.parse::<i64>().is_err() => {
if analysis.any() {
return Err(DiceError::InvalidArguments(format!(
"unknown analysis option: {value}"
))
.into());
}
return Ok(None);
}
_ => expr.push(arg.clone()),
}
}
if !analysis.any() {
return Ok(None);
}
if expr.is_empty() {
return Err(DiceError::InvalidArguments(
"analysis requires at least one dice expression".to_string(),
)
.into());
}
Ok(Some((analysis, expr)))
}
fn starts_with_known_command(args: &[String]) -> bool {
matches!(
args.first().map(String::as_str),
Some("roll" | "list" | "config" | "help" | "--help" | "-h")
)
}
fn parse_expr_args(expr: &[String]) -> Result<ParsedRoll> {
if expr.is_empty() {
return Err(DiceError::InvalidArguments(
"expected at least one dice expression".to_string(),
)
.into());
}
let expr_refs = expr.iter().map(String::as_str).collect::<Vec<_>>();
let parsed = parse_roll_exprs(&expr_refs)?;
if parsed.dice.is_empty() {
return Err(DiceError::InvalidArguments(
"rolling and analysis require at least one die".to_string(),
)
.into());
}
Ok(parsed)
}
fn resolve_dice(engine: &DiceEngine, parsed: &ParsedRoll) -> Result<Vec<DieId>> {
parsed
.dice
.iter()
.map(|name| engine.resolve_die(name).map_err(Into::into))
.collect()
}
fn default_config_path() -> Result<PathBuf> {
if let Some(path) = std::env::var_os("RDICE_CONFIG_PATH") {
let path = PathBuf::from(path);
if path.as_os_str().is_empty() {
return Err(CliError::EmptyConfigPath);
}
return Ok(path);
}
let home = dirs::home_dir().ok_or(CliError::ConfigPathUnavailable)?;
Ok(home.join(".config").join("rdice").join("config.toml"))
}
fn print_help() -> Result<()> {
output::print_line(
"Usage:\n rdice roll [-f|--folded] [-x|--expanded] [-E|--ev] [-R|--range] <dice-expr...>\n rdice <analysis-option...> <dice-expr...>\n rdice list\n rdice config path\n rdice config edit\n rdice config check\n rdice help [roll|list|config]\n rdice --version\n\nOptions:\n -E, --ev Show expected value\n -R, --range Show point range\n -f, --folded Group equal dice and show numeric group sums\n -x, --expanded Show every rolled die\n --no-color Disable ANSI color output\n -V, --version Show version\n\nTop-level analysis:\n Use -E and/or -R without 'roll'; no dice are rolled.\n\nEnvironment:\n RDICE_CONFIG_PATH Path to rdice config TOML\n NO_COLOR Disable ANSI color output\n\nExpressions:\n 3d13 Roll three dynamic numeric 13-sided dice\n 2coin Roll a configured custom die twice\n 5 -3 Apply integer modifiers to at least one die",
)
}
fn print_command_help(command: &str) -> Result<()> {
match command {
"roll" => print_roll_help(),
"list" => print_list_help(),
"config" => print_config_help(),
_ => Err(DiceError::InvalidArguments(format!("unknown help topic: {command}")).into()),
}
}
fn print_roll_help() -> Result<()> {
output::print_line(
"Usage:\n rdice roll [-f|--folded] [-x|--expanded] [-E|--ev] [-R|--range] <dice-expr...>\n\nRolls at least one die. Integer tokens after the dice are modifiers.\nExamples: rdice roll 3d6; rdice roll -x 2coin 1",
)
}
fn print_list_help() -> Result<()> {
output::print_line(
"Usage:\n rdice list\n\nLists built-in and configured dice. Numeric D<N> dice are dynamic for N >= 2.",
)
}
fn print_config_help() -> Result<()> {
output::print_line("Usage:\n rdice config path\n rdice config edit\n rdice config check")
}
fn print_version() -> Result<()> {
output::print_line(format_args!("rdice {}", env!("CARGO_PKG_VERSION")))
}