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