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