Skip to main content

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}