zlayer_builder/tui/mod.rs
1//! Terminal UI for build progress visualization
2//!
3//! This module provides a Ratatui-based TUI for displaying build progress,
4//! as well as a plain logger for CI/non-interactive environments.
5//!
6//! # Architecture
7//!
8//! The TUI is event-driven and communicates with the build process via channels:
9//!
10//! ```text
11//! ┌─────────────────┐ mpsc::Sender<BuildEvent> ┌─────────────────┐
12//! │ Build Process │ ──────────────────────────────▶ │ BuildTui │
13//! └─────────────────┘ └─────────────────┘
14//! ```
15//!
16//! # Example
17//!
18//! ```no_run
19//! use zlayer_builder::tui::{BuildTui, BuildEvent};
20//! use std::sync::mpsc;
21//!
22//! # fn main() -> std::io::Result<()> {
23//! // Create channel for build events
24//! let (tx, rx) = mpsc::channel();
25//!
26//! // Spawn build process that sends events
27//! std::thread::spawn(move || {
28//! tx.send(BuildEvent::StageStarted {
29//! index: 0,
30//! name: Some("builder".to_string()),
31//! base_image: "node:20-alpine".to_string(),
32//! }).unwrap();
33//! // ... more events ...
34//! });
35//!
36//! // Run TUI (blocks until build completes or user quits)
37//! let mut tui = BuildTui::new(rx);
38//! tui.run()?;
39//! # Ok(())
40//! # }
41//! ```
42
43mod app;
44mod build_view;
45mod logger;
46mod widgets;
47
48pub use app::{BuildState, BuildTui, InstructionState, StageState};
49pub use build_view::BuildView;
50pub use logger::PlainLogger;
51pub use zlayer_tui::widgets::scrollable_pane::OutputLine;
52
53/// Build event for TUI updates
54///
55/// These events are sent from the build process to update the TUI state.
56/// The TUI processes these events asynchronously and updates the display.
57#[derive(Debug, Clone)]
58pub enum BuildEvent {
59 /// Build has started — sent once up-front, before any `StageStarted`.
60 ///
61 /// Carries the total number of stages and instructions so that
62 /// progress-bar denominators can be populated before any work
63 /// begins. Backends that have the full instruction tree at entry
64 /// (Dockerfile/ZImagefile) should emit this; if it is not emitted,
65 /// the TUI falls back to counting stages/instructions as events arrive.
66 BuildStarted {
67 /// Total number of stages in this build.
68 total_stages: usize,
69 /// Total number of instructions across all stages.
70 total_instructions: usize,
71 },
72
73 /// Starting a new stage
74 StageStarted {
75 /// Stage index (0-based)
76 index: usize,
77 /// Optional stage name (from `AS name`)
78 name: Option<String>,
79 /// Base image for this stage
80 base_image: String,
81 },
82
83 /// Starting an instruction within a stage
84 InstructionStarted {
85 /// Stage index
86 stage: usize,
87 /// Instruction index within the stage
88 index: usize,
89 /// Instruction text (e.g., "RUN npm ci")
90 instruction: String,
91 },
92
93 /// Instruction output (streaming)
94 Output {
95 /// Output line content
96 line: String,
97 /// Whether this is stderr (true) or stdout (false)
98 is_stderr: bool,
99 },
100
101 /// Instruction completed
102 InstructionComplete {
103 /// Stage index
104 stage: usize,
105 /// Instruction index
106 index: usize,
107 /// Whether this instruction was served from cache
108 cached: bool,
109 },
110
111 /// Stage completed
112 StageComplete {
113 /// Stage index
114 index: usize,
115 },
116
117 /// Build complete
118 BuildComplete {
119 /// Final image ID
120 image_id: String,
121 },
122
123 /// Build failed
124 BuildFailed {
125 /// Error message
126 error: String,
127 },
128}
129
130/// Status of an instruction during build
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
132pub enum InstructionStatus {
133 /// Instruction has not started yet
134 #[default]
135 Pending,
136 /// Instruction is currently running
137 Running,
138 /// Instruction completed successfully
139 Complete {
140 /// Whether the result was served from cache
141 cached: bool,
142 },
143 /// Instruction failed
144 Failed,
145}
146
147impl InstructionStatus {
148 /// Returns true if the instruction is complete (successfully or from cache)
149 #[must_use]
150 pub fn is_complete(&self) -> bool {
151 matches!(self, Self::Complete { .. })
152 }
153
154 /// Returns true if the instruction is currently running
155 #[must_use]
156 pub fn is_running(&self) -> bool {
157 matches!(self, Self::Running)
158 }
159
160 /// Returns true if the instruction failed
161 #[must_use]
162 pub fn is_failed(&self) -> bool {
163 matches!(self, Self::Failed)
164 }
165
166 /// Returns the status indicator character
167 #[must_use]
168 pub fn indicator(&self) -> char {
169 match self {
170 Self::Pending => '\u{25CB}', // ○
171 Self::Running => '\u{25B6}', // ▶
172 Self::Complete { .. } => '\u{2713}', // ✓
173 Self::Failed => '\u{2717}', // ✗
174 }
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn test_instruction_status_indicator() {
184 assert_eq!(InstructionStatus::Pending.indicator(), '\u{25CB}');
185 assert_eq!(InstructionStatus::Running.indicator(), '\u{25B6}');
186 assert_eq!(
187 InstructionStatus::Complete { cached: false }.indicator(),
188 '\u{2713}'
189 );
190 assert_eq!(InstructionStatus::Failed.indicator(), '\u{2717}');
191 }
192
193 #[test]
194 fn test_instruction_status_states() {
195 assert!(!InstructionStatus::Pending.is_complete());
196 assert!(!InstructionStatus::Pending.is_running());
197 assert!(!InstructionStatus::Pending.is_failed());
198
199 assert!(!InstructionStatus::Running.is_complete());
200 assert!(InstructionStatus::Running.is_running());
201 assert!(!InstructionStatus::Running.is_failed());
202
203 assert!(InstructionStatus::Complete { cached: false }.is_complete());
204 assert!(!InstructionStatus::Complete { cached: true }.is_running());
205 assert!(!InstructionStatus::Complete { cached: false }.is_failed());
206
207 assert!(!InstructionStatus::Failed.is_complete());
208 assert!(!InstructionStatus::Failed.is_running());
209 assert!(InstructionStatus::Failed.is_failed());
210 }
211}