1use atty::Stream;
2use clap::builder::styling::{AnsiColor, Effects, Styles};
3use crossterm::style::{Attribute, Color, Stylize, style};
4use std::fmt::Display;
5use unicode_width::UnicodeWidthStr;
6
7#[derive(Clone, Copy)]
8pub enum Tone {
9 Success,
10 Error,
11 Warning,
12 Info,
13 Accent,
14 Muted,
15}
16
17pub fn clap_styles() -> Styles {
18 Styles::styled()
19 .header(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
20 .usage(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
21 .literal(AnsiColor::Yellow.on_default().effects(Effects::BOLD))
22 .placeholder(AnsiColor::Green.on_default())
23 .valid(AnsiColor::Green.on_default().effects(Effects::BOLD))
24 .invalid(AnsiColor::Red.on_default().effects(Effects::BOLD))
25 .error(AnsiColor::Red.on_default().effects(Effects::BOLD))
26}
27
28fn colors_enabled(stream: Stream) -> bool {
29 std::env::var_os("NO_COLOR").is_none() && atty::is(stream)
30}
31
32pub fn stdout_styled() -> bool {
33 colors_enabled(Stream::Stdout)
34}
35
36pub fn stderr_styled() -> bool {
37 colors_enabled(Stream::Stderr)
38}
39
40fn tone_color(tone: Tone) -> Color {
41 match tone {
42 Tone::Success => Color::Green,
43 Tone::Error => Color::Red,
44 Tone::Warning => Color::Yellow,
45 Tone::Info => Color::Blue,
46 Tone::Accent => Color::Cyan,
47 Tone::Muted => Color::DarkGrey,
48 }
49}
50
51fn tone_label(tone: Tone) -> &'static str {
52 match tone {
53 Tone::Success => "OK",
54 Tone::Error => "ERR",
55 Tone::Warning => "WARN",
56 Tone::Info => "INFO",
57 Tone::Accent => "NOTE",
58 Tone::Muted => "··",
59 }
60}
61
62fn plain_prefix(tone: Tone) -> &'static str {
63 match tone {
64 Tone::Success => "✅",
65 Tone::Error => "❌",
66 Tone::Warning => "⚠️",
67 Tone::Info => "ℹ️",
68 Tone::Accent => "•",
69 Tone::Muted => "·",
70 }
71}
72
73pub fn tone_text(text: &str, tone: Tone) -> String {
74 if stdout_styled() {
75 format!(
76 "{}",
77 style(text)
78 .with(tone_color(tone))
79 .attribute(Attribute::Bold)
80 )
81 } else {
82 text.to_string()
83 }
84}
85
86pub fn accent(text: &str) -> String {
87 if stdout_styled() {
88 format!(
89 "{}",
90 style(text).with(Color::Cyan).attribute(Attribute::Bold)
91 )
92 } else {
93 text.to_string()
94 }
95}
96
97pub fn muted(text: &str) -> String {
98 if stdout_styled() {
99 format!("{}", style(text).with(Color::DarkGrey))
100 } else {
101 text.to_string()
102 }
103}
104
105pub fn divider(width: usize) -> String {
106 if stdout_styled() {
107 format!("{}", style("─".repeat(width)).with(Color::DarkGrey))
108 } else {
109 "-".repeat(width)
110 }
111}
112
113pub fn header(title: &str) -> String {
114 if stdout_styled() {
115 format!(
116 "{}\n{}",
117 accent(title),
118 divider(title.chars().count().max(24))
119 )
120 } else {
121 format!("=== {} ===", title)
122 }
123}
124
125pub fn section(title: &str) -> String {
126 if stdout_styled() {
127 format!("\n{}", accent(title))
128 } else {
129 format!("\n=== {} ===", title)
130 }
131}
132
133pub fn key_value(label: &str, value: impl Display) -> String {
134 if stdout_styled() {
135 let width = UnicodeWidthStr::width(label).min(18);
136 let padding = 18usize.saturating_sub(width).max(1);
137 format!("{}{}{}", muted(label), " ".repeat(padding), value)
138 } else {
139 format!("{label}: {value}")
140 }
141}
142
143pub fn status_line_stdout(tone: Tone, message: &str) -> String {
144 if stdout_styled() {
145 format!(
146 "{} {}",
147 style(format!(" {} ", tone_label(tone)))
148 .with(tone_color(tone))
149 .attribute(Attribute::Bold),
150 message
151 )
152 } else {
153 format!("{} {}", plain_prefix(tone), message)
154 }
155}
156
157pub fn status_line_stderr(tone: Tone, message: &str) -> String {
158 if stderr_styled() {
159 format!(
160 "{} {}",
161 style(format!(" {} ", tone_label(tone)))
162 .with(tone_color(tone))
163 .attribute(Attribute::Bold),
164 message
165 )
166 } else {
167 format!("{} {}", plain_prefix(tone), message)
168 }
169}
170
171pub fn progress_bar(ratio: f64, width: usize) -> String {
172 let clamped = ratio.clamp(0.0, 1.0);
173 if stdout_styled() {
174 let filled = (clamped * width as f64).round() as usize;
175 let empty = width.saturating_sub(filled);
176 let left = format!("{}", style("█".repeat(filled)).with(Color::Green));
177 let right = format!("{}", style("░".repeat(empty)).with(Color::DarkGrey));
178 format!("{left}{right}")
179 } else {
180 let percent = (clamped * 100.0).round() as usize;
181 format!("{percent}%")
182 }
183}