Skip to main content

zlayer_builder/tui/
logger.rs

1//! Plain logger for CI/non-interactive environments
2//!
3//! This module provides a simple logging output mode that works in
4//! non-interactive environments like CI pipelines, where a full TUI
5//! would not be appropriate.
6
7use super::BuildEvent;
8use zlayer_tui::logger::{colorize, detect_color_support};
9use zlayer_tui::palette::ansi;
10
11/// Simple logging output for CI/non-interactive mode
12///
13/// This provides a line-by-line output of build progress suitable for
14/// log files and CI systems that don't support interactive terminals.
15///
16/// # Example
17///
18/// ```
19/// use zlayer_builder::tui::{PlainLogger, BuildEvent};
20///
21/// let logger = PlainLogger::new(false); // quiet mode
22///
23/// logger.handle_event(&BuildEvent::StageStarted {
24///     index: 0,
25///     name: Some("builder".to_string()),
26///     base_image: "node:20-alpine".to_string(),
27/// });
28/// // Output: ==> Stage: builder (node:20-alpine)
29///
30/// logger.handle_event(&BuildEvent::InstructionStarted {
31///     stage: 0,
32///     index: 0,
33///     instruction: "RUN npm ci".to_string(),
34/// });
35/// // Output:   -> RUN npm ci
36/// ```
37#[derive(Debug, Clone)]
38pub struct PlainLogger {
39    /// Whether to show verbose output (including all stdout/stderr lines)
40    verbose: bool,
41    /// Whether to use colors in output
42    color: bool,
43}
44
45impl Default for PlainLogger {
46    fn default() -> Self {
47        Self::new(false)
48    }
49}
50
51impl PlainLogger {
52    /// Create a new plain logger
53    ///
54    /// # Arguments
55    ///
56    /// * `verbose` - If true, shows all output lines. If false, only shows
57    ///   stage and instruction transitions.
58    #[must_use]
59    pub fn new(verbose: bool) -> Self {
60        Self {
61            verbose,
62            color: detect_color_support(),
63        }
64    }
65
66    /// Create a new plain logger with explicit color setting
67    #[must_use]
68    pub fn with_color(verbose: bool, color: bool) -> Self {
69        Self { verbose, color }
70    }
71
72    /// Apply ANSI color codes if color is enabled.
73    ///
74    /// Thin wrapper around [`zlayer_tui::logger::colorize`] that uses
75    /// this logger's color setting.
76    fn colorize(&self, text: &str, color: &str) -> String {
77        colorize(text, color, self.color)
78    }
79
80    /// Handle a build event and print appropriate output
81    pub fn handle_event(&self, event: &BuildEvent) {
82        match event {
83            BuildEvent::StageStarted {
84                index,
85                name,
86                base_image,
87            } => {
88                let stage_name = name.as_deref().unwrap_or("unnamed");
89                let header = format!("==> Stage {}: {} ({})", index + 1, stage_name, base_image);
90                println!("{}", self.colorize(&header, ansi::CYAN));
91            }
92
93            BuildEvent::InstructionStarted { instruction, .. } => {
94                let line = format!("  -> {instruction}");
95                println!("{}", self.colorize(&line, ansi::YELLOW));
96            }
97
98            BuildEvent::Output { line, is_stderr } if self.verbose => {
99                if *is_stderr {
100                    eprintln!("     {}", self.colorize(line, ansi::DIM));
101                } else {
102                    println!("     {line}");
103                }
104            }
105
106            BuildEvent::Output { .. } => {
107                // In non-verbose mode, we skip individual output lines
108            }
109
110            BuildEvent::InstructionComplete { cached, .. } => {
111                if *cached && self.verbose {
112                    println!("     {}", self.colorize("[cached]", ansi::CYAN));
113                }
114            }
115
116            BuildEvent::StageComplete { index } => {
117                if self.verbose {
118                    let line = format!("  Stage {} complete", index + 1);
119                    println!("{}", self.colorize(&line, ansi::GREEN));
120                }
121            }
122
123            BuildEvent::BuildComplete { image_id } => {
124                println!();
125                let success = format!("Build complete: {image_id}");
126                println!("{}", self.colorize(&success, ansi::GREEN));
127            }
128
129            BuildEvent::BuildFailed { error } => {
130                println!();
131                let failure = format!("Build failed: {error}");
132                eprintln!("{}", self.colorize(&failure, ansi::RED));
133            }
134        }
135    }
136
137    /// Process a stream of events, printing each one
138    ///
139    /// This is useful for processing events from a channel in a loop.
140    pub fn process_events<I>(&self, events: I)
141    where
142        I: IntoIterator<Item = BuildEvent>,
143    {
144        for event in events {
145            self.handle_event(&event);
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_plain_logger_creation() {
156        let logger = PlainLogger::new(false);
157        assert!(!logger.verbose);
158
159        let verbose_logger = PlainLogger::new(true);
160        assert!(verbose_logger.verbose);
161    }
162
163    #[test]
164    fn test_with_color() {
165        let logger = PlainLogger::with_color(false, true);
166        assert!(logger.color);
167
168        let no_color_logger = PlainLogger::with_color(false, false);
169        assert!(!no_color_logger.color);
170    }
171
172    #[test]
173    fn test_colorize_enabled() {
174        let logger = PlainLogger::with_color(false, true);
175        let result = logger.colorize("test", ansi::GREEN);
176        assert!(result.contains("\x1b[32m"));
177        assert!(result.contains("\x1b[0m"));
178        assert!(result.contains("test"));
179    }
180
181    #[test]
182    fn test_colorize_disabled() {
183        let logger = PlainLogger::with_color(false, false);
184        let result = logger.colorize("test", ansi::GREEN);
185        assert_eq!(result, "test");
186        assert!(!result.contains("\x1b["));
187    }
188
189    #[test]
190    fn test_handle_event_does_not_panic() {
191        // This test just ensures that handling various events doesn't panic
192        let logger = PlainLogger::with_color(true, false);
193
194        // All event types should be handled without panic
195        logger.handle_event(&BuildEvent::StageStarted {
196            index: 0,
197            name: Some("builder".to_string()),
198            base_image: "alpine".to_string(),
199        });
200
201        logger.handle_event(&BuildEvent::InstructionStarted {
202            stage: 0,
203            index: 0,
204            instruction: "RUN echo hello".to_string(),
205        });
206
207        logger.handle_event(&BuildEvent::Output {
208            line: "hello".to_string(),
209            is_stderr: false,
210        });
211
212        logger.handle_event(&BuildEvent::Output {
213            line: "warning".to_string(),
214            is_stderr: true,
215        });
216
217        logger.handle_event(&BuildEvent::InstructionComplete {
218            stage: 0,
219            index: 0,
220            cached: true,
221        });
222
223        logger.handle_event(&BuildEvent::StageComplete { index: 0 });
224
225        logger.handle_event(&BuildEvent::BuildComplete {
226            image_id: "sha256:abc".to_string(),
227        });
228
229        logger.handle_event(&BuildEvent::BuildFailed {
230            error: "test error".to_string(),
231        });
232    }
233
234    #[test]
235    fn test_default() {
236        let logger = PlainLogger::default();
237        assert!(!logger.verbose);
238    }
239}