Skip to main content

autom8/output/
banner.rs

1//! Phase banner display.
2//!
3//! Provides visual phase indicators for the autom8 workflow.
4
5use terminal_size::{terminal_size, Width};
6
7use super::colors::*;
8
9const DEFAULT_TERMINAL_WIDTH: u16 = 80;
10const MIN_BANNER_WIDTH: usize = 20;
11const MAX_BANNER_WIDTH: usize = 80;
12
13/// Color options for phase banners.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum BannerColor {
16    /// Cyan - used for starting a phase
17    Cyan,
18    /// Green - used for successful completion
19    Green,
20    /// Red - used for failure
21    Red,
22    /// Yellow - used for correction/warning phases
23    Yellow,
24}
25
26impl BannerColor {
27    /// Get the ANSI color code for this banner color.
28    pub fn ansi_code(&self) -> &'static str {
29        match self {
30            BannerColor::Cyan => CYAN,
31            BannerColor::Green => GREEN,
32            BannerColor::Red => RED,
33            BannerColor::Yellow => YELLOW,
34        }
35    }
36}
37
38/// Get the current terminal width for banner display.
39fn get_terminal_width_for_banner() -> usize {
40    terminal_size()
41        .map(|(Width(w), _)| w as usize)
42        .unwrap_or(DEFAULT_TERMINAL_WIDTH as usize)
43}
44
45/// Print a color-coded phase banner.
46///
47/// Banner format: `━━━ PHASE_NAME ━━━` with appropriate color.
48/// The banner width adapts to terminal width (clamped between MIN and MAX).
49///
50/// # Arguments
51///
52/// * `phase_name` - The name of the phase (e.g., "RUNNING", "REVIEWING")
53/// * `color` - The color to use for the banner
54pub fn print_phase_banner(phase_name: &str, color: BannerColor) {
55    let terminal_width = get_terminal_width_for_banner();
56
57    // Clamp banner width between MIN and MAX
58    let banner_width = terminal_width.clamp(MIN_BANNER_WIDTH, MAX_BANNER_WIDTH);
59
60    // Calculate padding: " PHASE_NAME " has phase_name.len() + 2 spaces
61    let phase_with_spaces = format!(" {} ", phase_name);
62    let phase_len = phase_with_spaces.chars().count();
63
64    // Calculate how many ━ characters we need on each side
65    let remaining = banner_width.saturating_sub(phase_len);
66    let left_padding = remaining / 2;
67    let right_padding = remaining - left_padding;
68
69    let color_code = color.ansi_code();
70
71    println!(
72        "{}{BOLD}{}{}{}{}",
73        color_code,
74        "━".repeat(left_padding),
75        phase_with_spaces,
76        "━".repeat(right_padding),
77        RESET
78    );
79}
80
81/// Print a phase footer (bottom border) to visually close the output section.
82///
83/// The footer is a horizontal line using the same style as the phase banner,
84/// providing visual framing around the Claude output section.
85///
86/// # Arguments
87///
88/// * `color` - The color to use for the footer (should match the phase banner)
89pub fn print_phase_footer(color: BannerColor) {
90    let terminal_width = get_terminal_width_for_banner();
91
92    // Clamp banner width between MIN and MAX (same as phase banner)
93    let banner_width = terminal_width.clamp(MIN_BANNER_WIDTH, MAX_BANNER_WIDTH);
94
95    let color_code = color.ansi_code();
96
97    println!("{}{BOLD}{}{RESET}", color_code, "━".repeat(banner_width));
98    // Print blank line for padding after the frame
99    println!();
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn test_banner_color_ansi_codes() {
108        assert_eq!(BannerColor::Cyan.ansi_code(), CYAN);
109        assert_eq!(BannerColor::Green.ansi_code(), GREEN);
110        assert_eq!(BannerColor::Red.ansi_code(), RED);
111        assert_eq!(BannerColor::Yellow.ansi_code(), YELLOW);
112    }
113
114    #[test]
115    fn test_banner_color_equality() {
116        assert_eq!(BannerColor::Cyan, BannerColor::Cyan);
117        assert_ne!(BannerColor::Cyan, BannerColor::Green);
118    }
119
120    #[test]
121    fn test_get_terminal_width_returns_valid_width() {
122        let width = get_terminal_width_for_banner();
123        assert!(width >= MIN_BANNER_WIDTH);
124    }
125
126    #[test]
127    fn test_banner_width_clamping() {
128        assert!(MIN_BANNER_WIDTH < MAX_BANNER_WIDTH);
129        assert_eq!(MIN_BANNER_WIDTH, 20);
130        assert_eq!(MAX_BANNER_WIDTH, 80);
131    }
132
133    #[test]
134    fn test_print_phase_banner_all_colors_and_phases() {
135        let test_cases: &[(&str, BannerColor)] = &[
136            ("RUNNING", BannerColor::Cyan),
137            ("REVIEWING", BannerColor::Cyan),
138            ("CORRECTING", BannerColor::Yellow),
139            ("COMMITTING", BannerColor::Cyan),
140            ("SUCCESS", BannerColor::Green),
141            ("FAILURE", BannerColor::Red),
142        ];
143
144        for (phase_name, color) in test_cases {
145            print_phase_banner(phase_name, *color);
146        }
147    }
148
149    #[test]
150    fn test_print_phase_banner_edge_cases() {
151        // Empty name should not panic
152        print_phase_banner("", BannerColor::Cyan);
153
154        // Very long name should not panic
155        print_phase_banner(
156            "THIS_IS_A_VERY_LONG_PHASE_NAME_THAT_EXCEEDS_NORMAL_LENGTH",
157            BannerColor::Cyan,
158        );
159    }
160}