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::context::Interactivity;
25use clawless_core::event::process::ProcessEvent;
26use clawless_core::event::prompt::PromptRequest;
27use clawless_core::event::{Event, EventReceiver};
28use clawless_core::exit::Exit;
29use clawless_core::process::Stream;
30
31use super::Presenter;
32use super::line_question;
33use super::line_reader::LineReader;
34use crate::error::CommandResult;
35use crate::output::OutputMode;
36use crate::output::Verbosity;
37
38/// Terminal presenter adapter
39///
40/// Renders command output to the terminal. In text mode, all output goes to stdout. In JSON
41/// mode, messages go to stderr and artifacts are serialized as JSON to stdout. This follows the
42/// convention used by `gh`, `kubectl`, and `jq`.
43///
44/// The presenter also asks the user the questions of the command. It writes a question to
45/// stderr in both modes, and the user answers with one line on stdin. Verbosity and output mode
46/// do not suppress a question. A presenter that is not built as interactive drops every
47/// question.
48///
49/// `TerminalPresenter` is constructed once via its [builder], consumed by a single call to
50/// [`present`], and dropped when the command completes. The presenter holds the [`EventReceiver`]
51/// alive during command execution so that [`EventSender`]s do not receive errors when sending.
52/// After the command completes, the presenter and its receiver are dropped.
53///
54/// # Examples
55///
56/// ```
57/// use clawless_core::event::event_channel;
58/// use clawless_cli::presenter::TerminalPresenter;
59///
60/// let (_sender, receiver) = event_channel();
61/// let presenter = TerminalPresenter::builder().receiver(receiver).build();
62/// ```
63///
64/// [`EventReceiver`]: clawless_core::event::EventReceiver
65/// [`EventSender`]: clawless_core::event::EventSender
66/// [`present`]: super::Presenter::present
67/// [builder]: TerminalPresenter::builder
68#[derive(Debug, Builder)]
69pub struct TerminalPresenter {
70    /// How much detail to render. The presenter drops events below this level
71    #[builder(default)]
72    verbosity: Verbosity,
73
74    /// Whether to render events as text or as JSON
75    #[builder(default)]
76    mode: OutputMode,
77
78    /// Whether a user is present who can answer a prompt
79    #[builder(default)]
80    interactivity: Interactivity,
81
82    /// Stream of events that the command produces
83    receiver: EventReceiver,
84}
85
86/// Reports whether an event of a run belongs on the error stream
87///
88/// A program separates its result from its diagnostics, and the presenter keeps that separation:
89/// what a program wrote to its standard error is written to the standard error of the
90/// application. A reader that redirects one of the two streams therefore sees the same split that
91/// running the program by hand would give.
92///
93/// The start and the end of a run are not output of the program. They belong with the result,
94/// which is where a transcript reads in the order that a person expects.
95// r[impl process.render.streams]
96fn is_diagnostic(event: &ProcessEvent) -> bool {
97    match event {
98        ProcessEvent::Started { .. } => false,
99        ProcessEvent::Finished { .. } => false,
100        ProcessEvent::Line { line, .. } => match line.stream() {
101            Stream::StandardError => true,
102            Stream::StandardOutput => false,
103        },
104    }
105}
106
107/// Renders one event for the given verbosity and output mode
108///
109/// `stdout` and `stderr` are the standard output and the standard error of the application. A
110/// test passes buffers.
111///
112/// In text mode, messages and details go to `stdout`. They therefore interleave with the
113/// artifacts, in the order that the command produced them.
114///
115/// In JSON mode, messages and details go to `stderr` instead. `stdout` then carries only JSON
116/// artifacts, which a caller can pipe into another tool.
117///
118/// The output of an external program is supplementary, so it follows the same rule as a detail
119/// and appears only when the user asks for verbose output. A command that wants a program to be
120/// visible at the default verbosity says so itself with a message.
121///
122/// The presenter handles a prompt before it renders an event. This function drops a prompt that
123/// reaches it.
124///
125/// # Panics
126///
127/// Panics if the process cannot write to the output stream. A reader that closes the pipe
128/// causes this panic.
129// Writing to the process output stream fails only when the stream itself is gone, such as a
130// pipe the reader has closed. A presenter whose output stream has vanished has nowhere left
131// to report the failure, so it fails loudly rather than dropping output silently.
132// r[impl process.render.verbosity]
133#[allow(clippy::expect_used)]
134fn render_event(
135    event: Event,
136    verbosity: Verbosity,
137    mode: OutputMode,
138    stdout: &mut impl Write,
139    stderr: &mut impl Write,
140) {
141    match event {
142        Event::Message(msg) => match verbosity {
143            Verbosity::Quiet => {}
144            Verbosity::Default | Verbosity::Verbose => match mode {
145                OutputMode::Text => writeln!(stdout, "{msg}").expect("should write message"),
146                OutputMode::Json => writeln!(stderr, "{msg}").expect("should write message"),
147            },
148        },
149        Event::Detail(msg) => match verbosity {
150            Verbosity::Quiet | Verbosity::Default => {}
151            Verbosity::Verbose => match mode {
152                OutputMode::Text => writeln!(stdout, "{msg}").expect("should write detail"),
153                OutputMode::Json => writeln!(stderr, "{msg}").expect("should write detail"),
154            },
155        },
156        Event::Artifact(artifact) => {
157            let line = match mode {
158                OutputMode::Text => artifact.to_string(),
159                OutputMode::Json => {
160                    serde_json::to_string(&artifact).expect("should serialize artifact to JSON")
161                }
162            };
163            writeln!(stdout, "{line}").expect("should write artifact");
164        }
165        Event::Process(event) => match verbosity {
166            Verbosity::Quiet | Verbosity::Default => {}
167            Verbosity::Verbose => {
168                let to_stderr = match mode {
169                    OutputMode::Json => true,
170                    OutputMode::Text => is_diagnostic(&event),
171                };
172
173                if to_stderr {
174                    writeln!(stderr, "{event}").expect("should write process event");
175                } else {
176                    writeln!(stdout, "{event}").expect("should write process event");
177                }
178            }
179        },
180        Event::Prompt(request) => drop(request),
181    }
182}
183
184/// Asks the user the question of a request, on the display and from the input
185///
186/// The display is the standard error in every output mode, so the standard output carries only
187/// the result of the command.
188///
189/// The presenter drops a selection without options, because no line is an answer to it.
190async fn ask_user(request: PromptRequest, input: &mut LineReader, display: &mut impl Write) {
191    match request {
192        PromptRequest::Confirm { question, reply } => {
193            line_question::ask(&question, reply, input, display).await;
194        }
195        PromptRequest::Select { question, reply } => {
196            if question.options().is_empty() {
197                drop(reply);
198            } else {
199                line_question::ask(&question, reply, input, display).await;
200            }
201        }
202        PromptRequest::Text { question, reply } => {
203            line_question::ask(&question, reply, input, display).await;
204        }
205    }
206}
207
208impl TerminalPresenter {
209    /// Presents the output of a command on the given streams
210    ///
211    /// [`Presenter::present`] passes the standard output and the standard error of the
212    /// application, and the standard input if a user is present. A test passes buffers and
213    /// scripted input.
214    ///
215    /// Each write locks its stream for one line only, so the presenter does not block other
216    /// writers for the whole presentation.
217    ///
218    /// The presenter handles one event at a time. A question therefore appears after the
219    /// output that the command sent before it, and later output appears after the answer.
220    /// Verbosity does not apply to a question. Without an input, the presenter drops every
221    /// prompt.
222    ///
223    /// # Errors
224    ///
225    /// Returns the error of the command if the command fails.
226    ///
227    /// # Panics
228    ///
229    /// Panics if the command panicked, and if a stream cannot be written.
230    async fn present_on(
231        self,
232        command: Pin<Box<dyn Future<Output = CommandResult<Exit>> + Send>>,
233        stdout: &mut impl Write,
234        stderr: &mut impl Write,
235        mut input: Option<LineReader>,
236    ) -> CommandResult<Exit> {
237        let Self {
238            verbosity,
239            mode,
240            interactivity: _,
241            mut receiver,
242        } = self;
243
244        let command_handle = tokio::spawn(command);
245
246        while let Some(event) = receiver.recv().await {
247            match event {
248                Event::Prompt(request) => match &mut input {
249                    Some(input) => ask_user(*request, input, stderr).await,
250                    None => drop(request),
251                },
252                Event::Message(_) | Event::Detail(_) | Event::Artifact(_) | Event::Process(_) => {
253                    render_event(event, verbosity, mode, stdout, stderr);
254                }
255            }
256        }
257
258        // The join fails only if the command task panicked or was aborted. Resuming the
259        // panic on this thread preserves the original panic message for the user.
260        #[allow(clippy::expect_used)]
261        command_handle.await.expect("command task panicked")
262    }
263}
264
265#[async_trait(?Send)]
266impl Presenter for TerminalPresenter {
267    async fn present(
268        self,
269        command: Pin<Box<dyn Future<Output = CommandResult<Exit>> + Send>>,
270    ) -> CommandResult<Exit> {
271        let input = match self.interactivity {
272            Interactivity::Interactive => Some(LineReader::stdin()),
273            Interactivity::NonInteractive => None,
274        };
275
276        self.present_on(
277            command,
278            &mut std::io::stdout(),
279            &mut std::io::stderr(),
280            input,
281        )
282        .await
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    // An assertion in a test panics by design. A `# Panics` section on every test
289    // would repeat that and give the reader no information.
290    #![allow(clippy::missing_panics_doc)]
291
292    use std::collections::VecDeque;
293    use std::process::ExitCode;
294    use std::sync::{Arc, Mutex};
295    use std::time::Duration;
296
297    use clawless_core::context::Interactivity;
298    use clawless_core::event::process::{Outcome, RunId};
299    use clawless_core::event::prompt::PromptRequest;
300    use clawless_core::event::{EventSender, event_channel};
301    use clawless_core::output::Output;
302    use clawless_core::process::Invocation;
303    use clawless_core::process::Line;
304    use clawless_core::prompt::{Confirm, Confirmation, Prompt, Select};
305
306    use super::*;
307
308    /// Collects what a presenter writes to both of its streams, in the order of the writes
309    #[derive(Clone, Debug, Default)]
310    struct Transcript {
311        /// The bytes of every write so far
312        bytes: Arc<Mutex<Vec<u8>>>,
313    }
314
315    impl Transcript {
316        /// Returns everything that was written so far
317        fn text(&self) -> String {
318            String::from_utf8_lossy(&self.bytes.lock().expect("should lock")).into_owned()
319        }
320    }
321
322    impl Write for Transcript {
323        fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
324            self.bytes
325                .lock()
326                .expect("should lock")
327                .extend_from_slice(buffer);
328
329            Ok(buffer.len())
330        }
331
332        fn flush(&mut self) -> std::io::Result<()> {
333            Ok(())
334        }
335    }
336
337    /// Returns a command that says what it is about to do, asks, and reports the answer
338    fn confirming(
339        sender: EventSender,
340    ) -> Pin<Box<dyn Future<Output = CommandResult<Exit>> + Send>> {
341        Box::pin(async move {
342            let output = Output::new(sender);
343            let prompt = Prompt::builder()
344                .output(output.clone())
345                .interactivity(Interactivity::Interactive)
346                .build();
347
348            output.message("About to release 1.4.0.").await?;
349            let answer = prompt
350                .confirm(Confirm::new("Release?").with_default(Confirmation::No))
351                .await?;
352            output.message(format!("The user said {answer:?}.")).await?;
353
354            Ok(Exit::from(()))
355        })
356    }
357
358    /// Returns an input over the lines that a user types
359    fn typed(lines: &[&str]) -> LineReader {
360        let mut lines = lines
361            .iter()
362            .map(|line| (*line).to_owned())
363            .collect::<VecDeque<_>>();
364
365        LineReader::new(move |buffer| {
366            let line = lines.pop_front().unwrap_or_default();
367            buffer.push_str(&line);
368            Ok(line.len())
369        })
370    }
371
372    #[test]
373    fn builder_with_defaults_uses_default_verbosity_and_mode() {
374        let (_sender, receiver) = event_channel();
375
376        let presenter = TerminalPresenter::builder().receiver(receiver).build();
377
378        assert_eq!(presenter.verbosity, Verbosity::Default);
379        assert_eq!(presenter.mode, OutputMode::Text);
380    }
381
382    #[test]
383    fn builder_with_mode_uses_provided_mode() {
384        let (_sender, receiver) = event_channel();
385
386        let presenter = TerminalPresenter::builder()
387            .receiver(receiver)
388            .mode(OutputMode::Json)
389            .build();
390
391        assert_eq!(presenter.mode, OutputMode::Json);
392    }
393
394    #[test]
395    fn builder_with_verbosity_uses_provided_verbosity() {
396        let (_sender, receiver) = event_channel();
397
398        let presenter = TerminalPresenter::builder()
399            .receiver(receiver)
400            .verbosity(Verbosity::Verbose)
401            .build();
402
403        assert_eq!(presenter.verbosity, Verbosity::Verbose);
404    }
405
406    #[test]
407    fn is_diagnostic_with_a_finished_run_returns_false() {
408        let event = ProcessEvent::Finished {
409            id: RunId::next(),
410            invocation: Invocation::new("git"),
411            outcome: Outcome::Incomplete,
412            duration: Duration::ZERO,
413        };
414
415        let diagnostic = is_diagnostic(&event);
416
417        assert!(!diagnostic);
418    }
419
420    // r[verify process.render.streams]
421    #[test]
422    fn is_diagnostic_with_a_standard_error_line_returns_true() {
423        let event = ProcessEvent::Line {
424            id: RunId::next(),
425            line: Line::new(Stream::StandardError, "no such file"),
426        };
427
428        let diagnostic = is_diagnostic(&event);
429
430        assert!(diagnostic);
431    }
432
433    #[test]
434    fn is_diagnostic_with_a_standard_output_line_returns_false() {
435        let event = ProcessEvent::Line {
436            id: RunId::next(),
437            line: Line::new(Stream::StandardOutput, "hello"),
438        };
439
440        let diagnostic = is_diagnostic(&event);
441
442        assert!(!diagnostic);
443    }
444
445    #[test]
446    fn is_diagnostic_with_a_started_run_returns_false() {
447        let event = ProcessEvent::Started {
448            id: RunId::next(),
449            invocation: Invocation::new("git"),
450            process_id: None,
451        };
452
453        let diagnostic = is_diagnostic(&event);
454
455        assert!(!diagnostic);
456    }
457
458    #[tokio::test]
459    async fn present_consumes_events_from_channel() {
460        let (sender, receiver) = event_channel();
461        let presenter = TerminalPresenter::builder().receiver(receiver).build();
462
463        drop(
464            presenter
465                .present(Box::pin(async move {
466                    sender
467                        .send(Event::Message("consumed".to_string()))
468                        .await
469                        .expect("should send");
470                    Ok(Exit::from(()))
471                }))
472                .await
473                .expect("should succeed"),
474        );
475    }
476
477    #[tokio::test]
478    async fn present_on_with_a_prompt_asks_after_the_earlier_output() {
479        let (sender, receiver) = event_channel();
480        let presenter = TerminalPresenter::builder().receiver(receiver).build();
481        let transcript = Transcript::default();
482
483        drop(
484            presenter
485                .present_on(
486                    confirming(sender),
487                    &mut transcript.clone(),
488                    &mut transcript.clone(),
489                    Some(typed(&["y\n"])),
490                )
491                .await
492                .expect("should succeed"),
493        );
494
495        assert_eq!(
496            transcript.text(),
497            "About to release 1.4.0.\nRelease? [y/N] The user said Yes.\n"
498        );
499    }
500
501    #[tokio::test]
502    async fn present_on_with_a_prompt_at_quiet_verbosity_still_asks() {
503        let (sender, receiver) = event_channel();
504        let presenter = TerminalPresenter::builder()
505            .receiver(receiver)
506            .verbosity(Verbosity::Quiet)
507            .build();
508        let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
509
510        drop(
511            presenter
512                .present_on(
513                    confirming(sender),
514                    &mut stdout,
515                    &mut stderr,
516                    Some(typed(&["y\n"])),
517                )
518                .await
519                .expect("should succeed"),
520        );
521
522        assert_eq!(String::from_utf8_lossy(&stderr), "Release? [y/N] ");
523    }
524
525    #[tokio::test]
526    async fn present_on_with_a_prompt_in_json_mode_keeps_the_question_off_stdout() {
527        let (sender, receiver) = event_channel();
528        let presenter = TerminalPresenter::builder()
529            .receiver(receiver)
530            .mode(OutputMode::Json)
531            .build();
532        let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
533
534        drop(
535            presenter
536                .present_on(
537                    confirming(sender),
538                    &mut stdout,
539                    &mut stderr,
540                    Some(typed(&["y\n"])),
541                )
542                .await
543                .expect("should succeed"),
544        );
545
546        assert_eq!(String::from_utf8_lossy(&stdout), "");
547    }
548
549    #[tokio::test]
550    async fn present_on_with_a_prompt_in_text_mode_asks_on_stderr() {
551        let (sender, receiver) = event_channel();
552        let presenter = TerminalPresenter::builder().receiver(receiver).build();
553        let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
554
555        drop(
556            presenter
557                .present_on(
558                    confirming(sender),
559                    &mut stdout,
560                    &mut stderr,
561                    Some(typed(&["y\n"])),
562                )
563                .await
564                .expect("should succeed"),
565        );
566
567        assert_eq!(String::from_utf8_lossy(&stderr), "Release? [y/N] ");
568    }
569
570    #[tokio::test]
571    async fn present_on_with_a_selection_sends_the_position_to_the_command() {
572        let (sender, receiver) = event_channel();
573        let presenter = TerminalPresenter::builder().receiver(receiver).build();
574        let transcript = Transcript::default();
575
576        drop(
577            presenter
578                .present_on(
579                    Box::pin(async move {
580                        let output = Output::new(sender);
581                        let prompt = Prompt::builder()
582                            .output(output.clone())
583                            .interactivity(Interactivity::Interactive)
584                            .build();
585
586                        let kind = prompt.select("Kind", ["Added", "Fixed"]).await?;
587                        output.message(format!("The kind is {kind}.")).await?;
588
589                        Ok(Exit::from(()))
590                    }),
591                    &mut transcript.clone(),
592                    &mut transcript.clone(),
593                    Some(typed(&["2\n"])),
594                )
595                .await
596                .expect("should succeed"),
597        );
598
599        assert_eq!(
600            transcript.text(),
601            "Kind:\n  1) Added\n  2) Fixed\nEnter a number [1-2]: The kind is Fixed.\n"
602        );
603    }
604
605    #[tokio::test]
606    async fn present_on_with_a_selection_without_options_drops_the_prompt() {
607        let (sender, receiver) = event_channel();
608        let presenter = TerminalPresenter::builder().receiver(receiver).build();
609        let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
610
611        let error = presenter
612            .present_on(
613                Box::pin(async move {
614                    let options: [&str; 0] = [];
615                    let (request, pending) = PromptRequest::select(Select::new("Kind", options));
616                    Output::new(sender).prompt(request).await?;
617                    pending.wait().await?;
618
619                    Ok(Exit::from(()))
620                }),
621                &mut stdout,
622                &mut stderr,
623                Some(typed(&["1\n"])),
624            )
625            .await
626            .expect_err("should fail");
627
628        assert_eq!(
629            error.to_string(),
630            "the prompt was dropped without an answer"
631        );
632    }
633
634    #[tokio::test]
635    async fn present_on_with_a_text_prompt_sends_the_line_to_the_command() {
636        let (sender, receiver) = event_channel();
637        let presenter = TerminalPresenter::builder().receiver(receiver).build();
638        let transcript = Transcript::default();
639
640        drop(
641            presenter
642                .present_on(
643                    Box::pin(async move {
644                        let output = Output::new(sender);
645                        let prompt = Prompt::builder()
646                            .output(output.clone())
647                            .interactivity(Interactivity::Interactive)
648                            .build();
649
650                        let title = prompt.text("Title").await?;
651                        output.message(format!("The title is {title}.")).await?;
652
653                        Ok(Exit::from(()))
654                    }),
655                    &mut transcript.clone(),
656                    &mut transcript.clone(),
657                    Some(typed(&["Fix the race\n"])),
658                )
659                .await
660                .expect("should succeed"),
661        );
662
663        assert_eq!(transcript.text(), "Title: The title is Fix the race.\n");
664    }
665
666    #[tokio::test]
667    async fn present_on_without_an_input_drops_the_prompt() {
668        let (sender, receiver) = event_channel();
669        let presenter = TerminalPresenter::builder().receiver(receiver).build();
670        let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
671
672        let error = presenter
673            .present_on(confirming(sender), &mut stdout, &mut stderr, None)
674            .await
675            .expect_err("should fail");
676
677        assert_eq!(
678            error.root_cause().to_string(),
679            "the prompt was dropped without an answer"
680        );
681    }
682
683    #[tokio::test]
684    async fn present_on_writes_the_events_of_the_command_to_the_streams() {
685        let (sender, receiver) = event_channel();
686        let presenter = TerminalPresenter::builder().receiver(receiver).build();
687        let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
688
689        drop(
690            presenter
691                .present_on(
692                    Box::pin(async move {
693                        sender
694                            .send(Event::Message("hello".to_owned()))
695                            .await
696                            .expect("should send");
697                        Ok(Exit::from(()))
698                    }),
699                    &mut stdout,
700                    &mut stderr,
701                    None,
702                )
703                .await
704                .expect("should succeed"),
705        );
706
707        assert_eq!((stdout, stderr), (b"hello\n".to_vec(), Vec::new()));
708    }
709
710    #[tokio::test]
711    async fn present_with_a_prompt_drops_the_request() {
712        let (sender, receiver) = event_channel();
713        let presenter = TerminalPresenter::builder().receiver(receiver).build();
714
715        let error = presenter
716            .present(Box::pin(async move {
717                let (request, pending) = PromptRequest::confirm(Confirm::new("Release?"));
718                sender
719                    .send(Event::Prompt(Box::new(request)))
720                    .await
721                    .expect("should send");
722                pending.wait().await?;
723                Ok(Exit::from(()))
724            }))
725            .await
726            .expect_err("should fail");
727
728        assert_eq!(
729            error.to_string(),
730            "the prompt was dropped without an answer"
731        );
732    }
733
734    #[tokio::test]
735    async fn present_with_an_exit_returns_the_exit() {
736        let (sender, receiver) = event_channel();
737        let presenter = TerminalPresenter::builder().receiver(receiver).build();
738
739        let exit = presenter
740            .present(Box::pin(async move {
741                drop(sender);
742                Ok(Exit::builder().code(ExitCode::from(3)).build())
743            }))
744            .await
745            .expect("should succeed");
746
747        assert_eq!(exit, Exit::builder().code(ExitCode::from(3)).build());
748    }
749
750    #[tokio::test]
751    async fn present_with_error_propagates_error() {
752        let (sender, receiver) = event_channel();
753        let presenter = TerminalPresenter::builder().receiver(receiver).build();
754
755        let error = presenter
756            .present(Box::pin(async move {
757                drop(sender);
758                Err(anyhow::anyhow!("command failed"))
759            }))
760            .await
761            .expect_err("should fail");
762
763        assert_eq!(error.to_string(), "command failed");
764    }
765
766    #[tokio::test]
767    async fn present_with_receiver_keeps_channel_open_during_execution() {
768        let (sender, receiver) = event_channel();
769        let presenter = TerminalPresenter::builder().receiver(receiver).build();
770
771        drop(
772            presenter
773                .present(Box::pin(async move {
774                    sender
775                        .send(clawless_core::event::Event::Message("hello".to_string()))
776                        .await
777                        .expect("should send while presenter holds receiver");
778                    Ok(Exit::from(()))
779                }))
780                .await
781                .expect("should succeed"),
782        );
783    }
784
785    #[test]
786    fn render_event_with_a_detail_at_default_verbosity_writes_nothing() {
787        let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
788
789        render_event(
790            Event::Detail("reading".to_owned()),
791            Verbosity::Default,
792            OutputMode::Text,
793            &mut stdout,
794            &mut stderr,
795        );
796
797        assert_eq!((stdout, stderr), (Vec::new(), Vec::new()));
798    }
799
800    #[test]
801    fn render_event_with_a_detail_at_verbose_verbosity_writes_to_stdout() {
802        let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
803
804        render_event(
805            Event::Detail("reading".to_owned()),
806            Verbosity::Verbose,
807            OutputMode::Text,
808            &mut stdout,
809            &mut stderr,
810        );
811
812        assert_eq!((stdout, stderr), (b"reading\n".to_vec(), Vec::new()));
813    }
814
815    #[test]
816    fn render_event_with_a_message_at_quiet_verbosity_writes_nothing() {
817        let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
818
819        render_event(
820            Event::Message("hello".to_owned()),
821            Verbosity::Quiet,
822            OutputMode::Text,
823            &mut stdout,
824            &mut stderr,
825        );
826
827        assert_eq!((stdout, stderr), (Vec::new(), Vec::new()));
828    }
829
830    #[test]
831    fn render_event_with_a_message_in_json_mode_writes_to_stderr() {
832        let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
833
834        render_event(
835            Event::Message("hello".to_owned()),
836            Verbosity::Default,
837            OutputMode::Json,
838            &mut stdout,
839            &mut stderr,
840        );
841
842        assert_eq!((stdout, stderr), (Vec::new(), b"hello\n".to_vec()));
843    }
844
845    #[test]
846    fn render_event_with_a_message_in_text_mode_writes_to_stdout() {
847        let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
848
849        render_event(
850            Event::Message("hello".to_owned()),
851            Verbosity::Default,
852            OutputMode::Text,
853            &mut stdout,
854            &mut stderr,
855        );
856
857        assert_eq!((stdout, stderr), (b"hello\n".to_vec(), Vec::new()));
858    }
859
860    #[test]
861    fn render_event_with_a_standard_error_line_in_text_mode_writes_to_stderr() {
862        let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
863
864        render_event(
865            Event::Process(Box::new(ProcessEvent::Line {
866                id: RunId::next(),
867                line: Line::new(Stream::StandardError, "no such file"),
868            })),
869            Verbosity::Verbose,
870            OutputMode::Text,
871            &mut stdout,
872            &mut stderr,
873        );
874
875        assert_eq!((stdout, stderr), (Vec::new(), b"no such file\n".to_vec()));
876    }
877
878    #[test]
879    fn render_event_with_an_artifact_in_json_mode_writes_json_to_stdout() {
880        let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
881
882        render_event(
883            Event::Artifact(Box::new(42)),
884            Verbosity::Quiet,
885            OutputMode::Json,
886            &mut stdout,
887            &mut stderr,
888        );
889
890        assert_eq!((stdout, stderr), (b"42\n".to_vec(), Vec::new()));
891    }
892
893    #[test]
894    fn trait_send() {
895        fn assert_send<T: Send>() {}
896        assert_send::<TerminalPresenter>();
897    }
898
899    #[test]
900    fn trait_unpin() {
901        fn assert_unpin<T: Unpin>() {}
902        assert_unpin::<TerminalPresenter>();
903    }
904}