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\"]"]
);
}
}