rdice-cli 0.2.1

Command-line dice roller powered by rdice-core
use std::io::{self, Write};

use rdice_core::{
    DiceEngine, DiceError, DieRoll, FaceValue, Result as CoreResult, RollAnalysis, RollBatchResult,
};

use crate::error::Result;

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], is_terminal: bool) -> bool {
    color_enabled(args, is_terminal, std::env::var_os("NO_COLOR").is_some())
}

fn color_enabled(args: &[String], is_terminal: bool, no_color_env: bool) -> bool {
    is_terminal && !no_color_env && !args.iter().any(|arg| arg == "--no-color")
}

pub fn print_error(err: &impl std::fmt::Display, style: OutputStyle) {
    let _ = writeln!(
        io::stderr().lock(),
        "{}: {}",
        paint(style, RED, "Error"),
        escape_user_text(&err.to_string())
    );
}

pub fn print_line(line: impl std::fmt::Display) -> Result<()> {
    writeln!(io::stdout().lock(), "{line}")?;
    Ok(())
}

pub fn print_user_line(line: &str) -> Result<()> {
    print_line(escape_user_text(line))
}

pub fn print_dice(engine: &DiceEngine, style: OutputStyle) -> Result<()> {
    for die in engine.list_dice() {
        let kind = if die.is_numeric() {
            "builtin"
        } else {
            "custom"
        };
        let faces = format_die_faces(die.is_numeric(), die.faces());
        let line = format!("{} ({kind}): [{faces}]", escape_user_text(die.name()));
        let color = if die.is_numeric() { DIM } else { CYAN };
        print_line(paint(style, color, line))?;
    }
    print_line(paint(style, DIM, "D<N> (dynamic numeric): N >= 2"))
}

fn format_die_faces(is_numeric: bool, faces: &[FaceValue]) -> String {
    if is_numeric
        && 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(format_list_value)
        .collect::<Vec<_>>()
        .join(", ")
}

fn format_list_value(value: &FaceValue) -> String {
    match value {
        FaceValue::Integer(value) => value.to_string(),
        FaceValue::Text(value) => format!("{value:?}"),
    }
}

fn escape_user_text(value: &str) -> String {
    let mut escaped = String::with_capacity(value.len());
    for character in value.chars() {
        if character.is_control() {
            escaped.extend(character.escape_default());
        } else {
            escaped.push(character);
        }
    }
    escaped
}

pub fn print_roll_result(
    result: &RollBatchResult,
    modifiers: &[i64],
    mode: RollOutputMode,
    style: OutputStyle,
) -> Result<()> {
    let modifier_sum = modifiers.iter().try_fold(0_i64, |sum, modifier| {
        sum.checked_add(*modifier)
            .ok_or(DiceError::ArithmeticOverflow {
                operation: "summing CLI modifiers",
            })
    })?;
    let total = result
        .integer_sum
        .map(|sum| {
            sum.checked_add(modifier_sum)
                .ok_or(DiceError::ArithmeticOverflow {
                    operation: "summing CLI roll total",
                })
        })
        .transpose()?;

    match mode {
        RollOutputMode::Folded => print_folded_rolls(&result.rolls, style),
        RollOutputMode::Expanded => print_expanded_rolls(&result.rolls, style),
    }?;

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

    if should_print_total(result, modifiers, mode) {
        match total {
            Some(total) => print_line(paint(style, BOLD_GREEN, format!("total: {total}")))?,
            None if !modifiers.is_empty() => {
                print_line(paint(style, BOLD_GREEN, format!("total: {modifier_sum}")))?;
            }
            None => {}
        }
    }
    Ok(())
}

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

fn print_expanded_rolls(rolls: &[DieRoll], style: OutputStyle) -> Result<()> {
    for roll in rolls {
        let value = format_list_value(&roll.face.value);
        let die_name = escape_user_text(&roll.die_name);
        let line = format!("#{} {die_name}: {value}", roll.ordinal);
        print_line(paint(style, CYAN, line))?;
    }
    Ok(())
}

fn print_folded_rolls(rolls: &[DieRoll], style: OutputStyle) -> Result<()> {
    for line in format_folded_rolls(rolls)? {
        print_line(paint(style, CYAN, line))?;
    }
    Ok(())
}

fn format_folded_rolls(rolls: &[DieRoll]) -> CoreResult<Vec<String>> {
    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.face.value);
        } else {
            groups.push((&roll.die_name, vec![&roll.face.value]));
        }
    }

    let mut lines = Vec::with_capacity(groups.len());
    for (die_name, values) in groups {
        let value_count = values.len();
        let shown_values = values
            .iter()
            .map(|value| format_list_value(value))
            .collect::<Vec<_>>()
            .join(", ");
        if values
            .iter()
            .all(|value| matches!(value, FaceValue::Integer(_)))
        {
            let mut sum = 0_i64;
            for value in values {
                let FaceValue::Integer(integer) = value else {
                    unreachable!("all folded values were checked as integers");
                };
                sum = sum
                    .checked_add(*integer)
                    .ok_or(DiceError::ArithmeticOverflow {
                        operation: "summing folded CLI roll group",
                    })?;
            }
            lines.push(format!(
                "{} x{value_count}: {sum}",
                escape_user_text(die_name)
            ));
        } else {
            lines.push(format!("{}: [{shown_values}]", escape_user_text(die_name)));
        }
    }
    Ok(lines)
}

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()
    }
}

#[cfg(test)]
mod tests {
    use rdice_core::{DieId, DieRoll, FaceValue, RolledFace};

    use super::{color_enabled, format_folded_rolls};

    #[test]
    fn color_requires_a_terminal_and_no_explicit_disable() {
        let no_args = Vec::new();
        assert!(color_enabled(&no_args, true, false));
        assert!(!color_enabled(&no_args, false, false));
        assert!(!color_enabled(&no_args, true, true));
        assert!(!color_enabled(&["--no-color".into()], true, false));
    }

    #[test]
    fn folded_mixed_values_preserve_text_and_do_not_infer_count_from_rendering() {
        let die_id = DieId::custom(1).unwrap();
        let rolls = [
            DieRoll {
                ordinal: 1,
                die_id,
                die_name: "mixed".into(),
                face: RolledFace {
                    face_index: 0,
                    value: FaceValue::Integer(1),
                },
            },
            DieRoll {
                ordinal: 2,
                die_id,
                die_name: "mixed".into(),
                face: RolledFace {
                    face_index: 1,
                    value: FaceValue::Text("a, b".into()),
                },
            },
        ];

        assert_eq!(
            format_folded_rolls(&rolls).unwrap(),
            vec!["mixed: [1, \"a, b\"]"]
        );
    }
}