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    /// Starting a new stage
60    StageStarted {
61        /// Stage index (0-based)
62        index: usize,
63        /// Optional stage name (from `AS name`)
64        name: Option<String>,
65        /// Base image for this stage
66        base_image: String,
67    },
68
69    /// Starting an instruction within a stage
70    InstructionStarted {
71        /// Stage index
72        stage: usize,
73        /// Instruction index within the stage
74        index: usize,
75        /// Instruction text (e.g., "RUN npm ci")
76        instruction: String,
77    },
78
79    /// Instruction output (streaming)
80    Output {
81        /// Output line content
82        line: String,
83        /// Whether this is stderr (true) or stdout (false)
84        is_stderr: bool,
85    },
86
87    /// Instruction completed
88    InstructionComplete {
89        /// Stage index
90        stage: usize,
91        /// Instruction index
92        index: usize,
93        /// Whether this instruction was served from cache
94        cached: bool,
95    },
96
97    /// Stage completed
98    StageComplete {
99        /// Stage index
100        index: usize,
101    },
102
103    /// Build complete
104    BuildComplete {
105        /// Final image ID
106        image_id: String,
107    },
108
109    /// Build failed
110    BuildFailed {
111        /// Error message
112        error: String,
113    },
114}
115
116/// Status of an instruction during build
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub enum InstructionStatus {
119    /// Instruction has not started yet
120    #[default]
121    Pending,
122    /// Instruction is currently running
123    Running,
124    /// Instruction completed successfully
125    Complete {
126        /// Whether the result was served from cache
127        cached: bool,
128    },
129    /// Instruction failed
130    Failed,
131}
132
133impl InstructionStatus {
134    /// Returns true if the instruction is complete (successfully or from cache)
135    pub fn is_complete(&self) -> bool {
136        matches!(self, Self::Complete { .. })
137    }
138
139    /// Returns true if the instruction is currently running
140    pub fn is_running(&self) -> bool {
141        matches!(self, Self::Running)
142    }
143
144    /// Returns true if the instruction failed
145    pub fn is_failed(&self) -> bool {
146        matches!(self, Self::Failed)
147    }
148
149    /// Returns the status indicator character
150    pub fn indicator(&self) -> char {
151        match self {
152            Self::Pending => '\u{25CB}',         // ○
153            Self::Running => '\u{25B6}',         // ▶
154            Self::Complete { .. } => '\u{2713}', // ✓
155            Self::Failed => '\u{2717}',          // ✗
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn test_instruction_status_indicator() {
166        assert_eq!(InstructionStatus::Pending.indicator(), '\u{25CB}');
167        assert_eq!(InstructionStatus::Running.indicator(), '\u{25B6}');
168        assert_eq!(
169            InstructionStatus::Complete { cached: false }.indicator(),
170            '\u{2713}'
171        );
172        assert_eq!(InstructionStatus::Failed.indicator(), '\u{2717}');
173    }
174
175    #[test]
176    fn test_instruction_status_states() {
177        assert!(!InstructionStatus::Pending.is_complete());
178        assert!(!InstructionStatus::Pending.is_running());
179        assert!(!InstructionStatus::Pending.is_failed());
180
181        assert!(!InstructionStatus::Running.is_complete());
182        assert!(InstructionStatus::Running.is_running());
183        assert!(!InstructionStatus::Running.is_failed());
184
185        assert!(InstructionStatus::Complete { cached: false }.is_complete());
186        assert!(!InstructionStatus::Complete { cached: true }.is_running());
187        assert!(!InstructionStatus::Complete { cached: false }.is_failed());
188
189        assert!(!InstructionStatus::Failed.is_complete());
190        assert!(!InstructionStatus::Failed.is_running());
191        assert!(InstructionStatus::Failed.is_failed());
192    }
193}