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::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#[derive(Debug, Builder)]
69pub struct TerminalPresenter {
70 #[builder(default)]
72 verbosity: Verbosity,
73
74 #[builder(default)]
76 mode: OutputMode,
77
78 #[builder(default)]
80 interactivity: Interactivity,
81
82 receiver: EventReceiver,
84}
85
86fn 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#[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
184async 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 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 #[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 #![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 #[derive(Clone, Debug, Default)]
310 struct Transcript {
311 bytes: Arc<Mutex<Vec<u8>>>,
313 }
314
315 impl Transcript {
316 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 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 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 #[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}