agentic_codebase/cli/output.rs
1//! Output formatting and styling helpers.
2//!
3//! Provides colored output, progress indicators, and human-friendly formatting.
4//! Respects the `NO_COLOR` environment variable and TTY detection so that
5//! output is always readable when piped, captured, or run in CI.
6
7use std::io::Write;
8
9// ---------------------------------------------------------------------------
10// TTY / color detection
11// ---------------------------------------------------------------------------
12
13/// Returns `true` when stdout is connected to an interactive terminal.
14fn atty_stdout() -> bool {
15 unsafe { libc_isatty(1) != 0 }
16}
17
18extern "C" {
19 #[link_name = "isatty"]
20 fn libc_isatty(fd: i32) -> i32;
21}
22
23/// Returns `true` when colored output should be used.
24///
25/// Color is enabled when ALL of the following are true:
26/// - `NO_COLOR` environment variable is **not** set
27/// - `ACB_NO_COLOR` environment variable is **not** set
28/// - Stdout is a TTY (interactive terminal)
29pub fn color_enabled() -> bool {
30 if std::env::var("NO_COLOR").is_ok() {
31 return false;
32 }
33 if std::env::var("ACB_NO_COLOR").is_ok() {
34 return false;
35 }
36 atty_stdout()
37}
38
39// ---------------------------------------------------------------------------
40// Styled output helper
41// ---------------------------------------------------------------------------
42
43/// A thin helper that conditionally applies ANSI escape codes.
44///
45/// Create with [`Styled::auto()`] to respect environment / TTY, or
46/// force a mode with [`Styled::plain()`] / [`Styled::colored()`].
47#[derive(Clone, Copy)]
48pub struct Styled {
49 use_color: bool,
50}
51
52impl Styled {
53 /// Auto-detect whether to use color based on environment and TTY.
54 pub fn auto() -> Self {
55 Self {
56 use_color: color_enabled(),
57 }
58 }
59
60 /// Force plain text output (no colors).
61 pub fn plain() -> Self {
62 Self { use_color: false }
63 }
64
65 /// Force colored output regardless of environment.
66 #[allow(dead_code)]
67 pub fn colored() -> Self {
68 Self { use_color: true }
69 }
70
71 // -- Symbols --------------------------------------------------------
72
73 /// Green check mark or "OK".
74 pub fn ok(&self) -> &str {
75 if self.use_color {
76 "\x1b[32m\u{2713}\x1b[0m"
77 } else {
78 "OK"
79 }
80 }
81
82 /// Red cross or "FAIL".
83 pub fn fail(&self) -> &str {
84 if self.use_color {
85 "\x1b[31m\u{2717}\x1b[0m"
86 } else {
87 "FAIL"
88 }
89 }
90
91 /// Yellow warning symbol or "!!".
92 pub fn warn(&self) -> &str {
93 if self.use_color {
94 "\x1b[33m\u{26A0}\x1b[0m"
95 } else {
96 "!!"
97 }
98 }
99
100 /// Blue info dot or "->".
101 pub fn info(&self) -> &str {
102 if self.use_color {
103 "\x1b[34m\u{25CF}\x1b[0m"
104 } else {
105 "->"
106 }
107 }
108
109 /// Dimmed right arrow or " ".
110 pub fn arrow(&self) -> &str {
111 if self.use_color {
112 "\x1b[90m\u{2192}\x1b[0m"
113 } else {
114 "->"
115 }
116 }
117
118 // -- Text coloring --------------------------------------------------
119
120 /// Bold text.
121 pub fn bold(&self, text: &str) -> String {
122 if self.use_color {
123 format!("\x1b[1m{}\x1b[0m", text)
124 } else {
125 text.to_string()
126 }
127 }
128
129 /// Green text (for success values).
130 pub fn green(&self, text: &str) -> String {
131 if self.use_color {
132 format!("\x1b[32m{}\x1b[0m", text)
133 } else {
134 text.to_string()
135 }
136 }
137
138 /// Yellow text (for warnings / numbers).
139 pub fn yellow(&self, text: &str) -> String {
140 if self.use_color {
141 format!("\x1b[33m{}\x1b[0m", text)
142 } else {
143 text.to_string()
144 }
145 }
146
147 /// Red text (for errors).
148 pub fn red(&self, text: &str) -> String {
149 if self.use_color {
150 format!("\x1b[31m{}\x1b[0m", text)
151 } else {
152 text.to_string()
153 }
154 }
155
156 /// Cyan text (for paths and identifiers).
157 pub fn cyan(&self, text: &str) -> String {
158 if self.use_color {
159 format!("\x1b[36m{}\x1b[0m", text)
160 } else {
161 text.to_string()
162 }
163 }
164
165 /// Dim / grey text (for secondary information).
166 pub fn dim(&self, text: &str) -> String {
167 if self.use_color {
168 format!("\x1b[90m{}\x1b[0m", text)
169 } else {
170 text.to_string()
171 }
172 }
173}
174
175// ---------------------------------------------------------------------------
176// Formatting helpers
177// ---------------------------------------------------------------------------
178
179/// Format a byte count into a human-readable string (e.g., "4.2 MB").
180pub fn format_size(bytes: u64) -> String {
181 const KB: u64 = 1024;
182 const MB: u64 = 1024 * 1024;
183 const GB: u64 = 1024 * 1024 * 1024;
184 if bytes >= GB {
185 format!("{:.1} GB", bytes as f64 / GB as f64)
186 } else if bytes >= MB {
187 format!("{:.1} MB", bytes as f64 / MB as f64)
188 } else if bytes >= KB {
189 format!("{:.1} KB", bytes as f64 / KB as f64)
190 } else {
191 format!("{} B", bytes)
192 }
193}
194
195/// Print a simple inline progress indicator to stderr.
196///
197/// Call repeatedly with increasing `current` values; each call
198/// overwrites the previous line. Call [`progress_done`] when finished.
199pub fn progress(label: &str, current: usize, total: usize) {
200 if total == 0 || !color_enabled() {
201 return;
202 }
203 let pct = (current as f64 / total as f64 * 100.0).min(100.0);
204 let bar_width = 20;
205 let filled = (pct / 100.0 * bar_width as f64) as usize;
206 let empty = bar_width - filled;
207 eprint!(
208 "\r {} [{}{}] {:>3.0}%",
209 label,
210 "\u{2588}".repeat(filled),
211 "\u{2591}".repeat(empty),
212 pct,
213 );
214 let _ = std::io::stderr().flush();
215}
216
217/// Finish a progress indicator with a newline on stderr.
218pub fn progress_done() {
219 if color_enabled() {
220 eprintln!();
221 }
222}