rdice-cli 0.1.2

Command-line dice roller powered by rdice-core
use rdice_core::DiceEngine;
use rdice_core::die::{DieKind, FaceValue};
use rdice_core::engine::{DieRoll, RollAnalysis, RollBatchResult};

const RESET: &str = "\x1b[0m";
const BOLD_GREEN: &str = "\x1b[1;32m";
const CYAN: &str = "\x1b[36m";
const DIM: &str = "\x1b[2m";
const MAGENTA: &str = "\x1b[35m";
const RED: &str = "\x1b[31m";
const YELLOW: &str = "\x1b[33m";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RollOutputMode {
    Folded,
    Expanded,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutputStyle {
    color: bool,
}

impl OutputStyle {
    pub fn new(color: bool) -> Self {
        Self { color }
    }

    pub fn color_enabled(self) -> bool {
        self.color
    }
}

pub fn color_enabled_from(args: &[String]) -> bool {
    std::env::var_os("NO_COLOR").is_none() && !args.iter().any(|arg| arg == "--no-color")
}

pub fn print_error(err: &impl std::fmt::Display, style: OutputStyle) {
    eprintln!("{}: {err}", paint(style, RED, "Error"));
}

pub fn print_dice(engine: &DiceEngine, style: OutputStyle) {
    for die in engine.list_dice() {
        let kind = match die.kind {
            DieKind::Builtin => "builtin",
            DieKind::Custom => "custom",
        };
        let faces = format_die_faces(die.kind, &die.faces);
        let line = format!("{} ({kind}): [{faces}]", die.name);
        let color = match die.kind {
            DieKind::Builtin => DIM,
            DieKind::Custom => CYAN,
        };
        println!("{}", paint(style, color, line));
    }
}

fn format_die_faces(kind: DieKind, faces: &[FaceValue]) -> String {
    if kind == DieKind::Builtin
        && faces.len() > 20
        && faces.iter().enumerate().all(
            |(index, face)| matches!(face, FaceValue::Integer(value) if *value == index as i64 + 1),
        )
    {
        return format!("1..{}", faces.len());
    }

    faces
        .iter()
        .map(ToString::to_string)
        .collect::<Vec<_>>()
        .join(", ")
}

pub fn print_roll_result(
    result: &RollBatchResult,
    modifiers: &[i64],
    mode: RollOutputMode,
    style: OutputStyle,
) {
    match mode {
        RollOutputMode::Folded => print_folded_rolls(&result.rolls, style),
        RollOutputMode::Expanded => print_expanded_rolls(&result.rolls, style),
    }

    for modifier in modifiers {
        println!(
            "{}",
            paint(style, YELLOW, format!("modifier: {modifier:+}"))
        );
    }

    let modifier_sum: i64 = modifiers.iter().sum();
    if should_print_total(result, modifiers, mode) {
        match result.integer_sum {
            Some(sum) => println!(
                "{}",
                paint(style, BOLD_GREEN, format!("total: {}", sum + modifier_sum))
            ),
            None if !modifiers.is_empty() => {
                println!(
                    "{}",
                    paint(style, BOLD_GREEN, format!("total: {modifier_sum}"))
                );
            }
            None => {}
        }
    }
}

pub fn print_analysis(
    analysis: &RollAnalysis,
    show_expected: bool,
    show_range: bool,
    style: OutputStyle,
) {
    if show_expected {
        println!(
            "{}",
            paint(
                style,
                MAGENTA,
                format!("expected: {}", analysis.expected_value)
            )
        );
    }
    if show_range {
        let line = format!(
            "range: {}..{}",
            analysis.point_range.min, analysis.point_range.max
        );
        println!("{}", paint(style, MAGENTA, line));
    }
}

fn print_expanded_rolls(rolls: &[DieRoll], style: OutputStyle) {
    for roll in rolls {
        let line = format!("#{} {}: {}", roll.roll_id, roll.die_name, roll.value);
        println!("{}", paint(style, CYAN, line));
    }
}

fn print_folded_rolls(rolls: &[DieRoll], style: OutputStyle) {
    let mut groups: Vec<(&str, Vec<&FaceValue>)> = Vec::new();
    for roll in rolls {
        if let Some((_, values)) = groups
            .iter_mut()
            .find(|(die_name, _)| *die_name == roll.die_name)
        {
            values.push(&roll.value);
        } else {
            groups.push((&roll.die_name, vec![&roll.value]));
        }
    }

    for (die_name, values) in groups {
        let shown_values = values
            .iter()
            .map(ToString::to_string)
            .collect::<Vec<_>>()
            .join(", ");
        let mut sum = 0_i64;
        let mut has_integer = false;
        for value in values {
            if let FaceValue::Integer(integer) = value {
                sum += integer;
                has_integer = true;
            }
        }
        if has_integer {
            let line = format!("{die_name} x{}: {sum}", shown_values.split(", ").count());
            println!("{}", paint(style, CYAN, line));
        } else {
            let line = format!("{die_name}: [{shown_values}]");
            println!("{}", paint(style, CYAN, line));
        }
    }
}

fn should_print_total(result: &RollBatchResult, modifiers: &[i64], mode: RollOutputMode) -> bool {
    let roll_count = result.rolls.len();
    let modifier_count = modifiers.len();
    match mode {
        RollOutputMode::Expanded => roll_count + modifier_count > 1,
        RollOutputMode::Folded => {
            let mut group_names: Vec<&str> = Vec::new();
            for roll in &result.rolls {
                if !group_names.contains(&roll.die_name.as_str()) {
                    group_names.push(&roll.die_name);
                }
            }
            let group_count = group_names.len();
            group_count + modifier_count > 1
        }
    }
}

fn paint(style: OutputStyle, ansi: &str, text: impl std::fmt::Display) -> String {
    if style.color_enabled() {
        format!("{ansi}{text}{RESET}")
    } else {
        text.to_string()
    }
}