use anstyle::{AnsiColor, Style};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tone {
Good,
Warn,
Bad,
Quiet,
}
impl Tone {
pub fn mark(self) -> &'static str {
match self {
Tone::Good => "✓",
Tone::Warn => "!",
Tone::Bad => "✗",
Tone::Quiet => "○",
}
}
fn style(self) -> Style {
match self {
Tone::Good => AnsiColor::Green.on_default(),
Tone::Warn => AnsiColor::Yellow.on_default().bold(),
Tone::Bad => AnsiColor::Red.on_default().bold(),
Tone::Quiet => DIM,
}
}
}
const DIM: Style = Style::new().dimmed();
const BOLD: Style = Style::new().bold();
const ACCENT: Style = AnsiColor::Cyan.on_default();
fn paint(style: Style, text: &str) -> String {
format!("{style}{text}{style:#}")
}
pub fn toned(tone: Tone, text: &str) -> String {
paint(tone.style(), text)
}
pub fn dim(text: &str) -> String {
paint(DIM, text)
}
pub fn bold(text: &str) -> String {
paint(BOLD, text)
}
pub fn accent(text: &str) -> String {
paint(ACCENT, text)
}
pub fn clip(text: &str, max: usize) -> String {
let one_line = text.split_whitespace().collect::<Vec<_>>().join(" ");
match one_line.char_indices().nth(max.saturating_sub(1)) {
Some((cut, _)) if one_line.chars().count() > max => {
format!("{}…", one_line[..cut].trim_end())
}
_ => one_line,
}
}
pub fn title(command: &str, about: &str) {
if about.is_empty() {
anstream::println!("{}", paint(BOLD, command));
} else {
anstream::println!("{} {}", paint(BOLD, command), paint(DIM, about));
}
}
pub fn section(name: &str, about: &str) {
anstream::println!();
if about.is_empty() {
anstream::println!("{}", paint(BOLD, name));
} else {
anstream::println!("{} {}", paint(BOLD, name), paint(ACCENT, about));
}
}
pub fn check(tone: Tone, label: &str, width: usize, detail: &str, fix: Option<&str>) {
let detail = match tone {
Tone::Quiet => paint(DIM, detail),
_ => detail.to_string(),
};
anstream::println!(
" {} {:<width$} {}",
paint(tone.style(), tone.mark()),
label,
detail
);
if let Some(fix) = fix {
anstream::println!(
" {:<width$} {} {}",
"",
paint(ACCENT, "→"),
paint(ACCENT, fix)
);
}
}
pub fn verdict(tone: Tone, text: &str) {
anstream::println!();
anstream::println!("{} {}", paint(tone.style(), tone.mark()), paint(BOLD, text));
}
pub fn tilde(text: &str) -> String {
let home = std::env::var("HOME")
.ok()
.filter(|h| h.len() > 1)
.or_else(|| std::env::var("USERPROFILE").ok().filter(|h| h.len() > 1));
match home {
Some(home) => text.replace(&home, "~"),
None => text.to_string(),
}
}
pub const WIDTH: usize = 100;
pub fn fold(text: &str, max: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
if line.is_empty() {
line.push_str(word);
continue;
}
if line.chars().count() + 1 + word.chars().count() <= max {
line.push(' ');
line.push_str(word);
continue;
}
let clause = line
.match_indices(' ')
.map(|(at, _)| at)
.rfind(|&at| {
line[..at].ends_with([',', ';', ':', '.']) || line[at + 1..].starts_with('(')
})
.filter(|&at| at >= line.len() / 2);
match clause {
Some(at) => {
let rest = line[at..].trim_start().to_string();
line.truncate(at);
lines.push(std::mem::replace(&mut line, rest));
line.push(' ');
line.push_str(word);
}
None => lines.push(std::mem::replace(&mut line, word.to_string())),
}
}
if !line.is_empty() {
lines.push(line);
}
lines
}
pub fn check_fitted(tone: Tone, label: &str, width: usize, detail: &str, fix: Option<&str>) {
let (detail_at, fix_at) = (width + 6, width + 8);
let fitted = |text: &str, at: usize| {
fold(text, WIDTH.saturating_sub(at).max(40)).join(&format!("\n{}", " ".repeat(at)))
};
let fix = fix.map(|f| fitted(f, fix_at));
check(
tone,
label,
width,
&fitted(detail, detail_at),
fix.as_deref(),
);
}
pub fn step(tone: Tone, text: &str, next: Option<&str>) {
let text = fold(text, WIDTH - 4).join("\n ");
let text = match tone {
Tone::Quiet => paint(DIM, &text),
_ => text,
};
anstream::println!(" {} {text}", paint(tone.style(), tone.mark()));
if let Some(next) = next {
anstream::println!(" {}", paint(ACCENT, &format!("→ {next}")));
}
}
pub fn next_steps(steps: &[(String, Option<String>)]) {
if steps.is_empty() {
return;
}
section("Next", "");
for (command, why) in steps {
anstream::println!(" {}", paint(ACCENT, &format!("→ {command}")));
if let Some(why) = why {
for line in fold(why, WIDTH - 4) {
anstream::println!(" {}", paint(DIM, &line));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_long_line_is_clipped_to_fit_and_says_so() {
assert_eq!(clip("short", 10), "short");
assert_eq!(clip("one\n two three", 20), "one two three");
assert_eq!(clip("abcdefghij klm", 10), "abcdefghi…");
assert_eq!(clip("abcd efgh", 5), "abcd…");
assert_eq!(clip("héllo wörld", 6), "héllo…");
}
#[test]
fn a_long_detail_folds_after_a_clause_and_never_inside_a_word() {
assert_eq!(fold("short", 20), ["short"]);
assert_eq!(fold("", 20), Vec::<String>::new());
assert_eq!(
fold("the token is set, so every program can read it", 24),
["the token is set,", "so every program can", "read it"]
);
assert_eq!(fold("a, bcd efg hij", 12), ["a, bcd efg", "hij"]);
assert_eq!(
fold("key in ~/a/very/long/path/device.key here", 10),
["key in", "~/a/very/long/path/device.key", "here"]
);
assert_eq!(fold("héllo wörld", 5), ["héllo", "wörld"]);
assert_eq!(
fold(
"key in ~/.recall/device.key (a file readable by you only)",
40
),
[
"key in ~/.recall/device.key",
"(a file readable by you only)"
]
);
}
}