1use std::io::Write;
7
8use owo_colors::{OwoColorize, Style};
9
10use crate::event::{ArtifactState, Cell, EventStream, LogLevel, UiEvent};
11use crate::palette::{Rgb, Role, RoleMap};
12use crate::sigil::Sigil;
13
14pub fn should_color() -> bool {
17 if std::env::var_os("NO_COLOR").is_some() {
18 return false;
19 }
20 #[cfg(unix)]
22 {
23 use std::os::fd::AsRawFd;
24 let fd = std::io::stderr().as_raw_fd();
25 (unsafe { libc::isatty(fd) }) == 1
27 }
28 #[cfg(not(unix))]
29 {
30 true
31 }
32}
33
34pub struct Renderer {
35 pub role_map: RoleMap,
36 pub color: bool,
37}
38
39impl Default for Renderer {
40 fn default() -> Self {
41 Self::new(RoleMap::default())
42 }
43}
44
45impl Renderer {
46 pub fn new(role_map: RoleMap) -> Self {
47 Self {
48 role_map,
49 color: should_color(),
50 }
51 }
52
53 pub fn plain(role_map: RoleMap) -> Self {
54 Self {
55 role_map,
56 color: false,
57 }
58 }
59
60 pub fn with_color(mut self, on: bool) -> Self {
61 self.color = on;
62 self
63 }
64
65 pub fn render(&self, events: &EventStream, w: &mut impl Write) -> std::io::Result<()> {
66 for e in &events.events {
67 self.render_one(e, w)?;
68 }
69 Ok(())
70 }
71
72 pub fn render_one(&self, e: &UiEvent, w: &mut impl Write) -> std::io::Result<()> {
73 match e {
74 UiEvent::Banner { title, subtitle } => self.banner(title, subtitle.as_deref(), w),
75 UiEvent::Section { title } => self.section(title, w),
76 UiEvent::Log { level, message } => self.log(*level, message, w),
77 UiEvent::PhaseBegin { phase } => {
78 let arrow = self.glyph(Sigil::Arrow);
79 let phase_s = self.text(phase, Role::Primary);
80 writeln!(w, " {arrow} {phase_s}")
81 }
82 UiEvent::PhaseEnd { phase, elapsed_ms } => {
83 let check = self.glyph(Sigil::Check);
84 let phase_s = self.text(phase, Role::Dim);
85 let elapsed = self.dim_elapsed(*elapsed_ms);
86 writeln!(w, " {check} {phase_s} {elapsed}")
87 }
88 UiEvent::Artifact { name, hash, state } => self.artifact(name, hash, state, w),
89 UiEvent::Summary {
90 root_hash,
91 total,
92 built,
93 cached,
94 failed,
95 } => self.summary(root_hash, *total, *built, *cached, *failed, w),
96 UiEvent::Row { cells } => self.row(cells, w),
97 }
98 }
99
100 fn banner(
101 &self,
102 title: &str,
103 subtitle: Option<&str>,
104 w: &mut impl Write,
105 ) -> std::io::Result<()> {
106 let snow = self.glyph(Sigil::Snowflake);
107 let title_s = self.text(title, Role::Primary);
108 writeln!(w, "{snow} {title_s}")?;
109 if let Some(sub) = subtitle {
110 let sub_s = self.text(sub, Role::Dim);
111 writeln!(w, " {sub_s}")?;
112 }
113 Ok(())
114 }
115
116 fn section(&self, title: &str, w: &mut impl Write) -> std::io::Result<()> {
117 let sec = self.glyph(Sigil::Section);
118 let title_s = self.text(title, Role::Primary);
119 writeln!(w)?;
120 writeln!(w, "{sec} {title_s}")?;
121 let line_char = "─";
123 let rule = line_char.repeat(2 + title.chars().count() + 1);
124 let rule_s = self.text(&rule, Role::Dim);
125 writeln!(w, "{rule_s}")?;
126 Ok(())
127 }
128
129 fn log(&self, level: LogLevel, message: &str, w: &mut impl Write) -> std::io::Result<()> {
130 let sigil = match level {
131 LogLevel::Success => Sigil::Check,
132 LogLevel::Error => Sigil::Cross,
133 LogLevel::Warn => Sigil::Tilde,
134 LogLevel::Info => Sigil::Dot,
135 LogLevel::Dim => Sigil::DotHollow,
136 };
137 let s = self.colored_glyph(sigil, level.role());
138 let msg = self.text(message, level.role());
139 writeln!(w, " {s} {msg}")
140 }
141
142 fn artifact(
143 &self,
144 name: &str,
145 hash: &crate::event::ShortHash,
146 state: &ArtifactState,
147 w: &mut impl Write,
148 ) -> std::io::Result<()> {
149 let snow = self.colored_glyph(Sigil::Snowflake, Role::Primary);
151 let name_s = self.text(name, Role::Primary);
152 let diamond = self.colored_glyph(Sigil::Diamond, Role::Info);
153 let hash_s = self.text(&format!("blake3:{hash}"), Role::Dim);
154 let state_chunk = self.state_chunk(state);
155 writeln!(w, " {snow} {name_s:<28} {diamond} {hash_s} {state_chunk}")
156 }
157
158 fn state_chunk(&self, state: &ArtifactState) -> String {
159 match state {
160 ArtifactState::Built { elapsed_ms } => {
161 let g = self.colored_glyph(Sigil::Gear, Role::Warn);
162 let label = self.text("built", Role::Success);
163 let ms = self.text(&format!("{:.1}s", *elapsed_ms as f64 / 1000.0), Role::Dim);
164 format!("{g} {label} {ms}")
165 }
166 ArtifactState::Cached => {
167 let g = self.colored_glyph(Sigil::Lightning, Role::Success);
168 let label = self.text("cached", Role::Success);
169 format!("{g} {label}")
170 }
171 ArtifactState::Pending => {
172 let g = self.colored_glyph(Sigil::DotHollow, Role::Dim);
173 let label = self.text("pending", Role::Dim);
174 format!("{g} {label}")
175 }
176 ArtifactState::Failed { reason } => {
177 let g = self.colored_glyph(Sigil::Cross, Role::Error);
178 let label = self.text("failed", Role::Error);
179 let reason_s = self.text(reason, Role::Error);
180 format!("{g} {label} {reason_s}")
181 }
182 }
183 }
184
185 fn summary(
186 &self,
187 root_hash: &crate::event::ShortHash,
188 total: usize,
189 built: usize,
190 cached: usize,
191 failed: usize,
192 w: &mut impl Write,
193 ) -> std::io::Result<()> {
194 writeln!(w)?;
195 let tri = self.colored_glyph(Sigil::Triangle, Role::Primary);
196 let label = self.text("content root", Role::Primary);
197 let diamond = self.colored_glyph(Sigil::Diamond, Role::Info);
198 let hash = self.text(&format!("blake3:{root_hash}"), Role::Dim);
199 writeln!(w, "{tri} {label} {diamond} {hash}")?;
200 let summary =
201 format!(" {total} total · {built} built · {cached} cached · {failed} failed",);
202 let role = if failed > 0 {
203 Role::Error
204 } else {
205 Role::Success
206 };
207 writeln!(w, "{}", self.text(&summary, role))?;
208 Ok(())
209 }
210
211 fn row(&self, cells: &[Cell], w: &mut impl Write) -> std::io::Result<()> {
212 let mut parts = Vec::with_capacity(cells.len());
213 for c in cells {
214 let role = c.role.unwrap_or(Role::Info);
215 parts.push(self.text(&c.text, role));
216 }
217 writeln!(w, " {}", parts.join(" "))
218 }
219
220 fn glyph(&self, s: Sigil) -> String {
223 self.colored_glyph(s, s.default_role())
224 }
225
226 fn colored_glyph(&self, s: Sigil, r: Role) -> String {
227 self.text(s.glyph(), r)
228 }
229
230 fn text(&self, s: &str, role: Role) -> String {
231 if !self.color {
232 return s.to_string();
233 }
234 let rgb = self.role_map.color_of(role);
235 self.apply(s, rgb)
236 }
237
238 fn apply(&self, s: &str, rgb: Rgb) -> String {
239 let style = Style::new().color(rgb.owo());
242 s.style(style).to_string()
243 }
244
245 fn dim_elapsed(&self, ms: u64) -> String {
246 self.text(&format!("{:.1}s", ms as f64 / 1000.0), Role::Dim)
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use crate::event::ShortHash;
254
255 fn plain() -> Renderer {
256 Renderer::plain(RoleMap::default())
257 }
258
259 #[test]
260 fn banner_with_no_color_contains_plain_snowflake_and_title() {
261 let r = plain();
262 let mut out: Vec<u8> = Vec::new();
263 r.render_one(
264 &UiEvent::Banner {
265 title: "tatara-boot-gen".into(),
266 subtitle: Some("plex".into()),
267 },
268 &mut out,
269 )
270 .unwrap();
271 let s = String::from_utf8(out).unwrap();
272 assert!(s.contains('❄'));
273 assert!(s.contains("tatara-boot-gen"));
274 assert!(s.contains("plex"));
275 }
276
277 #[test]
278 fn artifact_renders_name_hash_state() {
279 let r = plain();
280 let mut out: Vec<u8> = Vec::new();
281 r.render_one(
282 &UiEvent::Artifact {
283 name: "initrd-plex".into(),
284 hash: ShortHash::from_blake3_hex("cxx3i50l"),
285 state: ArtifactState::Cached,
286 },
287 &mut out,
288 )
289 .unwrap();
290 let s = String::from_utf8(out).unwrap();
291 assert!(s.contains("initrd-plex"));
292 assert!(s.contains("blake3:cxx3i50"));
293 assert!(s.contains("cached"));
294 }
295
296 #[test]
297 fn summary_shows_totals_and_hash() {
298 let r = plain();
299 let mut out: Vec<u8> = Vec::new();
300 r.render_one(
301 &UiEvent::Summary {
302 root_hash: ShortHash::from_blake3_hex("abcd1234"),
303 total: 7,
304 built: 3,
305 cached: 4,
306 failed: 0,
307 },
308 &mut out,
309 )
310 .unwrap();
311 let s = String::from_utf8(out).unwrap();
312 assert!(s.contains("blake3:abcd123"));
313 assert!(s.contains("7 total"));
314 assert!(s.contains("3 built"));
315 assert!(s.contains("4 cached"));
316 }
317
318 #[test]
319 fn section_renders_divider_rule() {
320 let r = plain();
321 let mut out: Vec<u8> = Vec::new();
322 r.render_one(
323 &UiEvent::Section {
324 title: "synthesize".into(),
325 },
326 &mut out,
327 )
328 .unwrap();
329 let s = String::from_utf8(out).unwrap();
330 assert!(s.contains('⟡'));
331 assert!(s.contains("synthesize"));
332 assert!(s.contains('─'));
333 }
334
335 #[test]
336 fn log_sigils_match_level() {
337 let r = plain();
338 for (level, expected_glyph) in [
339 (LogLevel::Success, '✓'),
340 (LogLevel::Error, '✗'),
341 (LogLevel::Warn, '~'),
342 ] {
343 let mut out: Vec<u8> = Vec::new();
344 r.render_one(
345 &UiEvent::Log {
346 level,
347 message: "m".into(),
348 },
349 &mut out,
350 )
351 .unwrap();
352 let s = String::from_utf8(out).unwrap();
353 assert!(
354 s.contains(expected_glyph),
355 "{level:?} should use {expected_glyph}"
356 );
357 }
358 }
359}