Skip to main content

scope_cli/cli/
prompt.rs

1//! # Prompt input
2//!
3//! Reads the answer to an interactive prompt (the setup wizard, a token
4//! selection) from stdin, so that a cancel can stop the read.
5//!
6//! In the TUI, a suspend-mode command reads its prompts on the plain
7//! terminal. Ctrl-C there must cancel the command and return to the TUI. A
8//! blocking `read_line` cannot be stopped, and a read on another thread
9//! would stay behind and take the TUI's next keys. So the reader waits for
10//! input in short steps and checks `CANCEL` between them. It reads one
11//! byte at a time, so it never takes bytes after the newline.
12//!
13//! Outside the TUI nothing sets `CANCEL`, and the reader acts as
14//! `read_line` does.
15//!
16//! Every prompt goes through `ask` (or `pick`, for a numbered list).
17//! It pauses the progress bars, so a spinner never draws over a question.
18//! A question, a list to pick from and the text around a prompt are not
19//! data: they go to the diagnostic channel of the [`Output`]. So piped
20//! data stays clean, and a TUI run keeps only data for Ctrl-Y and `save`.
21
22use crate::cli::output::Output;
23use scope::error::ScopeError;
24use std::io::{self, BufRead};
25use std::sync::atomic::{AtomicBool, Ordering};
26
27/// Set when a signal cancels the suspend-mode command. A prompt read that
28/// sees it stops with [`io::ErrorKind::Interrupted`]. The TUI clears it
29/// when a suspend-mode command starts.
30pub(crate) static CANCEL: AtomicBool = AtomicBool::new(false);
31
32/// How long the reader waits for input before it checks the cancel flag
33/// again. It is the longest delay between a cancel and the read's return.
34#[cfg(unix)]
35const POLL_INTERVAL_MS: libc::c_int = 100;
36
37/// The error a cancelled read returns.
38fn cancelled() -> io::Error {
39    io::Error::new(io::ErrorKind::Interrupted, "cancelled")
40}
41
42/// Writes `question` to the diagnostic channel of `out` and reads one
43/// line of answer from `reader`. Every progress bar is paused from before
44/// the question is written until the answer is read, so no bar draws over
45/// it. The question may span lines. The channel is stderr on the CLI,
46/// which Rust does not buffer, so the question shows before the read.
47///
48/// # Errors
49///
50/// Returns the write or read error.
51pub(crate) fn ask(reader: &mut impl BufRead, out: &Output, question: &str) -> io::Result<String> {
52    crate::cli::progress::paused(|| {
53        crate::err!(out, "{question}")?;
54        let mut answer = String::new();
55        reader.read_line(&mut answer)?;
56        Ok(answer)
57    })
58}
59
60/// `ask` on stdin (cancellable in the TUI). The `scope` binary's own
61/// prompts use it.
62///
63/// # Errors
64///
65/// Returns the write or read error.
66pub fn ask_stdin(out: &Output, question: &str) -> io::Result<String> {
67    ask(&mut stdin(), out, question)
68}
69
70/// Shows `list` (it ends with a newline), asks `Select <what> (1-<count>): `
71/// and returns the zero-based index of the answer.
72///
73/// # Errors
74///
75/// The write or read error; `Api` when the answer is not a number
76/// from 1 to `count`.
77pub(crate) fn pick(
78    reader: &mut impl BufRead,
79    out: &Output,
80    list: &str,
81    what: &str,
82    count: usize,
83) -> scope::error::Result<usize> {
84    let question = format!("{list}\nSelect {what} (1-{count}): ");
85    let answer = ask(reader, out, &question)?;
86    let n: usize = answer
87        .trim()
88        .parse()
89        .map_err(|_| ScopeError::Api("Invalid selection".to_string()))?;
90    if !(1..=count).contains(&n) {
91        return Err(ScopeError::Api(format!(
92            "Selection must be between 1 and {count}"
93        )));
94    }
95    Ok(n - 1)
96}
97
98/// The input for a prompt: stdin, cancellable with [`CANCEL`].
99#[cfg(unix)]
100pub(crate) fn stdin() -> PromptInput<'static> {
101    PromptInput::new(libc::STDIN_FILENO, &CANCEL)
102}
103
104/// The input for a prompt: stdin. There is no cancel off unix.
105#[cfg(not(unix))]
106pub(crate) fn stdin() -> io::StdinLock<'static> {
107    io::stdin().lock()
108}
109
110/// A line reader over a file descriptor that stops when `cancel` is set.
111#[cfg(unix)]
112pub(crate) struct PromptInput<'a> {
113    fd: std::os::fd::RawFd,
114    cancel: &'a AtomicBool,
115    /// The byte `fill_buf` read and `consume` did not take yet.
116    byte: [u8; 1],
117    has_byte: bool,
118}
119
120#[cfg(unix)]
121impl<'a> PromptInput<'a> {
122    pub(crate) fn new(fd: std::os::fd::RawFd, cancel: &'a AtomicBool) -> Self {
123        Self {
124            fd,
125            cancel,
126            byte: [0],
127            has_byte: false,
128        }
129    }
130
131    /// Reads one byte. `None` at end of input. Waits in steps of
132    /// [`POLL_INTERVAL_MS`] and stops with `Interrupted` when `cancel` is
133    /// set.
134    fn read_byte(&mut self) -> io::Result<Option<u8>> {
135        if std::mem::take(&mut self.has_byte) {
136            return Ok(Some(self.byte[0]));
137        }
138        loop {
139            if self.cancel.load(Ordering::SeqCst) {
140                return Err(cancelled());
141            }
142            let mut pfd = libc::pollfd {
143                fd: self.fd,
144                events: libc::POLLIN,
145                revents: 0,
146            };
147            // SAFETY: one valid pollfd, and poll writes only its revents.
148            let ready = unsafe { libc::poll(&mut pfd, 1, POLL_INTERVAL_MS) };
149            if ready < 0 {
150                let e = io::Error::last_os_error();
151                if e.kind() == io::ErrorKind::Interrupted {
152                    continue;
153                }
154                return Err(e);
155            }
156            if ready == 0 {
157                continue;
158            }
159            // Input, end of input or an error: the read tells which.
160            let mut b = 0u8;
161            // SAFETY: reads at most one byte into a valid one-byte buffer.
162            let n = unsafe { libc::read(self.fd, (&mut b as *mut u8).cast(), 1) };
163            match n {
164                1 => return Ok(Some(b)),
165                0 => return Ok(None),
166                _ => {
167                    let e = io::Error::last_os_error();
168                    if matches!(
169                        e.kind(),
170                        io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock
171                    ) {
172                        continue;
173                    }
174                    return Err(e);
175                }
176            }
177        }
178    }
179}
180
181#[cfg(unix)]
182impl io::Read for PromptInput<'_> {
183    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
184        let Some(first) = buf.first_mut() else {
185            return Ok(0);
186        };
187        Ok(match self.read_byte()? {
188            Some(b) => {
189                *first = b;
190                1
191            }
192            None => 0,
193        })
194    }
195}
196
197#[cfg(unix)]
198impl BufRead for PromptInput<'_> {
199    /// The prompts use `read_line`; this exists because `BufRead` needs
200    /// it. A cancel returns `Other`, not `Interrupted`: the std helpers
201    /// over `fill_buf` retry on `Interrupted`, so they would never stop.
202    fn fill_buf(&mut self) -> io::Result<&[u8]> {
203        if !self.has_byte {
204            match self.read_byte() {
205                Ok(Some(b)) => {
206                    self.byte[0] = b;
207                    self.has_byte = true;
208                }
209                Ok(None) => {}
210                Err(e) if e.kind() == io::ErrorKind::Interrupted => {
211                    return Err(io::Error::other(e));
212                }
213                Err(e) => return Err(e),
214            }
215        }
216        Ok(&self.byte[..usize::from(self.has_byte)])
217    }
218
219    fn consume(&mut self, amt: usize) {
220        if amt > 0 {
221            self.has_byte = false;
222        }
223    }
224
225    /// Reads up to and including the newline, as `BufRead::read_line`
226    /// does, and appends it to `buf`. Returns the number of bytes read; 0
227    /// at end of input.
228    ///
229    /// # Errors
230    ///
231    /// `Interrupted` when the cancel flag is set; `InvalidData` when the
232    /// line is not UTF-8 (`buf` is then unchanged); or the read error.
233    fn read_line(&mut self, buf: &mut String) -> io::Result<usize> {
234        let mut line = Vec::new();
235        while let Some(b) = self.read_byte()? {
236            line.push(b);
237            if b == b'\n' {
238                break;
239            }
240        }
241        let text = String::from_utf8(line).map_err(|_| {
242            io::Error::new(
243                io::ErrorKind::InvalidData,
244                "stream did not contain valid UTF-8",
245            )
246        })?;
247        buf.push_str(&text);
248        Ok(text.len())
249    }
250}
251
252#[cfg(test)]
253mod ask_tests {
254    use super::*;
255    use crate::cli::progress::testing::{REGISTRY_LOCK, Screen, drawing_spinner};
256    use std::io::Write;
257    use std::sync::Arc;
258    use std::sync::atomic::AtomicUsize;
259    use std::time::Duration;
260
261    /// An answer that takes a while to type. It notes how many draws the
262    /// screen had when the answer came.
263    struct SlowAnswer<'a> {
264        screen: &'a Screen,
265        draws_at_answer: Option<usize>,
266    }
267
268    impl io::Read for SlowAnswer<'_> {
269        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
270            if self.draws_at_answer.is_some() {
271                return Ok(0);
272            }
273            std::thread::sleep(Duration::from_millis(40));
274            self.draws_at_answer = Some(self.screen.ops().len());
275            buf[..2].copy_from_slice(b"y\n");
276            Ok(2)
277        }
278    }
279
280    /// A diagnostic channel that notes how many draws the screen had when
281    /// the question was written.
282    struct Question {
283        screen: Screen,
284        draws_at_question: Arc<AtomicUsize>,
285    }
286
287    impl Write for Question {
288        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
289            self.draws_at_question
290                .store(self.screen.ops().len(), Ordering::SeqCst);
291            Ok(buf.len())
292        }
293        fn flush(&mut self) -> io::Result<()> {
294            Ok(())
295        }
296    }
297
298    #[test]
299    fn test_ask_keeps_a_ticking_spinner_off_the_question() {
300        // crawl by symbol: the "Searching..." spinner drew over "Save this
301        // token for future use? [y/N]" until the answer came.
302        let _lock = REGISTRY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
303        let screen = Screen::default();
304        let sp = drawing_spinner("Searching...", &Output::capture().0, &screen);
305        std::thread::sleep(Duration::from_millis(30));
306        let mut answer = SlowAnswer {
307            screen: &screen,
308            draws_at_answer: None,
309        };
310        let mut reader = io::BufReader::new(&mut answer);
311        let draws_at_question = Arc::new(AtomicUsize::new(0));
312        let out = Output::from_writers(
313            Vec::new(),
314            Question {
315                screen: screen.clone(),
316                draws_at_question: draws_at_question.clone(),
317            },
318        );
319        let got = ask(&mut reader, &out, "Save? [y/N]: ").unwrap();
320        drop(reader);
321        assert_eq!(got, "y\n");
322        assert_eq!(
323            answer.draws_at_answer,
324            Some(draws_at_question.load(Ordering::SeqCst)),
325            "the spinner drew between the question and the answer"
326        );
327        sp.finish_and_clear();
328    }
329
330    #[test]
331    fn test_a_prompt_never_reaches_the_data_channel() {
332        // `scope crawl USDC > out.json`: the question must show on the
333        // terminal and stay out of the file.
334        let (out, cap) = Output::capture();
335        let got = ask(&mut io::Cursor::new(b"y\n".to_vec()), &out, "Save? [y/N]: ").unwrap();
336        assert_eq!(got, "y\n");
337        assert_eq!(cap.out(), "");
338        assert_eq!(cap.err(), "Save? [y/N]: ");
339    }
340
341    #[test]
342    fn test_ask_returns_the_write_error() {
343        let out = Output::from_writers(Vec::new(), crate::cli::output::tests::ClosedPipe);
344        let e = ask(&mut io::Cursor::new(b"y\n".to_vec()), &out, "q? ").unwrap_err();
345        assert_eq!(e.kind(), io::ErrorKind::BrokenPipe);
346    }
347
348    #[test]
349    fn test_pick_returns_the_index_and_rejects_bad_answers() {
350        let pick_with = |answer: &str| {
351            let (out, cap) = Output::capture();
352            let got = pick(
353                &mut io::Cursor::new(answer.as_bytes().to_vec()),
354                &out,
355                "1 a\n2 b\n",
356                "token",
357                2,
358            );
359            assert_eq!(cap.err(), "1 a\n2 b\n\nSelect token (1-2): ");
360            assert_eq!(cap.out(), "", "the list is not data");
361            got.map_err(|e| e.to_string())
362        };
363        assert_eq!(pick_with("2\n"), Ok(1));
364        assert!(pick_with("x\n").unwrap_err().contains("Invalid selection"));
365        assert!(
366            pick_with("3\n")
367                .unwrap_err()
368                .contains("Selection must be between 1 and 2")
369        );
370        assert!(
371            pick_with("0\n")
372                .unwrap_err()
373                .contains("Selection must be between 1 and 2")
374        );
375    }
376}
377
378#[cfg(all(test, unix))]
379mod tests {
380    use super::*;
381    use std::io::Read;
382    use std::os::fd::RawFd;
383    use std::time::{Duration, Instant};
384
385    /// A pipe: (read end, write end). Closed on drop.
386    struct Pipe(RawFd, RawFd);
387
388    impl Pipe {
389        fn new() -> Self {
390            let mut fds = [0; 2];
391            // SAFETY: pipe writes two fds into a valid two-element array.
392            assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
393            Pipe(fds[0], fds[1])
394        }
395
396        fn write(&self, bytes: &[u8]) {
397            // SAFETY: writes from a valid buffer of the given length.
398            let n = unsafe { libc::write(self.1, bytes.as_ptr().cast(), bytes.len()) };
399            assert_eq!(n, bytes.len() as isize);
400        }
401
402        fn close_write(&mut self) {
403            // SAFETY: closes the write end once; -1 marks it closed.
404            unsafe { libc::close(self.1) };
405            self.1 = -1;
406        }
407
408        /// What is left in the pipe, read without the prompt reader.
409        fn rest(&mut self) -> Vec<u8> {
410            self.close_write();
411            let mut out = Vec::new();
412            let mut b = [0u8; 64];
413            loop {
414                // SAFETY: reads into a valid buffer of the given length.
415                let n = unsafe { libc::read(self.0, b.as_mut_ptr().cast(), b.len()) };
416                if n <= 0 {
417                    return out;
418                }
419                out.extend_from_slice(&b[..n as usize]);
420            }
421        }
422    }
423
424    impl Drop for Pipe {
425        fn drop(&mut self) {
426            for fd in [self.0, self.1] {
427                if fd >= 0 {
428                    // SAFETY: each open fd is closed once.
429                    unsafe { libc::close(fd) };
430                }
431            }
432        }
433    }
434
435    #[test]
436    fn test_reads_one_line_and_leaves_the_rest() {
437        // The next prompt reads the next line: the reader must not take
438        // bytes after the newline.
439        let flag = AtomicBool::new(false);
440        let mut pipe = Pipe::new();
441        pipe.write(b"yes\nnext\n");
442        let mut input = PromptInput::new(pipe.0, &flag);
443        let mut line = String::from("old:");
444        assert_eq!(input.read_line(&mut line).unwrap(), 4);
445        assert_eq!(line, "old:yes\n", "appends, as read_line does");
446        assert_eq!(pipe.rest(), b"next\n");
447    }
448
449    #[test]
450    fn test_end_of_input_returns_the_partial_line_then_zero() {
451        // A closed stdin must end the prompt as read_line does, not hang.
452        let flag = AtomicBool::new(false);
453        let mut pipe = Pipe::new();
454        pipe.write(b"abc");
455        pipe.close_write();
456        let mut input = PromptInput::new(pipe.0, &flag);
457        let mut line = String::new();
458        assert_eq!(input.read_line(&mut line).unwrap(), 3);
459        assert_eq!(line, "abc");
460        assert_eq!(input.read_line(&mut line).unwrap(), 0);
461    }
462
463    #[test]
464    fn test_invalid_utf8_is_an_error_and_leaves_the_buffer() {
465        let flag = AtomicBool::new(false);
466        let pipe = Pipe::new();
467        pipe.write(b"\xff\n");
468        let mut input = PromptInput::new(pipe.0, &flag);
469        let mut line = String::from("kept");
470        let e = input.read_line(&mut line).unwrap_err();
471        assert_eq!(e.kind(), io::ErrorKind::InvalidData);
472        assert_eq!(line, "kept");
473    }
474
475    #[test]
476    fn test_cancel_stops_a_waiting_read_and_takes_no_later_input() {
477        // Ctrl-C at a prompt in the TUI: the read must stop soon, and no
478        // reader may stay behind to take the TUI's next keys.
479        let flag = AtomicBool::new(false);
480        let mut pipe = Pipe::new();
481        let started = Instant::now();
482        let e = std::thread::scope(|s| {
483            s.spawn(|| {
484                std::thread::sleep(Duration::from_millis(150));
485                flag.store(true, Ordering::SeqCst);
486            });
487            let mut line = String::new();
488            PromptInput::new(pipe.0, &flag)
489                .read_line(&mut line)
490                .unwrap_err()
491        });
492        assert_eq!(e.kind(), io::ErrorKind::Interrupted);
493        assert!(started.elapsed() < Duration::from_secs(1), "stopped late");
494        pipe.write(b"k");
495        std::thread::sleep(Duration::from_millis(2 * POLL_INTERVAL_MS as u64));
496        assert_eq!(pipe.rest(), b"k", "the key after the cancel is still there");
497    }
498
499    #[test]
500    fn test_a_set_flag_stops_the_read_before_it_waits() {
501        let flag = AtomicBool::new(true);
502        let mut pipe = Pipe::new();
503        pipe.write(b"y\n");
504        let mut line = String::new();
505        let e = PromptInput::new(pipe.0, &flag)
506            .read_line(&mut line)
507            .unwrap_err();
508        assert_eq!(e.kind(), io::ErrorKind::Interrupted);
509        assert_eq!(pipe.rest(), b"y\n");
510    }
511
512    #[test]
513    fn test_read_and_fill_buf_take_one_byte_at_a_time() {
514        let flag = AtomicBool::new(false);
515        let mut pipe = Pipe::new();
516        pipe.write(b"ab");
517        let mut input = PromptInput::new(pipe.0, &flag);
518        assert_eq!(input.read(&mut []).unwrap(), 0);
519        assert_eq!(input.fill_buf().unwrap(), b"a");
520        assert_eq!(input.fill_buf().unwrap(), b"a", "not consumed yet");
521        let mut buf = [0u8; 4];
522        assert_eq!(input.read(&mut buf).unwrap(), 1, "the buffered byte");
523        assert_eq!(buf[0], b'a');
524        input.consume(0);
525        assert_eq!(input.fill_buf().unwrap(), b"b");
526        input.consume(1);
527        assert_eq!(pipe.rest(), b"");
528        assert_eq!(input.fill_buf().unwrap(), b"", "end of input");
529        assert_eq!(input.read(&mut buf).unwrap(), 0);
530    }
531
532    #[test]
533    fn test_fill_buf_cancel_is_not_interrupted() {
534        // std's read_until retries Interrupted forever; fill_buf must not
535        // return it.
536        let flag = AtomicBool::new(true);
537        let pipe = Pipe::new();
538        let mut input = PromptInput::new(pipe.0, &flag);
539        let e = input.fill_buf().unwrap_err();
540        assert_eq!(e.kind(), io::ErrorKind::Other);
541        let mut v = Vec::new();
542        assert!(input.read_until(b'\n', &mut v).is_err(), "no endless retry");
543    }
544
545    #[test]
546    fn test_a_read_error_is_returned() {
547        // A directory polls as ready, and reading it fails (EISDIR).
548        let flag = AtomicBool::new(false);
549        // SAFETY: a valid NUL-terminated path; the fd is closed below.
550        let fd = unsafe { libc::open(c"/".as_ptr(), libc::O_RDONLY) };
551        assert!(fd >= 0);
552        let mut line = String::new();
553        let r = PromptInput::new(fd, &flag).read_line(&mut line);
554        // SAFETY: closes the fd opened above, once.
555        unsafe { libc::close(fd) };
556        assert!(r.is_err());
557        assert_ne!(r.unwrap_err().kind(), io::ErrorKind::Interrupted);
558    }
559
560    #[test]
561    fn test_stdin_reads_from_the_process_cancel_flag() {
562        let input = stdin();
563        assert_eq!(input.fd, libc::STDIN_FILENO);
564        assert!(std::ptr::eq(input.cancel, &CANCEL));
565    }
566}