Skip to main content

flodl_cli/util/
prompt.rs

1//! Interactive terminal prompts.
2//!
3//! Reads from the terminal directly (`/dev/tty` on Unix, `CONIN$` on Windows)
4//! so prompts work even when stdin is piped.
5
6use std::io::{self, BufRead, Write};
7
8/// Open the terminal for reading, bypassing stdin.
9fn open_tty() -> io::Result<Box<dyn BufRead>> {
10    #[cfg(unix)]
11    {
12        use std::fs::File;
13        let f = File::open("/dev/tty")?;
14        Ok(Box::new(io::BufReader::new(f)))
15    }
16    #[cfg(windows)]
17    {
18        use std::fs::OpenOptions;
19        let f = OpenOptions::new().read(true).open("CONIN$")?;
20        Ok(Box::new(io::BufReader::new(f)))
21    }
22    #[cfg(not(any(unix, windows)))]
23    {
24        Ok(Box::new(io::BufReader::new(io::stdin())))
25    }
26}
27
28fn read_line(tty: &mut dyn BufRead) -> String {
29    let mut buf = String::new();
30    let _ = tty.read_line(&mut buf);
31    buf.trim().to_string()
32}
33
34/// Ask a yes/no question. Returns `default` on empty input.
35///
36/// Prompt should NOT include the `[Y/n]` suffix -- it is appended automatically.
37pub fn ask_yn(prompt: &str, default: bool) -> bool {
38    let suffix = if default { "[Y/n]" } else { "[y/N]" };
39    print!("{} {} ", prompt, suffix);
40    let _ = io::stdout().flush();
41
42    let mut tty = match open_tty() {
43        Ok(t) => t,
44        Err(_) => return default,
45    };
46    let answer = read_line(&mut *tty);
47
48    match answer.as_str() {
49        "" => default,
50        s if s.starts_with('y') || s.starts_with('Y') => true,
51        s if s.starts_with('n') || s.starts_with('N') => false,
52        _ => default,
53    }
54}
55
56/// Present a numbered menu and return the selected index (1-based).
57///
58/// Returns `default` (1-based) on empty or invalid input.
59pub fn ask_choice(prompt: &str, options: &[&str], default: usize) -> usize {
60    for (i, opt) in options.iter().enumerate() {
61        println!("    {}) {}", i + 1, opt);
62    }
63    println!();
64    print!("{} [{}]: ", prompt, default);
65    let _ = io::stdout().flush();
66
67    let mut tty = match open_tty() {
68        Ok(t) => t,
69        Err(_) => return default,
70    };
71    let answer = read_line(&mut *tty);
72
73    if answer.is_empty() {
74        return default;
75    }
76    match answer.parse::<usize>() {
77        Ok(n) if n >= 1 && n <= options.len() => n,
78        _ => default,
79    }
80}
81
82/// Ask for free-text input with a default value.
83///
84/// Returns `default` on empty input.
85#[allow(dead_code)]
86pub fn ask_text(prompt: &str, default: &str) -> String {
87    if default.is_empty() {
88        print!("{}: ", prompt);
89    } else {
90        print!("{} [{}]: ", prompt, default);
91    }
92    let _ = io::stdout().flush();
93
94    let mut tty = match open_tty() {
95        Ok(t) => t,
96        Err(_) => return default.to_string(),
97    };
98    let answer = read_line(&mut *tty);
99
100    if answer.is_empty() {
101        default.to_string()
102    } else {
103        answer
104    }
105}