Skip to main content

canic_host/terminal/
style.rs

1//! Module: terminal::style
2//!
3//! Responsibility: select terminal-safe styling for operator output.
4//! Does not own: workflow state, progress timing, or table layout.
5//! Boundary: color ANSI is emitted only for an interactive non-dumb terminal without `NO_COLOR`.
6
7use std::io::{self, IsTerminal};
8
9const RESET: &str = "\u{1b}[0m";
10const BOLD_CYAN: &str = "\u{1b}[1;36m";
11const DIM: &str = "\u{1b}[2m";
12const GREEN: &str = "\u{1b}[32m";
13const YELLOW: &str = "\u{1b}[33m";
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub struct TerminalStyle {
17    interactive: bool,
18    color: bool,
19}
20
21impl TerminalStyle {
22    #[must_use]
23    pub fn detected() -> Self {
24        let interactive =
25            io::stdout().is_terminal() && std::env::var("TERM").map_or(true, |term| term != "dumb");
26        Self {
27            interactive,
28            color: interactive && std::env::var_os("NO_COLOR").is_none(),
29        }
30    }
31
32    #[must_use]
33    pub const fn interactive(self) -> bool {
34        self.interactive
35    }
36
37    #[must_use]
38    pub fn heading(self, text: &str) -> String {
39        self.paint(BOLD_CYAN, text)
40    }
41
42    #[must_use]
43    pub fn success(self, text: &str) -> String {
44        self.paint(GREEN, text)
45    }
46
47    #[must_use]
48    pub fn warning(self, text: &str) -> String {
49        self.paint(YELLOW, text)
50    }
51
52    #[must_use]
53    pub fn muted(self, text: &str) -> String {
54        self.paint(DIM, text)
55    }
56
57    pub fn print_section(self, title: &str, detail: &str) {
58        println!(
59            "{} {}  {}",
60            self.heading("==>"),
61            self.heading(title),
62            self.muted(detail)
63        );
64    }
65
66    fn paint(self, code: &str, text: &str) -> String {
67        if self.color {
68            format!("{code}{text}{RESET}")
69        } else {
70            text.to_string()
71        }
72    }
73}
74
75#[cfg(test)]
76impl TerminalStyle {
77    const fn ansi_for_tests() -> Self {
78        Self {
79            interactive: true,
80            color: true,
81        }
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn ansi_style_colors_status_without_changing_text() {
91        let style = TerminalStyle::ansi_for_tests();
92
93        assert_eq!(style.success("done"), "\u{1b}[32mdone\u{1b}[0m");
94    }
95}