Skip to main content

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}