hm_render/lib.rs
1//! Build-event renderers shared by the `hm` CLI and the cloud plugin.
2//!
3//! This crate owns the output layer: the [`OutputRenderer`] trait, the
4//! [`OutputMode`] selection enum, and the concrete renderers
5//! ([`HumanRenderer`], [`ProgressRenderer`], [`JsonRenderer`]). All of
6//! them consume [`hm_plugin_protocol::BuildEvent`]s; nothing here depends
7//! on `hm` internals (no `RunContext`, no Docker types).
8
9use std::fmt;
10use std::io::IsTerminal;
11
12use hm_plugin_protocol::BuildEvent;
13
14/// Whether ANSI color should be used: honors an explicit no-color flag,
15/// the `NO_COLOR` env convention, and whether stderr is a TTY.
16///
17/// Single source of truth for the color rule, shared by the `hm` host
18/// context and the cloud plugin's render preferences.
19#[must_use]
20pub fn color_enabled(no_color_flag: bool) -> bool {
21 !no_color_flag && std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
22}
23
24/// Whether stderr is an interactive terminal (drives the progress view).
25#[must_use]
26pub fn stderr_interactive() -> bool {
27 std::io::stderr().is_terminal()
28}
29
30/// Whether stdout is NOT a TTY (i.e. piped) — used to force the streaming log view.
31#[must_use]
32pub fn stdout_piped() -> bool {
33 !std::io::stdout().is_terminal()
34}
35
36pub mod human;
37pub mod json;
38pub mod progress;
39pub mod spinner;
40
41pub use human::HumanRenderer;
42pub use json::JsonRenderer;
43pub use progress::ProgressRenderer;
44
45/// Synchronous observer of [`BuildEvent`]s.
46///
47/// Implementations format events for human consumption (progress bars,
48/// coloured log lines) or machine consumption (JSON-lines).
49pub trait OutputRenderer: Send + fmt::Debug {
50 /// Called once per event in emission order.
51 fn on_event(&mut self, event: &BuildEvent);
52}
53
54/// How to render output. Determined at startup from CLI flags and TTY detection.
55#[derive(Debug, Clone)]
56pub enum OutputMode {
57 Human {
58 /// Whether ANSI colors are enabled.
59 color: bool,
60 /// Whether stdout is an interactive terminal (enables prompts, spinners).
61 interactive: bool,
62 },
63 Json,
64}
65
66impl OutputMode {
67 /// True when output should be JSON, suitable for scripting.
68 #[must_use]
69 pub const fn is_json(&self) -> bool {
70 matches!(self, Self::Json)
71 }
72
73 /// True when output is meant for a human reader (color/spinner-friendly).
74 #[must_use]
75 pub const fn is_human(&self) -> bool {
76 matches!(self, Self::Human { .. })
77 }
78
79 /// True when ANSI color codes should be emitted.
80 #[must_use]
81 pub const fn color_enabled(&self) -> bool {
82 matches!(self, Self::Human { color: true, .. })
83 }
84
85 /// True when stdout is interactive (allows prompts and spinners).
86 #[must_use]
87 pub const fn interactive(&self) -> bool {
88 matches!(
89 self,
90 Self::Human {
91 interactive: true,
92 ..
93 }
94 )
95 }
96
97 /// True when OSC 8 hyperlinks should be emitted (interactive + color).
98 #[must_use]
99 pub const fn use_hyperlinks(&self) -> bool {
100 matches!(
101 self,
102 Self::Human {
103 interactive: true,
104 color: true
105 }
106 )
107 }
108}
109
110/// Build the renderer for a run.
111///
112/// `format` is the `--format` value (`"human"` or `"json"`); `color`
113/// controls ANSI output; `logs` forces the streaming [`HumanRenderer`]
114/// over the [`ProgressRenderer`] view (set by `--logs` or a CI
115/// environment). Mirrors the prior inline selection in
116/// `hm`'s `commands/run/local.rs`.
117///
118/// # Errors
119///
120/// Returns an error when `format` is neither `"human"` nor `"json"`.
121pub fn renderer_for(
122 format: &str,
123 color: bool,
124 logs: bool,
125) -> anyhow::Result<Box<dyn OutputRenderer>> {
126 match format {
127 "json" => Ok(Box::new(JsonRenderer::new(std::io::stdout()))),
128 "human" if logs => Ok(Box::new(HumanRenderer::new(std::io::stderr(), color))),
129 "human" => Ok(Box::new(ProgressRenderer::new(std::io::stderr(), color))),
130 other => anyhow::bail!("unknown --format '{other}'\n available: human, json"),
131 }
132}
133
134/// Drive a renderer from an mpsc stream of events.
135///
136/// Consumes events until the channel closes or a `BuildEnd` is seen.
137/// Mirrors the local broadcast `output_subscriber` loop, but for a
138/// single-consumer channel (used by the cloud path).
139pub async fn drive(
140 mut renderer: Box<dyn OutputRenderer>,
141 mut rx: tokio::sync::mpsc::Receiver<BuildEvent>,
142) {
143 while let Some(ev) = rx.recv().await {
144 let end = ev.is_build_end();
145 renderer.on_event(&ev);
146 if end {
147 break;
148 }
149 }
150}
151
152/// Drive a renderer from a [`Stream`] of events until it ends or a
153/// `BuildEnd` is seen.
154///
155/// The `hm-exec` backend handle yields events as a
156/// `BoxStream<'static, BuildEvent>`; this function is the counterpart to
157/// [`drive`] for that case.
158///
159/// [`Stream`]: futures::stream::Stream
160pub async fn drive_stream(
161 mut renderer: Box<dyn OutputRenderer>,
162 mut events: futures::stream::BoxStream<'static, BuildEvent>,
163) {
164 use futures::StreamExt as _;
165 while let Some(ev) = events.next().await {
166 let end = ev.is_build_end();
167 renderer.on_event(&ev);
168 if end {
169 break;
170 }
171 }
172}