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> {
self.summary_within(noun, option_budget())
}
pub fn summary_within(&self, noun: &str, budget: usize) -> Vec<String> {
let mut lines = Vec::new();
if !self.done.is_empty() {
lines.extend(wrap_list(
&format!("{noun}: installed "),
&self.done,
" ",
budget,
));
}
if !self.already.is_empty() {
lines.extend(wrap_list(
&format!("{noun}: "),
&self.already,
" already present",
budget,
));
}
lines
}
pub fn finish(self, noun: &str) {
for line in self.summary(noun) {
ok(&line);
}
}
}
fn wrap_list(prefix: &str, items: &[String], suffix: &str, budget: usize) -> Vec<String> {
const HANGING_INDENT: &str = " ";
let mut lines = Vec::new();
let mut current = prefix.to_string();
let mut count_on_line = 0;
for (i, item) in items.iter().enumerate() {
let last = i + 1 == items.len();
let piece = if last {
format!("{item}{suffix}")
} else {
format!("{item}, ")
};
if count_on_line > 0 && display_width(¤t) + display_width(&piece) > budget {
lines.push(current.trim_end().to_string());
current = HANGING_INDENT.to_string();
count_on_line = 0;
}
current.push_str(&piece);
count_on_line += 1;
}
if !current.trim().is_empty() {
lines.push(current.trim_end().to_string());
}
lines
}
pub const OPTION_SEPARATOR: &str = " - ";
pub fn option_line(key: &str, description: &str, badge: &str) -> String {
let full = format!("{key}{OPTION_SEPARATOR}{description} ({badge})");
let budget = option_budget();
if display_width(&full) <= budget {
return full;
}
let prefix = format!("{key}{OPTION_SEPARATOR}");
let tail = format!(" ({badge})");
let room = budget
.saturating_sub(display_width(&prefix))
.saturating_sub(display_width(&tail))
.saturating_sub(1);
if room < 8 {
return format!("{key}{tail}");
}
format!("{prefix}{}…{tail}", truncate_to(description, room))
}
fn option_budget() -> usize {
const MARKER_AND_MARGIN: usize = 8;
const FALLBACK: usize = 100;
let columns = crossterm::terminal::size()
.map(|(cols, _)| cols as usize)
.unwrap_or(FALLBACK);
columns.saturating_sub(MARKER_AND_MARGIN).max(24)
}
pub fn page_size() -> usize {
const CHROME: usize = 5;
const FALLBACK: usize = 10;
crossterm::terminal::size()
.map(|(_, rows)| (rows as usize).saturating_sub(CHROME))
.unwrap_or(FALLBACK)
.clamp(5, 15)
}
fn display_width(s: &str) -> usize {
unicode_width::UnicodeWidthStr::width(s)
}
fn truncate_to(s: &str, width: usize) -> String {
let mut out = String::new();
let mut used = 0;
for c in s.chars() {
let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if used + w > width {
break;
}
out.push(c);
used += w;
}
out.trim_end().to_string()
}
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 a_truncated_option_line_still_matches_its_key() {
let long = "Immutable data utility library for tables/arrays (Llama-style helpers) - no longer actively maintained upstream, but stable and widely used";
let line = option_line("sift", long, "stable");
assert!(option_is(&line, "sift"), "{line}");
assert_eq!(option_key(&line), "sift", "{line}");
assert!(!option_is(&line, "sif"), "a shorter key must not match");
assert!(
line.ends_with("(stable)"),
"the maintenance badge survives truncation - it is what the user is choosing on: {line}"
);
}
#[test]
fn a_very_narrow_budget_keeps_the_key_and_the_badge() {
let line = option_line("wally-package-types", &"x".repeat(200), "active");
assert!(option_is(&line, "wally-package-types") || line.starts_with("wally-package-types"));
assert!(line.contains("(active)"), "{line}");
}
#[test]
fn every_option_line_fits_the_budget() {
let budget = option_budget();
for (key, desc) in [
("t", "Runtime type checker - validates values (e.g. RemoteEvent payloads) against type definitions"),
("testez", &"very long description ".repeat(12)),
("sift", "Immutable data utility library for tables/arrays (Llama-style helpers) - no longer actively maintained upstream, but stable and widely used"),
("janitor", "Cleanup"),
] {
let line = option_line(key, desc, "stable");
assert!(
display_width(&line) <= budget,
"{key} renders {} columns, budget {budget}: {line}",
display_width(&line)
);
}
}
#[test]
fn a_short_option_line_is_untouched() {
let line = option_line("janitor", "Cleanup utility", "active");
assert_eq!(line, "janitor - Cleanup utility (active)");
}
#[test]
fn truncation_respects_display_width_not_byte_length() {
assert_eq!(truncate_to("ok", 4), "ok");
assert!(display_width(&truncate_to("aaaaaa", 5)) <= 5);
assert_eq!(truncate_to("abcdef", 3), "abc");
assert_eq!(truncate_to("ab cdef", 3), "ab");
}
#[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_names_every_item() {
let mut tally = Tally::new();
for name in ["git", "vscode", "studio", "roblox", "blender"] {
tally.already(name);
}
assert_eq!(
tally.summary_within("system apps", 200),
vec!["system apps: git, vscode, studio, roblox, blender already present"]
);
}
#[test]
fn a_tally_reports_each_kind_separately() {
let mut tally = Tally::new();
tally.did("rojo");
tally.did("wally");
tally.already("stylua");
assert_eq!(
tally.summary_within("rokit tools", 200),
vec![
"rokit tools: installed rojo, wally".to_string(),
"rokit tools: stylua already present".to_string(),
]
);
}
#[test]
fn a_long_list_wraps_rather_than_collapsing() {
let mut tally = Tally::new();
for name in [
"rojo", "wally", "wally-package-types", "selene", "stylua", "lute",
"luau-lsp-cli", "tarmac", "mantle",
] {
tally.already(name);
}
let lines = tally.summary_within("rokit tools", 60);
assert!(lines.len() > 1, "should wrap: {lines:?}");
for line in &lines {
assert!(display_width(line) <= 60, "line too wide: {line:?}");
}
let joined = lines.join(" ");
for name in ["rojo", "wally-package-types", "mantle"] {
assert!(joined.contains(name), "{name} missing from {lines:?}");
}
assert!(lines[1].starts_with(" "), "hanging indent: {lines:?}");
}
#[test]
fn an_item_wider_than_the_budget_is_not_truncated() {
let mut tally = Tally::new();
tally.already("a-very-long-tool-name-that-exceeds-any-sensible-budget");
let lines = tally.summary_within("tools", 20);
assert!(lines.join(" ").contains("a-very-long-tool-name-that-exceeds-any-sensible-budget"));
}
#[test]
fn an_empty_tally_says_nothing() {
assert!(Tally::new().summary_within("tools", 80).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");
}
}