use std::sync::atomic::{AtomicBool, Ordering};
static VERBOSE: AtomicBool = AtomicBool::new(false);
fn emoji() -> bool {
!matches!(std::env::var("RPROJ_NO_EMOJI").as_deref(), Ok("1" | "true"))
}
pub fn emoji_enabled() -> bool {
emoji()
}
fn marker(icon: &str, pad: &str, fallback: &str) -> String {
render_marker(emoji(), icon, pad, fallback)
}
fn render_marker(emoji: bool, icon: &str, pad: &str, fallback: &str) -> String {
match emoji {
true => format!("{icon}{pad}"),
false => format!("{fallback} "),
}
}
pub fn set_verbose(on: bool) {
VERBOSE.store(on, Ordering::Relaxed);
}
pub fn is_verbose() -> bool {
VERBOSE.load(Ordering::Relaxed)
}
pub fn section(title: &str) {
match emoji() {
true => println!("\n📦 {title}"),
false => println!("\n{title}"),
}
}
pub fn ok(msg: &str) {
println!(" {}{msg}", marker("✅", " ", "+"));
}
pub fn skip(msg: &str) {
println!(" {}{msg}", marker("➖", " ", "-"));
}
pub fn warn(msg: &str) {
println!(" {}{msg}", marker("❗", " ", "!"));
}
pub fn error(err: &anyhow::Error) {
use anstyle::{AnsiColor, Color, Style};
let red = Style::new()
.fg_color(Some(Color::Ansi(AnsiColor::Red)))
.bold();
let reset = red.render_reset();
let red = red.render();
anstream::eprintln!("\n{red}{}{}{reset}", marker("❗", " ", "!"), err);
for cause in err.chain().skip(1) {
anstream::eprintln!(" {cause}");
}
}
pub fn detail(msg: &str) {
for line in msg.lines() {
println!(" {line}");
}
}
pub fn command(program: &str, args: &[&str]) {
if is_verbose() {
println!(" $ {program} {}", args.join(" "));
}
}
pub fn passthrough(stdout: &str, stderr: &str) {
for stream in [stdout, stderr] {
for line in stream.lines().filter(|l| !l.trim().is_empty()) {
println!(" {line}");
}
}
}
#[derive(Default)]
pub struct Tally {
done: Vec<String>,
already: Vec<String>,
}
impl Tally {
pub fn new() -> Self {
Self::default()
}
pub fn did(&mut self, name: &str) {
self.done.push(name.to_string());
}
pub fn already(&mut self, name: &str) {
self.already.push(name.to_string());
}
pub fn summary(&self, noun: &str) -> Vec<String> {
let mut lines = Vec::new();
if !self.done.is_empty() {
lines.push(format!("{noun}: installed {}", self.done.join(", ")));
}
if !self.already.is_empty() {
let n = self.already.len();
lines.push(if n <= 4 {
format!("{noun}: {} already present", self.already.join(", "))
} else {
format!("{n} {noun} already present")
});
}
lines
}
pub fn finish(self, noun: &str) {
for line in self.summary(noun) {
ok(&line);
}
}
}
pub const OPTION_SEPARATOR: &str = " - ";
pub fn option_key(label: &str) -> &str {
label.split(OPTION_SEPARATOR).next().unwrap_or(label)
}
pub fn option_is(label: &str, key: &str) -> bool {
label.starts_with(&format!("{key}{OPTION_SEPARATOR}"))
}
pub const MULTISELECT_HELP: &str =
"↑↓ to move, space to select one, → to all, ← to none, enter to confirm, type to filter";
pub fn compact_multi_answer(opts: &[inquire::list_option::ListOption<&String>]) -> String {
if opts.is_empty() {
return "none".to_string();
}
let keys: Vec<&str> = opts.iter().map(|o| option_key(o.value)).collect();
format!("{} selected: {}", keys.len(), keys.join(", "))
}
pub fn compact_select_answer(opt: inquire::list_option::ListOption<&String>) -> String {
option_key(opt.value).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn option_matching_does_not_confuse_prefixes() {
let label = "reactRoblox - React's Roblox renderer (active)";
assert!(option_is(label, "reactRoblox"));
assert!(!option_is(label, "react"), "prefix must not match a longer key");
assert_eq!(option_key(label), "reactRoblox");
}
#[test]
fn option_key_handles_labels_without_a_separator() {
assert_eq!(option_key("none"), "none");
}
#[test]
fn both_marker_styles_are_the_same_width() {
assert_eq!(render_marker(true, "✅", " ", "+"), "✅ ");
assert_eq!(render_marker(false, "✅", " ", "+"), "+ ");
for (icon, pad, fallback) in [("✅", " ", "+"), ("➖", " ", "-"), ("❗", " ", "!")] {
let on = render_marker(true, icon, pad, fallback);
let off = render_marker(false, icon, pad, fallback);
assert_eq!(on.chars().count(), off.chars().count(), "{icon}");
}
}
#[test]
fn no_marker_icon_carries_a_variation_selector() {
for icon in ["✅", "➖", "❗", "📦"] {
assert_eq!(
icon.chars().count(),
1,
"{icon} must be one scalar, not a base char plus a selector"
);
assert!(
!icon.contains('\u{FE0F}'),
"{icon} must not carry VARIATION SELECTOR-16"
);
}
}
#[test]
fn multiselect_help_mentions_how_to_confirm() {
assert!(MULTISELECT_HELP.contains("enter to confirm"));
}
#[test]
fn a_tally_reports_each_kind_on_a_single_line() {
let mut tally = Tally::new();
tally.did("rojo");
tally.did("wally");
tally.already("stylua");
assert_eq!(
tally.summary("rokit tools"),
vec![
"rokit tools: installed rojo, wally".to_string(),
"rokit tools: stylua already present".to_string(),
]
);
}
#[test]
fn a_long_already_present_list_collapses_to_a_count() {
let mut tally = Tally::new();
for name in ["a", "b", "c", "d"] {
tally.already(name);
}
assert_eq!(tally.summary("tools"), vec!["tools: a, b, c, d already present"]);
tally.already("e");
assert_eq!(tally.summary("tools"), vec!["5 tools already present"]);
}
#[test]
fn an_empty_tally_says_nothing() {
assert!(Tally::new().summary("tools").is_empty());
}
#[test]
fn a_multi_select_summary_lists_keys_only() {
let labels = ["react - Roact-style declarative UI library (active)".to_string(),
"reflex - Redux-inspired state container (active)".to_string()];
let opts: Vec<inquire::list_option::ListOption<&String>> = labels
.iter()
.enumerate()
.map(|(i, label)| inquire::list_option::ListOption::new(i, label))
.collect();
assert_eq!(compact_multi_answer(&opts), "2 selected: react, reflex");
assert_eq!(compact_multi_answer(&[]), "none");
}
}