Skip to main content

clawless_core/prompt/
mod.rs

1//! Questions that a command asks its user
2//!
3//! [`Prompt`] is the interface that asks. [`Confirm`] is a question that the user answers with
4//! yes or no, and [`Confirmation`] is the answer. [`Text`] is a question that the user answers
5//! with one line of text, and [`Select`] is one that the user answers with one of several
6//! options. [`PromptUserError`] is the reason why a command received no answer.
7//!
8//! A question reaches the presenter as a [`PromptRequest`], and the presenter answers it.
9//! [`AnswerPromptError`] is the reason that the presenter reports when it has no answer.
10//!
11//! In a test, [`ScriptedUser`] answers each prompt with the next [`ScriptedAnswer`] of its
12//! script.
13//!
14//! # Examples
15//!
16//! ```no_run
17//! use clawless_core::prompt::{Confirm, Confirmation, Prompt};
18//!
19//! # async fn example(prompt: Prompt) -> Result<(), Box<dyn std::error::Error>> {
20//! let question = Confirm::new("Delete the branch?").with_default(Confirmation::No);
21//!
22//! match prompt.confirm(question).await? {
23//!     Confirmation::Yes => println!("deleting"),
24//!     Confirmation::No => println!("keeping"),
25//! }
26//! # Ok(())
27//! # }
28//! ```
29
30use std::fmt::Display;
31
32use bon::Builder;
33use getset::CopyGetters;
34
35pub use self::answer_prompt_error::AnswerPromptError;
36pub use self::confirm::Confirm;
37pub use self::confirmation::Confirmation;
38pub use self::prompt_user_error::PromptUserError;
39pub use self::scripted_answer::ScriptedAnswer;
40pub use self::scripted_user::ScriptedUser;
41pub use self::select::Select;
42pub use self::text::Text;
43use crate::cancellation::Cancellation;
44use crate::context::Interactivity;
45use crate::event::prompt::{PendingAnswer, PromptRequest};
46use crate::output::Output;
47
48/// The error that ends a prompt without an answer from the user
49mod answer_prompt_error;
50/// A question that a user answers with yes or no
51mod confirm;
52/// The answer of a user to a confirmation
53mod confirmation;
54/// The error returned when a command cannot get an answer from its user
55mod prompt_user_error;
56/// One answer in the script of a scripted user
57mod scripted_answer;
58/// A user for tests, who answers every prompt from a script
59mod scripted_user;
60/// A question that a user answers with one of several options
61mod select;
62/// A question that a user answers with one line of text
63mod text;
64
65/// Asks the user of the application a question and waits for the answer
66///
67/// `Prompt` holds the [`Output`] that sends a question to the presenter, the [`Cancellation`]
68/// that stops the wait, and the [`Interactivity`] of the application. [`Context::prompt`] builds
69/// one from the context of a command.
70///
71/// A question arrives at the presenter after the output that the command sent before it.
72///
73/// Without a user, a prompt returns [`PromptUserError::AbsentUser`] immediately and sends
74/// nothing. It never applies a default. [`interactivity`] reports whether a user is present.
75///
76/// Cancellation stops the wait, and the prompt returns [`PromptUserError::CancelledPrompt`].
77///
78/// `Prompt` is cheaply clonable. Cloning produces another handle to the same event channel and
79/// the same cancellation token.
80///
81/// # Examples
82///
83/// ```no_run
84/// use clawless_core::prompt::{Confirmation, Prompt};
85///
86/// # async fn example(prompt: Prompt) -> Result<(), Box<dyn std::error::Error>> {
87/// let answer = prompt.confirm("Delete the branch?").await?;
88///
89/// let delete = match answer {
90///     Confirmation::Yes => true,
91///     Confirmation::No => false,
92/// };
93/// # Ok(())
94/// # }
95/// ```
96///
97/// [`Context::prompt`]: crate::context::Context::prompt
98/// [`interactivity`]: Prompt::interactivity
99#[derive(Clone, Debug, Builder, CopyGetters)]
100pub struct Prompt {
101    /// The channel that carries a question to the presenter
102    output: Output,
103
104    /// The token that stops the wait for an answer
105    #[builder(default)]
106    cancellation: Cancellation,
107
108    /// Whether a user is present who can answer
109    #[builder(default)]
110    #[getset(get_copy = "pub")]
111    interactivity: Interactivity,
112}
113
114impl Prompt {
115    /// Asks the user to confirm with yes or no
116    ///
117    /// The question is a [`Confirm`], or a text that converts into one without a default.
118    ///
119    /// # Errors
120    ///
121    /// Returns [`PromptUserError::AbsentUser`] if no user is present, and
122    /// [`PromptUserError::CancelledPrompt`] if cancellation stopped the wait. Returns
123    /// [`PromptUserError::UndeliverablePrompt`] if the presenter stopped listening, and
124    /// [`PromptUserError::UnansweredPrompt`] if the prompt came back without an answer.
125    ///
126    /// # Examples
127    ///
128    /// ```no_run
129    /// use clawless_core::prompt::{Confirm, Confirmation, Prompt};
130    ///
131    /// # async fn example(prompt: Prompt) -> Result<(), Box<dyn std::error::Error>> {
132    /// let question = Confirm::new("Publish the release?").with_default(Confirmation::No);
133    ///
134    /// let answer = prompt.confirm(question).await?;
135    /// # Ok(())
136    /// # }
137    /// ```
138    pub async fn confirm(
139        &self,
140        question: impl Into<Confirm>,
141    ) -> Result<Confirmation, PromptUserError> {
142        let question = question.into();
143        let text = question.question().clone();
144        let (request, pending) = PromptRequest::confirm(question);
145
146        self.ask(text, request, pending).await
147    }
148
149    /// Asks the user to select one of several options
150    ///
151    /// The user sees the `Display` text of each option, in the order given. The call returns the
152    /// option that the user selected.
153    ///
154    /// # Errors
155    ///
156    /// Returns [`PromptUserError::MissingOptions`] if there is no option to select from, whether
157    /// or not a user is present. Returns [`PromptUserError::AbsentUser`] if no user is present,
158    /// and [`PromptUserError::CancelledPrompt`] if cancellation stopped the wait. Returns
159    /// [`PromptUserError::UndeliverablePrompt`] if the presenter stopped listening, and
160    /// [`PromptUserError::UnansweredPrompt`] if the prompt came back without an answer. Returns
161    /// [`PromptUserError::UnknownOption`] if the answer names an option that does not exist.
162    ///
163    /// # Examples
164    ///
165    /// ```no_run
166    /// use clawless_core::prompt::Prompt;
167    ///
168    /// # async fn example(prompt: Prompt) -> Result<(), Box<dyn std::error::Error>> {
169    /// let port = prompt.select("Port to listen on", [80, 443, 8080]).await?;
170    /// # Ok(())
171    /// # }
172    /// ```
173    pub async fn select<T: Display>(
174        &self,
175        question: impl Into<String>,
176        options: impl IntoIterator<Item = T>,
177    ) -> Result<T, PromptUserError> {
178        let question = question.into();
179        let mut options = options.into_iter().collect::<Vec<_>>();
180        let count = options.len();
181
182        if options.is_empty() {
183            return Err(PromptUserError::MissingOptions { question });
184        }
185
186        let select = Select::new(question.clone(), options.iter().map(ToString::to_string));
187        let (request, pending) = PromptRequest::select(select);
188        let index = self.ask(question.clone(), request, pending).await?;
189
190        if index < count {
191            Ok(options.swap_remove(index))
192        } else {
193            Err(PromptUserError::UnknownOption {
194                question,
195                index,
196                count,
197            })
198        }
199    }
200
201    /// Asks the user for one line of text
202    ///
203    /// The question is a [`Text`], or a text that converts into one. The answer is the line as
204    /// the user typed it, without the characters that end the line. The answer can be empty.
205    ///
206    /// # Errors
207    ///
208    /// Returns [`PromptUserError::AbsentUser`] if no user is present, and
209    /// [`PromptUserError::CancelledPrompt`] if cancellation stopped the wait. Returns
210    /// [`PromptUserError::UndeliverablePrompt`] if the presenter stopped listening, and
211    /// [`PromptUserError::UnansweredPrompt`] if the prompt came back without an answer.
212    ///
213    /// # Examples
214    ///
215    /// ```no_run
216    /// use clawless_core::prompt::Prompt;
217    ///
218    /// # async fn example(prompt: Prompt) -> Result<(), Box<dyn std::error::Error>> {
219    /// let title = prompt.text("Title of the change").await?;
220    /// # Ok(())
221    /// # }
222    /// ```
223    pub async fn text(&self, question: impl Into<Text>) -> Result<String, PromptUserError> {
224        let question = question.into();
225        let text = question.question().clone();
226        let (request, pending) = PromptRequest::text(question);
227
228        self.ask(text, request, pending).await
229    }
230
231    /// Sends a request to the presenter and waits for the answer to it
232    ///
233    /// The send and the wait both race the cancellation token, and cancellation wins a tie. A
234    /// token that is already cancelled therefore stops the prompt before it sends a request. The
235    /// send races too, because a full channel makes the sender wait.
236    ///
237    /// Cancellation drops the pending answer, which tells the presenter that the command has
238    /// stopped waiting.
239    ///
240    /// # Errors
241    ///
242    /// Returns [`PromptUserError::AbsentUser`] if no user is present, and
243    /// [`PromptUserError::CancelledPrompt`] if cancellation stopped the send or the wait. Returns
244    /// [`PromptUserError::UndeliverablePrompt`] if the presenter stopped listening, and
245    /// [`PromptUserError::UnansweredPrompt`] if the prompt came back without an answer.
246    async fn ask<T>(
247        &self,
248        question: String,
249        request: PromptRequest,
250        pending: PendingAnswer<T>,
251    ) -> Result<T, PromptUserError> {
252        match self.interactivity {
253            Interactivity::Interactive => {}
254            Interactivity::NonInteractive => return Err(PromptUserError::AbsentUser { question }),
255        }
256
257        let sent = tokio::select! {
258            biased;
259
260            () = self.cancellation.cancelled() => {
261                return Err(PromptUserError::CancelledPrompt { question });
262            }
263            sent = self.output.prompt(request) => sent,
264        };
265        if let Err(source) = sent {
266            return Err(PromptUserError::UndeliverablePrompt { question, source });
267        }
268
269        let answer = tokio::select! {
270            biased;
271
272            () = self.cancellation.cancelled() => {
273                return Err(PromptUserError::CancelledPrompt { question });
274            }
275            answer = pending.wait() => answer,
276        };
277
278        answer.map_err(|source| PromptUserError::UnansweredPrompt { question, source })
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    // An assertion in a test panics by design. A `# Panics` section on every test
285    // would repeat that and give the reader no information.
286    #![allow(clippy::missing_panics_doc)]
287
288    use super::*;
289    use crate::event::{Event, EventReceiver, event_channel};
290
291    /// Returns a prompt whose user is present, and the receiver that stands for the presenter
292    fn interactive() -> (Prompt, EventReceiver) {
293        let (sender, receiver) = event_channel();
294        let prompt = Prompt::builder()
295            .output(Output::new(sender))
296            .interactivity(Interactivity::Interactive)
297            .build();
298
299        (prompt, receiver)
300    }
301
302    /// Returns the next prompt request that the receiver holds
303    async fn next_request(receiver: &mut EventReceiver) -> Option<PromptRequest> {
304        while let Some(event) = receiver.recv().await {
305            match event {
306                Event::Prompt(request) => return Some(*request),
307                Event::Message(_) | Event::Detail(_) | Event::Artifact(_) | Event::Process(_) => {}
308            }
309        }
310
311        None
312    }
313
314    /// Returns whether the error reports a prompt without a user
315    fn is_absent_user(error: &PromptUserError) -> bool {
316        match error {
317            PromptUserError::AbsentUser { .. } => true,
318            PromptUserError::CancelledPrompt { .. } => false,
319            PromptUserError::UnansweredPrompt { .. } => false,
320            PromptUserError::UndeliverablePrompt { .. } => false,
321            PromptUserError::MissingOptions { .. } => false,
322            PromptUserError::UnknownOption { .. } => false,
323        }
324    }
325
326    /// Returns whether the error reports a prompt that cancellation stopped
327    fn is_cancelled(error: &PromptUserError) -> bool {
328        match error {
329            PromptUserError::CancelledPrompt { .. } => true,
330            PromptUserError::AbsentUser { .. } => false,
331            PromptUserError::UnansweredPrompt { .. } => false,
332            PromptUserError::UndeliverablePrompt { .. } => false,
333            PromptUserError::MissingOptions { .. } => false,
334            PromptUserError::UnknownOption { .. } => false,
335        }
336    }
337
338    /// Returns whether the error reports a prompt that the presenter dropped
339    fn is_dropped(error: &PromptUserError) -> bool {
340        match error {
341            PromptUserError::UnansweredPrompt { source, .. } => match source {
342                AnswerPromptError::DroppedRequest => true,
343                AnswerPromptError::ClosedInput => false,
344                AnswerPromptError::UnscriptedPrompt => false,
345                AnswerPromptError::UnusableTerminal { .. } => false,
346            },
347            PromptUserError::AbsentUser { .. } => false,
348            PromptUserError::CancelledPrompt { .. } => false,
349            PromptUserError::UndeliverablePrompt { .. } => false,
350            PromptUserError::MissingOptions { .. } => false,
351            PromptUserError::UnknownOption { .. } => false,
352        }
353    }
354
355    /// Returns whether the error reports a prompt that could not reach the presenter
356    fn is_undeliverable(error: &PromptUserError) -> bool {
357        match error {
358            PromptUserError::UndeliverablePrompt { .. } => true,
359            PromptUserError::AbsentUser { .. } => false,
360            PromptUserError::CancelledPrompt { .. } => false,
361            PromptUserError::UnansweredPrompt { .. } => false,
362            PromptUserError::MissingOptions { .. } => false,
363            PromptUserError::UnknownOption { .. } => false,
364        }
365    }
366
367    #[tokio::test]
368    async fn confirm_sends_the_question_with_its_default() {
369        let (prompt, mut receiver) = interactive();
370        let question = Confirm::new("Release?").with_default(Confirmation::No);
371        let asked = question.clone();
372        let command = tokio::spawn(async move { prompt.confirm(asked).await });
373
374        let request = next_request(&mut receiver).await;
375        command.abort();
376
377        assert_eq!(
378            request.and_then(|request| match request {
379                PromptRequest::Confirm { question, .. } => Some(question),
380                PromptRequest::Select { .. } | PromptRequest::Text { .. } => None,
381            }),
382            Some(question)
383        );
384    }
385
386    #[tokio::test]
387    async fn confirm_with_a_cancelled_token_returns_an_error() {
388        let cancellation = Cancellation::new();
389        let (sender, _receiver) = event_channel();
390        let prompt = Prompt::builder()
391            .output(Output::new(sender))
392            .cancellation(cancellation.clone())
393            .interactivity(Interactivity::Interactive)
394            .build();
395        cancellation.cancel();
396
397        let error = prompt.confirm("Release?").await.expect_err("should fail");
398
399        assert!(is_cancelled(&error));
400    }
401
402    #[tokio::test]
403    async fn confirm_with_a_cancelled_token_sends_no_request() {
404        let cancellation = Cancellation::new();
405        let (sender, mut receiver) = event_channel();
406        let prompt = Prompt::builder()
407            .output(Output::new(sender))
408            .cancellation(cancellation.clone())
409            .interactivity(Interactivity::Interactive)
410            .build();
411        cancellation.cancel();
412
413        drop(prompt.confirm("Release?").await);
414        drop(prompt);
415
416        assert!(next_request(&mut receiver).await.is_none());
417    }
418
419    #[tokio::test]
420    async fn confirm_with_a_closed_channel_returns_an_error() {
421        let (prompt, receiver) = interactive();
422        drop(receiver);
423
424        let error = prompt.confirm("Release?").await.expect_err("should fail");
425
426        assert!(is_undeliverable(&error));
427    }
428
429    #[tokio::test]
430    async fn confirm_with_a_dropped_request_returns_an_error() {
431        let (prompt, mut receiver) = interactive();
432        let presenter = tokio::spawn(async move { drop(next_request(&mut receiver).await) });
433
434        let error = prompt.confirm("Release?").await.expect_err("should fail");
435        presenter.await.expect("should join");
436
437        assert!(is_dropped(&error));
438    }
439
440    #[tokio::test]
441    async fn confirm_with_an_answer_returns_it() {
442        let (prompt, mut receiver) = interactive();
443        let presenter = tokio::spawn(async move {
444            match next_request(&mut receiver).await {
445                Some(PromptRequest::Confirm { reply, .. }) => reply.answer(Confirmation::Yes),
446                Some(PromptRequest::Select { .. } | PromptRequest::Text { .. }) | None => {}
447            }
448        });
449
450        let answer = prompt.confirm("Release?").await.expect("should answer");
451        presenter.await.expect("should join");
452
453        assert_eq!(answer, Confirmation::Yes);
454    }
455
456    #[tokio::test]
457    async fn confirm_with_cancellation_while_it_waits_abandons_the_reply() {
458        let cancellation = Cancellation::new();
459        let (sender, mut receiver) = event_channel();
460        let prompt = Prompt::builder()
461            .output(Output::new(sender))
462            .cancellation(cancellation.clone())
463            .interactivity(Interactivity::Interactive)
464            .build();
465        let command = tokio::spawn(async move { prompt.confirm("Release?").await });
466        let request = next_request(&mut receiver).await;
467        cancellation.cancel();
468
469        drop(command.await.expect("should join"));
470
471        assert_eq!(
472            request.map(|request| match request {
473                PromptRequest::Confirm { reply, .. } => reply.is_abandoned(),
474                PromptRequest::Select { reply, .. } => reply.is_abandoned(),
475                PromptRequest::Text { reply, .. } => reply.is_abandoned(),
476            }),
477            Some(true)
478        );
479    }
480
481    #[tokio::test]
482    async fn confirm_with_cancellation_while_it_waits_returns_an_error() {
483        let cancellation = Cancellation::new();
484        let (sender, mut receiver) = event_channel();
485        let prompt = Prompt::builder()
486            .output(Output::new(sender))
487            .cancellation(cancellation.clone())
488            .interactivity(Interactivity::Interactive)
489            .build();
490        let command = tokio::spawn(async move { prompt.confirm("Release?").await });
491        let _request = next_request(&mut receiver).await;
492        cancellation.cancel();
493
494        let error = command
495            .await
496            .expect("should join")
497            .expect_err("should fail");
498
499        assert!(is_cancelled(&error));
500    }
501
502    #[tokio::test]
503    async fn confirm_without_a_user_returns_an_error() {
504        let (sender, _receiver) = event_channel();
505        let prompt = Prompt::builder().output(Output::new(sender)).build();
506
507        let error = prompt.confirm("Release?").await.expect_err("should fail");
508
509        assert!(is_absent_user(&error));
510    }
511
512    #[tokio::test]
513    async fn confirm_without_a_user_sends_no_request() {
514        let (sender, mut receiver) = event_channel();
515        let prompt = Prompt::builder().output(Output::new(sender)).build();
516
517        drop(prompt.confirm("Release?").await);
518        drop(prompt);
519
520        assert!(next_request(&mut receiver).await.is_none());
521    }
522
523    #[test]
524    fn interactivity_with_defaults_is_non_interactive() {
525        let (sender, _receiver) = event_channel();
526
527        let prompt = Prompt::builder().output(Output::new(sender)).build();
528
529        assert_eq!(prompt.interactivity(), Interactivity::NonInteractive);
530    }
531
532    #[tokio::test]
533    async fn select_sends_the_options_as_their_display_text() {
534        let (prompt, mut receiver) = interactive();
535        let command = tokio::spawn(async move { prompt.select("Port", [80, 443]).await });
536
537        let request = next_request(&mut receiver).await;
538        command.abort();
539
540        assert_eq!(
541            request.and_then(|request| match request {
542                PromptRequest::Select { question, .. } => Some(question),
543                PromptRequest::Confirm { .. } | PromptRequest::Text { .. } => None,
544            }),
545            Some(Select::new("Port", ["80", "443"]))
546        );
547    }
548
549    #[tokio::test]
550    async fn select_with_an_answer_returns_the_option_at_that_index() {
551        let (prompt, mut receiver) = interactive();
552        let presenter = tokio::spawn(async move {
553            match next_request(&mut receiver).await {
554                Some(PromptRequest::Select { reply, .. }) => reply.answer(1),
555                Some(PromptRequest::Confirm { .. } | PromptRequest::Text { .. }) | None => {}
556            }
557        });
558
559        let answer = prompt
560            .select("Port", [80, 443])
561            .await
562            .expect("should answer");
563        presenter.await.expect("should join");
564
565        assert_eq!(answer, 443);
566    }
567
568    #[tokio::test]
569    async fn select_with_an_index_beyond_the_options_returns_an_error() {
570        let (prompt, mut receiver) = interactive();
571        let presenter = tokio::spawn(async move {
572            match next_request(&mut receiver).await {
573                Some(PromptRequest::Select { reply, .. }) => reply.answer(2),
574                Some(PromptRequest::Confirm { .. } | PromptRequest::Text { .. }) | None => {}
575            }
576        });
577
578        let error = prompt
579            .select("Port", [80, 443])
580            .await
581            .expect_err("should fail");
582        presenter.await.expect("should join");
583
584        assert_eq!(
585            error.to_string(),
586            "the answer to the prompt `Port` names option 2, and the prompt has 2 options"
587        );
588    }
589
590    #[tokio::test]
591    async fn select_without_a_user_returns_an_error() {
592        let (sender, _receiver) = event_channel();
593        let prompt = Prompt::builder().output(Output::new(sender)).build();
594
595        let error = prompt
596            .select("Port", [80, 443])
597            .await
598            .expect_err("should fail");
599
600        assert!(is_absent_user(&error));
601    }
602
603    #[tokio::test]
604    async fn select_without_options_returns_an_error() {
605        let (prompt, _receiver) = interactive();
606
607        let error = prompt
608            .select("Port", Vec::<u16>::new())
609            .await
610            .expect_err("should fail");
611
612        assert_eq!(
613            error.to_string(),
614            "the prompt `Port` has no options to select from"
615        );
616    }
617
618    #[tokio::test]
619    async fn text_with_an_answer_returns_it() {
620        let (prompt, mut receiver) = interactive();
621        let presenter = tokio::spawn(async move {
622            match next_request(&mut receiver).await {
623                Some(PromptRequest::Text { reply, .. }) => reply.answer("Fix the race".to_owned()),
624                Some(PromptRequest::Confirm { .. } | PromptRequest::Select { .. }) | None => {}
625            }
626        });
627
628        let answer = prompt.text("Title").await.expect("should answer");
629        presenter.await.expect("should join");
630
631        assert_eq!(answer, "Fix the race");
632    }
633
634    #[tokio::test]
635    async fn text_without_a_user_returns_an_error() {
636        let (sender, _receiver) = event_channel();
637        let prompt = Prompt::builder().output(Output::new(sender)).build();
638
639        let error = prompt.text("Title").await.expect_err("should fail");
640
641        assert!(is_absent_user(&error));
642    }
643
644    #[test]
645    fn trait_send() {
646        fn assert_send<T: Send>() {}
647        assert_send::<Prompt>();
648    }
649
650    #[test]
651    fn trait_sync() {
652        fn assert_sync<T: Sync>() {}
653        assert_sync::<Prompt>();
654    }
655
656    #[test]
657    fn trait_unpin() {
658        fn assert_unpin<T: Unpin>() {}
659        assert_unpin::<Prompt>();
660    }
661}