clawless_cli/presenter/
terminal.rs1use 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#[derive(Debug, Builder)]
57pub struct TerminalPresenter {
58 #[builder(default)]
60 verbosity: Verbosity,
61
62 #[builder(default)]
64 mode: OutputMode,
65
66 receiver: EventReceiver,
68}
69
70#[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 #[allow(clippy::expect_used)]
148 command_handle.await.expect("command task panicked")
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 #![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}