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::BuildStarted {
84                total_stages,
85                total_instructions,
86            } => {
87                let header = format!(
88                    "==> Building {total_stages} stages, {total_instructions} instructions total"
89                );
90                println!("{}", self.colorize(&header, ansi::CYAN));
91            }
92
93            BuildEvent::StageStarted {
94                index,
95                name,
96                base_image,
97            } => {
98                let stage_name = name.as_deref().unwrap_or("unnamed");
99                let header = format!("==> Stage {}: {} ({})", index + 1, stage_name, base_image);
100                println!("{}", self.colorize(&header, ansi::CYAN));
101            }
102
103            BuildEvent::InstructionStarted { instruction, .. } => {
104                let line = format!("  -> {instruction}");
105                println!("{}", self.colorize(&line, ansi::YELLOW));
106            }
107
108            BuildEvent::Output { line, is_stderr } if self.verbose => {
109                if *is_stderr {
110                    eprintln!("     {}", self.colorize(line, ansi::DIM));
111                } else {
112                    println!("     {line}");
113                }
114            }
115
116            BuildEvent::Output { .. } => {
117                // In non-verbose mode, we skip individual output lines
118            }
119
120            BuildEvent::InstructionComplete { cached, .. } => {
121                if *cached && self.verbose {
122                    println!("     {}", self.colorize("[cached]", ansi::CYAN));
123                }
124            }
125
126            BuildEvent::StageComplete { index } => {
127                if self.verbose {
128                    let line = format!("  Stage {} complete", index + 1);
129                    println!("{}", self.colorize(&line, ansi::GREEN));
130                }
131            }
132
133            BuildEvent::BuildComplete { image_id } => {
134                println!();
135                let success = format!("Build complete: {image_id}");
136                println!("{}", self.colorize(&success, ansi::GREEN));
137            }
138
139            BuildEvent::BuildFailed { error } => {
140                println!();
141                let failure = format!("Build failed: {error}");
142                eprintln!("{}", self.colorize(&failure, ansi::RED));
143            }
144        }
145    }
146
147    /// Process a stream of events, printing each one
148    ///
149    /// This is useful for processing events from a channel in a loop.
150    pub fn process_events<I>(&self, events: I)
151    where
152        I: IntoIterator<Item = BuildEvent>,
153    {
154        for event in events {
155            self.handle_event(&event);
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn test_plain_logger_creation() {
166        let logger = PlainLogger::new(false);
167        assert!(!logger.verbose);
168
169        let verbose_logger = PlainLogger::new(true);
170        assert!(verbose_logger.verbose);
171    }
172
173    #[test]
174    fn test_with_color() {
175        let logger = PlainLogger::with_color(false, true);
176        assert!(logger.color);
177
178        let no_color_logger = PlainLogger::with_color(false, false);
179        assert!(!no_color_logger.color);
180    }
181
182    #[test]
183    fn test_colorize_enabled() {
184        let logger = PlainLogger::with_color(false, true);
185        let result = logger.colorize("test", ansi::GREEN);
186        assert!(result.contains("\x1b[32m"));
187        assert!(result.contains("\x1b[0m"));
188        assert!(result.contains("test"));
189    }
190
191    #[test]
192    fn test_colorize_disabled() {
193        let logger = PlainLogger::with_color(false, false);
194        let result = logger.colorize("test", ansi::GREEN);
195        assert_eq!(result, "test");
196        assert!(!result.contains("\x1b["));
197    }
198
199    #[test]
200    fn test_handle_event_does_not_panic() {
201        // This test just ensures that handling various events doesn't panic
202        let logger = PlainLogger::with_color(true, false);
203
204        // All event types should be handled without panic
205        logger.handle_event(&BuildEvent::StageStarted {
206            index: 0,
207            name: Some("builder".to_string()),
208            base_image: "alpine".to_string(),
209        });
210
211        logger.handle_event(&BuildEvent::InstructionStarted {
212            stage: 0,
213            index: 0,
214            instruction: "RUN echo hello".to_string(),
215        });
216
217        logger.handle_event(&BuildEvent::Output {
218            line: "hello".to_string(),
219            is_stderr: false,
220        });
221
222        logger.handle_event(&BuildEvent::Output {
223            line: "warning".to_string(),
224            is_stderr: true,
225        });
226
227        logger.handle_event(&BuildEvent::InstructionComplete {
228            stage: 0,
229            index: 0,
230            cached: true,
231        });
232
233        logger.handle_event(&BuildEvent::StageComplete { index: 0 });
234
235        logger.handle_event(&BuildEvent::BuildComplete {
236            image_id: "sha256:abc".to_string(),
237        });
238
239        logger.handle_event(&BuildEvent::BuildFailed {
240            error: "test error".to_string(),
241        });
242    }
243
244    #[test]
245    fn test_default() {
246        let logger = PlainLogger::default();
247        assert!(!logger.verbose);
248    }
249}