Skip to main content

clawless_cli/presenter/
terminal.rs

1//! Terminal output adapter
2//!
3//! This module defines [`TerminalPresenter`], the concrete [`Presenter`] adapter for terminal
4//! output. `TerminalPresenter` is a stateless presenter: it renders each event as it arrives,
5//! writing to stdout or stderr based on its [`OutputMode`] and filtering by [`Verbosity`].
6//!
7//! `TerminalPresenter` is constructed once via its builder, consumed by a single call to
8//! [`present`], and dropped when the command completes. The builder requires an [`EventReceiver`]
9//! and defaults [`Verbosity`] to [`Verbosity::Default`] and [`OutputMode`] to
10//! [`OutputMode::Text`].
11//!
12//! [`EventReceiver`]: clawless_core::event::EventReceiver
13//! [`OutputMode`]: crate::output::OutputMode
14//! [`Presenter`]: super::Presenter
15//! [`Verbosity`]: crate::output::Verbosity
16//! [`present`]: super::Presenter::present
17
18use std::future::Future;
19use std::io::Write;
20use std::pin::Pin;
21
22use async_trait::async_trait;
23use bon::Builder;
24use clawless_core::event::{Event, EventReceiver};
25
26use super::Presenter;
27use crate::error::CommandResult;
28use crate::output::OutputMode;
29use crate::output::Verbosity;
30
31/// Terminal presenter adapter
32///
33/// Renders command output to the terminal. In text mode, all output goes to stdout. In JSON
34/// mode, messages go to stderr and artifacts are serialized as JSON to stdout. This follows the
35/// convention used by `gh`, `kubectl`, and `jq`.
36///
37/// `TerminalPresenter` is constructed once via its [builder], consumed by a single call to
38/// [`present`], and dropped when the command completes. The presenter holds the [`EventReceiver`]
39/// alive during command execution so that [`EventSender`]s do not receive errors when sending.
40/// After the command completes, the presenter and its receiver are dropped.
41///
42/// # Examples
43///
44/// ```
45/// use clawless_core::event::event_channel;
46/// use clawless_cli::presenter::TerminalPresenter;
47///
48/// let (_sender, receiver) = event_channel();
49/// let presenter = TerminalPresenter::builder().receiver(receiver).build();
50/// ```
51///
52/// [`EventReceiver`]: clawless_core::event::EventReceiver
53/// [`EventSender`]: clawless_core::event::EventSender
54/// [`present`]: super::Presenter::present
55/// [builder]: TerminalPresenter::builder
56#[derive(Debug, Builder)]
57pub struct TerminalPresenter {
58    /// How much detail to render. The presenter drops events below this level
59    #[builder(default)]
60    verbosity: Verbosity,
61
62    /// Whether to render events as text or as JSON
63    #[builder(default)]
64    mode: OutputMode,
65
66    /// Stream of events that the command produces
67    receiver: EventReceiver,
68}
69
70/// Renders one event to the terminal for the given verbosity and output mode
71///
72/// In text mode, messages and details go to stdout. They therefore interleave with the
73/// artifacts, in the order that the command produced them.
74///
75/// In JSON mode, messages and details go to stderr instead. Stdout then carries only JSON
76/// artifacts, which a caller can pipe into another tool.
77///
78/// # Panics
79///
80/// Panics if the process cannot write to the output stream. A reader that closes the pipe
81/// causes this panic.
82// Writing to the process output stream fails only when the stream itself is gone, such as a
83// pipe the reader has closed. A presenter whose output stream has vanished has nowhere left
84// to report the failure, so it fails loudly rather than dropping output silently.
85#[allow(clippy::expect_used)]
86fn render_event(event: Event, verbosity: Verbosity, mode: OutputMode) {
87    match event {
88        Event::Message(msg) => match verbosity {
89            Verbosity::Quiet => {}
90            Verbosity::Default | Verbosity::Verbose => match mode {
91                OutputMode::Text => {
92                    let mut handle = std::io::stdout().lock();
93                    writeln!(handle, "{msg}").expect("should write message");
94                }
95                OutputMode::Json => {
96                    let mut handle = std::io::stderr().lock();
97                    writeln!(handle, "{msg}").expect("should write message");
98                }
99            },
100        },
101        Event::Detail(msg) => match verbosity {
102            Verbosity::Quiet | Verbosity::Default => {}
103            Verbosity::Verbose => match mode {
104                OutputMode::Text => {
105                    let mut handle = std::io::stdout().lock();
106                    writeln!(handle, "{msg}").expect("should write detail");
107                }
108                OutputMode::Json => {
109                    let mut handle = std::io::stderr().lock();
110                    writeln!(handle, "{msg}").expect("should write detail");
111                }
112            },
113        },
114        Event::Artifact(artifact) => {
115            let line = match mode {
116                OutputMode::Text => artifact.to_string(),
117                OutputMode::Json => {
118                    serde_json::to_string(&artifact).expect("should serialize artifact to JSON")
119                }
120            };
121            let mut handle = std::io::stdout().lock();
122            writeln!(handle, "{line}").expect("should write artifact");
123        }
124    }
125}
126
127#[async_trait(?Send)]
128impl Presenter for TerminalPresenter {
129    async fn present(
130        self,
131        command: Pin<Box<dyn Future<Output = CommandResult> + Send>>,
132    ) -> CommandResult {
133        let Self {
134            verbosity,
135            mode,
136            mut receiver,
137        } = self;
138
139        let command_handle = tokio::spawn(command);
140
141        while let Some(event) = receiver.recv().await {
142            render_event(event, verbosity, mode);
143        }
144
145        // The join fails only if the command task panicked or was aborted. Resuming the
146        // panic on this thread preserves the original panic message for the user.
147        #[allow(clippy::expect_used)]
148        command_handle.await.expect("command task panicked")
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    // An assertion in a test panics by design. A `# Panics` section on every test
155    // would repeat that and give the reader no information.
156    #![allow(clippy::missing_panics_doc)]
157
158    use clawless_core::event::event_channel;
159
160    use super::*;
161
162    #[test]
163    fn builder_with_defaults_uses_default_verbosity_and_mode() {
164        let (_sender, receiver) = event_channel();
165
166        let presenter = TerminalPresenter::builder().receiver(receiver).build();
167
168        assert_eq!(presenter.verbosity, Verbosity::Default);
169        assert_eq!(presenter.mode, OutputMode::Text);
170    }
171
172    #[test]
173    fn builder_with_mode_uses_provided_mode() {
174        let (_sender, receiver) = event_channel();
175
176        let presenter = TerminalPresenter::builder()
177            .receiver(receiver)
178            .mode(OutputMode::Json)
179            .build();
180
181        assert_eq!(presenter.mode, OutputMode::Json);
182    }
183
184    #[test]
185    fn builder_with_verbosity_uses_provided_verbosity() {
186        let (_sender, receiver) = event_channel();
187
188        let presenter = TerminalPresenter::builder()
189            .receiver(receiver)
190            .verbosity(Verbosity::Verbose)
191            .build();
192
193        assert_eq!(presenter.verbosity, Verbosity::Verbose);
194    }
195
196    #[tokio::test]
197    async fn present_consumes_events_from_channel() {
198        let (sender, receiver) = event_channel();
199        let presenter = TerminalPresenter::builder().receiver(receiver).build();
200
201        presenter
202            .present(Box::pin(async move {
203                sender
204                    .send(Event::Message("consumed".to_string()))
205                    .await
206                    .expect("should send");
207                Ok(())
208            }))
209            .await
210            .expect("should succeed");
211    }
212
213    #[tokio::test]
214    async fn present_with_error_propagates_error() {
215        let (sender, receiver) = event_channel();
216        let presenter = TerminalPresenter::builder().receiver(receiver).build();
217
218        let error = presenter
219            .present(Box::pin(async move {
220                drop(sender);
221                Err(anyhow::anyhow!("command failed"))
222            }))
223            .await
224            .expect_err("should fail");
225
226        assert_eq!(error.to_string(), "command failed");
227    }
228
229    #[tokio::test]
230    async fn present_with_ok_returns_ok() {
231        let (sender, receiver) = event_channel();
232        let presenter = TerminalPresenter::builder().receiver(receiver).build();
233
234        presenter
235            .present(Box::pin(async move {
236                drop(sender);
237                Ok(())
238            }))
239            .await
240            .expect("should succeed");
241    }
242
243    #[tokio::test]
244    async fn present_with_receiver_keeps_channel_open_during_execution() {
245        let (sender, receiver) = event_channel();
246        let presenter = TerminalPresenter::builder().receiver(receiver).build();
247
248        presenter
249            .present(Box::pin(async move {
250                sender
251                    .send(clawless_core::event::Event::Message("hello".to_string()))
252                    .await
253                    .expect("should send while presenter holds receiver");
254                Ok(())
255            }))
256            .await
257            .expect("should succeed");
258    }
259
260    #[test]
261    fn trait_send() {
262        fn assert_send<T: Send>() {}
263        assert_send::<TerminalPresenter>();
264    }
265
266    #[test]
267    fn trait_unpin() {
268        fn assert_unpin<T: Unpin>() {}
269        assert_unpin::<TerminalPresenter>();
270    }
271}