1use 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::process::Stream;
29
30use super::Presenter;
31use super::line_question;
32use super::line_reader::LineReader;
33use crate::error::CommandResult;
34use crate::output::OutputMode;
35use crate::output::Verbosity;
36
37#[derive(Debug, Builder)]
68pub struct TerminalPresenter {
69 #[builder(default)]
71 verbosity: Verbosity,
72
73 #[builder(default)]
75 mode: OutputMode,
76
77 #[builder(default)]
79 interactivity: Interactivity,
80
81 receiver: EventReceiver,
83}
84
85fn is_diagnostic(event: &ProcessEvent) -> bool {
96 match event {
97 ProcessEvent::Started { .. } => false,
98 ProcessEvent::Finished { .. } => false,
99 ProcessEvent::Line { line, .. } => match line.stream() {
100 Stream::StandardError => true,
101 Stream::StandardOutput => false,
102 },
103 }
104}
105
106#[allow(clippy::expect_used)]
133fn render_event(
134 event: Event,
135 verbosity: Verbosity,
136 mode: OutputMode,
137 stdout: &mut impl Write,
138 stderr: &mut impl Write,
139) {
140 match event {
141 Event::Message(msg) => match verbosity {
142 Verbosity::Quiet => {}
143 Verbosity::Default | Verbosity::Verbose => match mode {
144 OutputMode::Text => writeln!(stdout, "{msg}").expect("should write message"),
145 OutputMode::Json => writeln!(stderr, "{msg}").expect("should write message"),
146 },
147 },
148 Event::Detail(msg) => match verbosity {
149 Verbosity::Quiet | Verbosity::Default => {}
150 Verbosity::Verbose => match mode {
151 OutputMode::Text => writeln!(stdout, "{msg}").expect("should write detail"),
152 OutputMode::Json => writeln!(stderr, "{msg}").expect("should write detail"),
153 },
154 },
155 Event::Artifact(artifact) => {
156 let line = match mode {
157 OutputMode::Text => artifact.to_string(),
158 OutputMode::Json => {
159 serde_json::to_string(&artifact).expect("should serialize artifact to JSON")
160 }
161 };
162 writeln!(stdout, "{line}").expect("should write artifact");
163 }
164 Event::Process(event) => match verbosity {
165 Verbosity::Quiet | Verbosity::Default => {}
166 Verbosity::Verbose => {
167 let to_stderr = match mode {
168 OutputMode::Json => true,
169 OutputMode::Text => is_diagnostic(&event),
170 };
171
172 if to_stderr {
173 writeln!(stderr, "{event}").expect("should write process event");
174 } else {
175 writeln!(stdout, "{event}").expect("should write process event");
176 }
177 }
178 },
179 Event::Prompt(request) => drop(request),
180 }
181}
182
183async fn ask_user(request: PromptRequest, input: &mut LineReader, display: &mut impl Write) {
190 match request {
191 PromptRequest::Confirm { question, reply } => {
192 line_question::ask(&question, reply, input, display).await;
193 }
194 PromptRequest::Select { question, reply } => {
195 if question.options().is_empty() {
196 drop(reply);
197 } else {
198 line_question::ask(&question, reply, input, display).await;
199 }
200 }
201 PromptRequest::Text { question, reply } => {
202 line_question::ask(&question, reply, input, display).await;
203 }
204 }
205}
206
207impl TerminalPresenter {
208 async fn present_on(
230 self,
231 command: Pin<Box<dyn Future<Output = CommandResult> + Send>>,
232 stdout: &mut impl Write,
233 stderr: &mut impl Write,
234 mut input: Option<LineReader>,
235 ) -> CommandResult {
236 let Self {
237 verbosity,
238 mode,
239 interactivity: _,
240 mut receiver,
241 } = self;
242
243 let command_handle = tokio::spawn(command);
244
245 while let Some(event) = receiver.recv().await {
246 match event {
247 Event::Prompt(request) => match &mut input {
248 Some(input) => ask_user(*request, input, stderr).await,
249 None => drop(request),
250 },
251 Event::Message(_) | Event::Detail(_) | Event::Artifact(_) | Event::Process(_) => {
252 render_event(event, verbosity, mode, stdout, stderr);
253 }
254 }
255 }
256
257 #[allow(clippy::expect_used)]
260 command_handle.await.expect("command task panicked")
261 }
262}
263
264#[async_trait(?Send)]
265impl Presenter for TerminalPresenter {
266 async fn present(
267 self,
268 command: Pin<Box<dyn Future<Output = CommandResult> + Send>>,
269 ) -> CommandResult {
270 let input = match self.interactivity {
271 Interactivity::Interactive => Some(LineReader::stdin()),
272 Interactivity::NonInteractive => None,
273 };
274
275 self.present_on(
276 command,
277 &mut std::io::stdout(),
278 &mut std::io::stderr(),
279 input,
280 )
281 .await
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 #![allow(clippy::missing_panics_doc)]
290
291 use std::collections::VecDeque;
292 use std::sync::{Arc, Mutex};
293 use std::time::Duration;
294
295 use clawless_core::context::Interactivity;
296 use clawless_core::event::process::{Outcome, RunId};
297 use clawless_core::event::prompt::PromptRequest;
298 use clawless_core::event::{EventSender, event_channel};
299 use clawless_core::output::Output;
300 use clawless_core::process::Invocation;
301 use clawless_core::process::Line;
302 use clawless_core::prompt::{Confirm, Confirmation, Prompt, Select};
303
304 use super::*;
305
306 #[derive(Clone, Debug, Default)]
308 struct Transcript {
309 bytes: Arc<Mutex<Vec<u8>>>,
311 }
312
313 impl Transcript {
314 fn text(&self) -> String {
316 String::from_utf8_lossy(&self.bytes.lock().expect("should lock")).into_owned()
317 }
318 }
319
320 impl Write for Transcript {
321 fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
322 self.bytes
323 .lock()
324 .expect("should lock")
325 .extend_from_slice(buffer);
326
327 Ok(buffer.len())
328 }
329
330 fn flush(&mut self) -> std::io::Result<()> {
331 Ok(())
332 }
333 }
334
335 fn confirming(sender: EventSender) -> Pin<Box<dyn Future<Output = CommandResult> + Send>> {
337 Box::pin(async move {
338 let output = Output::new(sender);
339 let prompt = Prompt::builder()
340 .output(output.clone())
341 .interactivity(Interactivity::Interactive)
342 .build();
343
344 output.message("About to release 1.4.0.").await?;
345 let answer = prompt
346 .confirm(Confirm::new("Release?").with_default(Confirmation::No))
347 .await?;
348 output.message(format!("The user said {answer:?}.")).await?;
349
350 Ok(())
351 })
352 }
353
354 fn typed(lines: &[&str]) -> LineReader {
356 let mut lines = lines
357 .iter()
358 .map(|line| (*line).to_owned())
359 .collect::<VecDeque<_>>();
360
361 LineReader::new(move |buffer| {
362 let line = lines.pop_front().unwrap_or_default();
363 buffer.push_str(&line);
364 Ok(line.len())
365 })
366 }
367
368 #[test]
369 fn builder_with_defaults_uses_default_verbosity_and_mode() {
370 let (_sender, receiver) = event_channel();
371
372 let presenter = TerminalPresenter::builder().receiver(receiver).build();
373
374 assert_eq!(presenter.verbosity, Verbosity::Default);
375 assert_eq!(presenter.mode, OutputMode::Text);
376 }
377
378 #[test]
379 fn builder_with_mode_uses_provided_mode() {
380 let (_sender, receiver) = event_channel();
381
382 let presenter = TerminalPresenter::builder()
383 .receiver(receiver)
384 .mode(OutputMode::Json)
385 .build();
386
387 assert_eq!(presenter.mode, OutputMode::Json);
388 }
389
390 #[test]
391 fn builder_with_verbosity_uses_provided_verbosity() {
392 let (_sender, receiver) = event_channel();
393
394 let presenter = TerminalPresenter::builder()
395 .receiver(receiver)
396 .verbosity(Verbosity::Verbose)
397 .build();
398
399 assert_eq!(presenter.verbosity, Verbosity::Verbose);
400 }
401
402 #[test]
403 fn is_diagnostic_with_a_finished_run_returns_false() {
404 let event = ProcessEvent::Finished {
405 id: RunId::next(),
406 invocation: Invocation::new("git"),
407 outcome: Outcome::Incomplete,
408 duration: Duration::ZERO,
409 };
410
411 let diagnostic = is_diagnostic(&event);
412
413 assert!(!diagnostic);
414 }
415
416 #[test]
418 fn is_diagnostic_with_a_standard_error_line_returns_true() {
419 let event = ProcessEvent::Line {
420 id: RunId::next(),
421 line: Line::new(Stream::StandardError, "no such file"),
422 };
423
424 let diagnostic = is_diagnostic(&event);
425
426 assert!(diagnostic);
427 }
428
429 #[test]
430 fn is_diagnostic_with_a_standard_output_line_returns_false() {
431 let event = ProcessEvent::Line {
432 id: RunId::next(),
433 line: Line::new(Stream::StandardOutput, "hello"),
434 };
435
436 let diagnostic = is_diagnostic(&event);
437
438 assert!(!diagnostic);
439 }
440
441 #[test]
442 fn is_diagnostic_with_a_started_run_returns_false() {
443 let event = ProcessEvent::Started {
444 id: RunId::next(),
445 invocation: Invocation::new("git"),
446 process_id: None,
447 };
448
449 let diagnostic = is_diagnostic(&event);
450
451 assert!(!diagnostic);
452 }
453
454 #[tokio::test]
455 async fn present_consumes_events_from_channel() {
456 let (sender, receiver) = event_channel();
457 let presenter = TerminalPresenter::builder().receiver(receiver).build();
458
459 presenter
460 .present(Box::pin(async move {
461 sender
462 .send(Event::Message("consumed".to_string()))
463 .await
464 .expect("should send");
465 Ok(())
466 }))
467 .await
468 .expect("should succeed");
469 }
470
471 #[tokio::test]
472 async fn present_on_with_a_prompt_asks_after_the_earlier_output() {
473 let (sender, receiver) = event_channel();
474 let presenter = TerminalPresenter::builder().receiver(receiver).build();
475 let transcript = Transcript::default();
476
477 presenter
478 .present_on(
479 confirming(sender),
480 &mut transcript.clone(),
481 &mut transcript.clone(),
482 Some(typed(&["y\n"])),
483 )
484 .await
485 .expect("should succeed");
486
487 assert_eq!(
488 transcript.text(),
489 "About to release 1.4.0.\nRelease? [y/N] The user said Yes.\n"
490 );
491 }
492
493 #[tokio::test]
494 async fn present_on_with_a_prompt_at_quiet_verbosity_still_asks() {
495 let (sender, receiver) = event_channel();
496 let presenter = TerminalPresenter::builder()
497 .receiver(receiver)
498 .verbosity(Verbosity::Quiet)
499 .build();
500 let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
501
502 presenter
503 .present_on(
504 confirming(sender),
505 &mut stdout,
506 &mut stderr,
507 Some(typed(&["y\n"])),
508 )
509 .await
510 .expect("should succeed");
511
512 assert_eq!(String::from_utf8_lossy(&stderr), "Release? [y/N] ");
513 }
514
515 #[tokio::test]
516 async fn present_on_with_a_prompt_in_json_mode_keeps_the_question_off_stdout() {
517 let (sender, receiver) = event_channel();
518 let presenter = TerminalPresenter::builder()
519 .receiver(receiver)
520 .mode(OutputMode::Json)
521 .build();
522 let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
523
524 presenter
525 .present_on(
526 confirming(sender),
527 &mut stdout,
528 &mut stderr,
529 Some(typed(&["y\n"])),
530 )
531 .await
532 .expect("should succeed");
533
534 assert_eq!(String::from_utf8_lossy(&stdout), "");
535 }
536
537 #[tokio::test]
538 async fn present_on_with_a_prompt_in_text_mode_asks_on_stderr() {
539 let (sender, receiver) = event_channel();
540 let presenter = TerminalPresenter::builder().receiver(receiver).build();
541 let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
542
543 presenter
544 .present_on(
545 confirming(sender),
546 &mut stdout,
547 &mut stderr,
548 Some(typed(&["y\n"])),
549 )
550 .await
551 .expect("should succeed");
552
553 assert_eq!(String::from_utf8_lossy(&stderr), "Release? [y/N] ");
554 }
555
556 #[tokio::test]
557 async fn present_on_with_a_selection_sends_the_position_to_the_command() {
558 let (sender, receiver) = event_channel();
559 let presenter = TerminalPresenter::builder().receiver(receiver).build();
560 let transcript = Transcript::default();
561
562 presenter
563 .present_on(
564 Box::pin(async move {
565 let output = Output::new(sender);
566 let prompt = Prompt::builder()
567 .output(output.clone())
568 .interactivity(Interactivity::Interactive)
569 .build();
570
571 let kind = prompt.select("Kind", ["Added", "Fixed"]).await?;
572 output.message(format!("The kind is {kind}.")).await?;
573
574 Ok(())
575 }),
576 &mut transcript.clone(),
577 &mut transcript.clone(),
578 Some(typed(&["2\n"])),
579 )
580 .await
581 .expect("should succeed");
582
583 assert_eq!(
584 transcript.text(),
585 "Kind:\n 1) Added\n 2) Fixed\nEnter a number [1-2]: The kind is Fixed.\n"
586 );
587 }
588
589 #[tokio::test]
590 async fn present_on_with_a_selection_without_options_drops_the_prompt() {
591 let (sender, receiver) = event_channel();
592 let presenter = TerminalPresenter::builder().receiver(receiver).build();
593 let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
594
595 let error = presenter
596 .present_on(
597 Box::pin(async move {
598 let options: [&str; 0] = [];
599 let (request, pending) = PromptRequest::select(Select::new("Kind", options));
600 Output::new(sender).prompt(request).await?;
601 pending.wait().await?;
602
603 Ok(())
604 }),
605 &mut stdout,
606 &mut stderr,
607 Some(typed(&["1\n"])),
608 )
609 .await
610 .expect_err("should fail");
611
612 assert_eq!(
613 error.to_string(),
614 "the prompt was dropped without an answer"
615 );
616 }
617
618 #[tokio::test]
619 async fn present_on_with_a_text_prompt_sends_the_line_to_the_command() {
620 let (sender, receiver) = event_channel();
621 let presenter = TerminalPresenter::builder().receiver(receiver).build();
622 let transcript = Transcript::default();
623
624 presenter
625 .present_on(
626 Box::pin(async move {
627 let output = Output::new(sender);
628 let prompt = Prompt::builder()
629 .output(output.clone())
630 .interactivity(Interactivity::Interactive)
631 .build();
632
633 let title = prompt.text("Title").await?;
634 output.message(format!("The title is {title}.")).await?;
635
636 Ok(())
637 }),
638 &mut transcript.clone(),
639 &mut transcript.clone(),
640 Some(typed(&["Fix the race\n"])),
641 )
642 .await
643 .expect("should succeed");
644
645 assert_eq!(transcript.text(), "Title: The title is Fix the race.\n");
646 }
647
648 #[tokio::test]
649 async fn present_on_without_an_input_drops_the_prompt() {
650 let (sender, receiver) = event_channel();
651 let presenter = TerminalPresenter::builder().receiver(receiver).build();
652 let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
653
654 let error = presenter
655 .present_on(confirming(sender), &mut stdout, &mut stderr, None)
656 .await
657 .expect_err("should fail");
658
659 assert_eq!(
660 error.root_cause().to_string(),
661 "the prompt was dropped without an answer"
662 );
663 }
664
665 #[tokio::test]
666 async fn present_on_writes_the_events_of_the_command_to_the_streams() {
667 let (sender, receiver) = event_channel();
668 let presenter = TerminalPresenter::builder().receiver(receiver).build();
669 let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
670
671 presenter
672 .present_on(
673 Box::pin(async move {
674 sender
675 .send(Event::Message("hello".to_owned()))
676 .await
677 .expect("should send");
678 Ok(())
679 }),
680 &mut stdout,
681 &mut stderr,
682 None,
683 )
684 .await
685 .expect("should succeed");
686
687 assert_eq!((stdout, stderr), (b"hello\n".to_vec(), Vec::new()));
688 }
689
690 #[tokio::test]
691 async fn present_with_a_prompt_drops_the_request() {
692 let (sender, receiver) = event_channel();
693 let presenter = TerminalPresenter::builder().receiver(receiver).build();
694
695 let error = presenter
696 .present(Box::pin(async move {
697 let (request, pending) = PromptRequest::confirm(Confirm::new("Release?"));
698 sender
699 .send(Event::Prompt(Box::new(request)))
700 .await
701 .expect("should send");
702 pending.wait().await?;
703 Ok(())
704 }))
705 .await
706 .expect_err("should fail");
707
708 assert_eq!(
709 error.to_string(),
710 "the prompt was dropped without an answer"
711 );
712 }
713
714 #[tokio::test]
715 async fn present_with_error_propagates_error() {
716 let (sender, receiver) = event_channel();
717 let presenter = TerminalPresenter::builder().receiver(receiver).build();
718
719 let error = presenter
720 .present(Box::pin(async move {
721 drop(sender);
722 Err(anyhow::anyhow!("command failed"))
723 }))
724 .await
725 .expect_err("should fail");
726
727 assert_eq!(error.to_string(), "command failed");
728 }
729
730 #[tokio::test]
731 async fn present_with_ok_returns_ok() {
732 let (sender, receiver) = event_channel();
733 let presenter = TerminalPresenter::builder().receiver(receiver).build();
734
735 presenter
736 .present(Box::pin(async move {
737 drop(sender);
738 Ok(())
739 }))
740 .await
741 .expect("should succeed");
742 }
743
744 #[tokio::test]
745 async fn present_with_receiver_keeps_channel_open_during_execution() {
746 let (sender, receiver) = event_channel();
747 let presenter = TerminalPresenter::builder().receiver(receiver).build();
748
749 presenter
750 .present(Box::pin(async move {
751 sender
752 .send(clawless_core::event::Event::Message("hello".to_string()))
753 .await
754 .expect("should send while presenter holds receiver");
755 Ok(())
756 }))
757 .await
758 .expect("should succeed");
759 }
760
761 #[test]
762 fn render_event_with_a_detail_at_default_verbosity_writes_nothing() {
763 let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
764
765 render_event(
766 Event::Detail("reading".to_owned()),
767 Verbosity::Default,
768 OutputMode::Text,
769 &mut stdout,
770 &mut stderr,
771 );
772
773 assert_eq!((stdout, stderr), (Vec::new(), Vec::new()));
774 }
775
776 #[test]
777 fn render_event_with_a_detail_at_verbose_verbosity_writes_to_stdout() {
778 let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
779
780 render_event(
781 Event::Detail("reading".to_owned()),
782 Verbosity::Verbose,
783 OutputMode::Text,
784 &mut stdout,
785 &mut stderr,
786 );
787
788 assert_eq!((stdout, stderr), (b"reading\n".to_vec(), Vec::new()));
789 }
790
791 #[test]
792 fn render_event_with_a_message_at_quiet_verbosity_writes_nothing() {
793 let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
794
795 render_event(
796 Event::Message("hello".to_owned()),
797 Verbosity::Quiet,
798 OutputMode::Text,
799 &mut stdout,
800 &mut stderr,
801 );
802
803 assert_eq!((stdout, stderr), (Vec::new(), Vec::new()));
804 }
805
806 #[test]
807 fn render_event_with_a_message_in_json_mode_writes_to_stderr() {
808 let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
809
810 render_event(
811 Event::Message("hello".to_owned()),
812 Verbosity::Default,
813 OutputMode::Json,
814 &mut stdout,
815 &mut stderr,
816 );
817
818 assert_eq!((stdout, stderr), (Vec::new(), b"hello\n".to_vec()));
819 }
820
821 #[test]
822 fn render_event_with_a_message_in_text_mode_writes_to_stdout() {
823 let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
824
825 render_event(
826 Event::Message("hello".to_owned()),
827 Verbosity::Default,
828 OutputMode::Text,
829 &mut stdout,
830 &mut stderr,
831 );
832
833 assert_eq!((stdout, stderr), (b"hello\n".to_vec(), Vec::new()));
834 }
835
836 #[test]
837 fn render_event_with_a_standard_error_line_in_text_mode_writes_to_stderr() {
838 let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
839
840 render_event(
841 Event::Process(Box::new(ProcessEvent::Line {
842 id: RunId::next(),
843 line: Line::new(Stream::StandardError, "no such file"),
844 })),
845 Verbosity::Verbose,
846 OutputMode::Text,
847 &mut stdout,
848 &mut stderr,
849 );
850
851 assert_eq!((stdout, stderr), (Vec::new(), b"no such file\n".to_vec()));
852 }
853
854 #[test]
855 fn render_event_with_an_artifact_in_json_mode_writes_json_to_stdout() {
856 let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
857
858 render_event(
859 Event::Artifact(Box::new(42)),
860 Verbosity::Quiet,
861 OutputMode::Json,
862 &mut stdout,
863 &mut stderr,
864 );
865
866 assert_eq!((stdout, stderr), (b"42\n".to_vec(), Vec::new()));
867 }
868
869 #[test]
870 fn trait_send() {
871 fn assert_send<T: Send>() {}
872 assert_send::<TerminalPresenter>();
873 }
874
875 #[test]
876 fn trait_unpin() {
877 fn assert_unpin<T: Unpin>() {}
878 assert_unpin::<TerminalPresenter>();
879 }
880}