Skip to main content

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    /// Pre-populates the instruction list up-front (from the parsed
130    /// Dockerfile) so backends that can't emit live per-instruction
131    /// `StageStarted`/`InstructionStarted` events (the buildah sidecar)
132    /// still show the full plan; instructions land as `Pending` and are
133    /// advanced to `Running`/`Complete` as progress arrives.
134    BuildPlan {
135        /// The full set of planned stages, in Dockerfile order.
136        stages: Vec<PlannedStage>,
137    },
138}
139
140/// A pre-parsed build stage used to pre-populate the TUI instruction list.
141///
142/// Carried by [`BuildEvent::BuildPlan`] so backends that cannot emit live
143/// per-instruction events (the buildah sidecar) can still render the full
144/// instruction plan up-front. `instructions` exclude the `FROM` line —
145/// they match the per-instruction text the native backend emits via
146/// `InstructionStarted` (rendered with `format!("{instruction:?}")`).
147#[derive(Debug, Clone)]
148pub struct PlannedStage {
149    /// Optional stage name (from `AS name`).
150    pub name: Option<String>,
151    /// Base image for this stage.
152    pub base_image: String,
153    /// Instruction text for each instruction in this stage (excluding `FROM`).
154    pub instructions: Vec<String>,
155}
156
157/// Status of an instruction during build
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
159pub enum InstructionStatus {
160    /// Instruction has not started yet
161    #[default]
162    Pending,
163    /// Instruction is currently running
164    Running,
165    /// Instruction completed successfully
166    Complete {
167        /// Whether the result was served from cache
168        cached: bool,
169    },
170    /// Instruction failed
171    Failed,
172}
173
174impl InstructionStatus {
175    /// Returns true if the instruction is complete (successfully or from cache)
176    #[must_use]
177    pub fn is_complete(&self) -> bool {
178        matches!(self, Self::Complete { .. })
179    }
180
181    /// Returns true if the instruction is currently running
182    #[must_use]
183    pub fn is_running(&self) -> bool {
184        matches!(self, Self::Running)
185    }
186
187    /// Returns true if the instruction failed
188    #[must_use]
189    pub fn is_failed(&self) -> bool {
190        matches!(self, Self::Failed)
191    }
192
193    /// Returns the status indicator character
194    #[must_use]
195    pub fn indicator(&self) -> char {
196        match self {
197            Self::Pending => '\u{25CB}',         // ○
198            Self::Running => '\u{25B6}',         // ▶
199            Self::Complete { .. } => '\u{2713}', // ✓
200            Self::Failed => '\u{2717}',          // ✗
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_instruction_status_indicator() {
211        assert_eq!(InstructionStatus::Pending.indicator(), '\u{25CB}');
212        assert_eq!(InstructionStatus::Running.indicator(), '\u{25B6}');
213        assert_eq!(
214            InstructionStatus::Complete { cached: false }.indicator(),
215            '\u{2713}'
216        );
217        assert_eq!(InstructionStatus::Failed.indicator(), '\u{2717}');
218    }
219
220    #[test]
221    fn test_instruction_status_states() {
222        assert!(!InstructionStatus::Pending.is_complete());
223        assert!(!InstructionStatus::Pending.is_running());
224        assert!(!InstructionStatus::Pending.is_failed());
225
226        assert!(!InstructionStatus::Running.is_complete());
227        assert!(InstructionStatus::Running.is_running());
228        assert!(!InstructionStatus::Running.is_failed());
229
230        assert!(InstructionStatus::Complete { cached: false }.is_complete());
231        assert!(!InstructionStatus::Complete { cached: true }.is_running());
232        assert!(!InstructionStatus::Complete { cached: false }.is_failed());
233
234        assert!(!InstructionStatus::Failed.is_complete());
235        assert!(!InstructionStatus::Failed.is_running());
236        assert!(InstructionStatus::Failed.is_failed());
237    }
238}