Skip to main content

outrig_cli/init/prompt/
dialoguer.rs

1//! Rich-TUI `PromptSource` impl backed by `dialoguer`.
2//!
3//! Handles the case where stdin is a real TTY: arrow-key fuzzy selects,
4//! interactive confirms, and inline-edited string input. The trait surface
5//! is identical to `TerminalPrompt`, so callers reach this through the
6//! `auto()` factory in the parent module.
7//!
8//! Dialoguer is sync and reads `/dev/tty` directly, so every call is
9//! wrapped in `tokio::task::spawn_blocking`.
10
11use std::io;
12
13use dialoguer::{FuzzySelect, Input, Select};
14use tokio::io::AsyncWriteExt;
15use tokio::task;
16
17use crate::error::{OutrigError, Result};
18use crate::init::prompt::{self, Field, PromptSource};
19
20#[derive(Debug, Default)]
21pub struct DialoguerPrompt;
22
23impl DialoguerPrompt {
24    pub fn new() -> Self {
25        Self
26    }
27}
28
29/// Print the field's `description` to stderr so the user has the same
30/// "what is this question asking?" context the line impl exposes via `?`.
31/// Empty descriptions are skipped: surrounding `[outrig]` log lines and
32/// the prompt name itself are sometimes enough.
33async fn write_description(description: &str) -> Result<()> {
34    if description.is_empty() {
35        return Ok(());
36    }
37    let line = format!("\n  {description}\n");
38    let mut stderr = tokio::io::stderr();
39    stderr.write_all(line.as_bytes()).await?;
40    stderr.flush().await?;
41    Ok(())
42}
43
44async fn write_field_help(field: &Field) -> Result<()> {
45    let buf = prompt::format_field_help(field);
46    let mut stderr = tokio::io::stderr();
47    stderr.write_all(buf.as_bytes()).await?;
48    stderr.flush().await?;
49    Ok(())
50}
51
52async fn write_error(msg: &str) -> Result<()> {
53    let line = format!("[outrig] {msg}\n");
54    let mut stderr = tokio::io::stderr();
55    stderr.write_all(line.as_bytes()).await?;
56    stderr.flush().await?;
57    Ok(())
58}
59
60fn map_dialoguer_err(e: dialoguer::Error) -> OutrigError {
61    match e {
62        dialoguer::Error::IO(io_err) => OutrigError::Io(io_err),
63    }
64}
65
66fn map_join_err(e: task::JoinError) -> OutrigError {
67    OutrigError::Io(io::Error::other(e))
68}
69
70impl PromptSource for DialoguerPrompt {
71    async fn ask_string(&mut self, field: &Field, default: &str) -> Result<String> {
72        loop {
73            let prompt = field.name.to_owned();
74            let default_owned = default.to_owned();
75            let answer = task::spawn_blocking(move || {
76                Input::<String>::new()
77                    .with_prompt(prompt)
78                    .default(default_owned)
79                    // Without this, dialoguer rejects an empty default ("") on
80                    // Enter; matches `TerminalPrompt`'s "Enter accepts default"
81                    // semantics regardless of whether the default is empty.
82                    .allow_empty(true)
83                    .interact_text()
84            })
85            .await
86            .map_err(map_join_err)?
87            .map_err(map_dialoguer_err)?;
88            if answer.trim() == "?" {
89                write_field_help(field).await?;
90                continue;
91            }
92            return Ok(answer);
93        }
94    }
95
96    /// Drives Y/n via `Input::<String>` rather than `Confirm` so `?` can
97    /// trigger help and bad input shows an error -- `Confirm` silently
98    /// re-prompts on anything but y/n, which reads as "nothing happened".
99    async fn ask_bool(&mut self, field: &Field, default: bool) -> Result<bool> {
100        let render = if default { "Y/n" } else { "y/N" };
101        loop {
102            let prompt = format!("{} [{}]", field.name, render);
103            let answer = task::spawn_blocking(move || {
104                Input::<String>::new()
105                    .with_prompt(prompt)
106                    .allow_empty(true)
107                    .interact_text()
108            })
109            .await
110            .map_err(map_join_err)?
111            .map_err(map_dialoguer_err)?;
112            let trimmed = answer.trim();
113            if trimmed.is_empty() {
114                return Ok(default);
115            }
116            if trimmed == "?" {
117                write_field_help(field).await?;
118                continue;
119            }
120            match prompt::parse_bool(trimmed) {
121                Some(b) => return Ok(b),
122                None => write_error("expected y/yes or n/no, or `?` for help").await?,
123            }
124        }
125    }
126
127    async fn ask_select(&mut self, field: &Field, default_idx: usize) -> Result<usize> {
128        write_description(field.description).await?;
129        let prompt = field.name.to_owned();
130        let items: Vec<&'static str> = field.options.iter().map(|(v, _)| *v).collect();
131        task::spawn_blocking(move || {
132            FuzzySelect::new()
133                .with_prompt(prompt)
134                .items(&items)
135                .default(default_idx)
136                .interact()
137        })
138        .await
139        .map_err(map_join_err)?
140        .map_err(map_dialoguer_err)
141        .map_err(Into::into)
142    }
143
144    /// Drives multi-select via a `Select` loop with checkbox-prefixed
145    /// items and an explicit "Done" row. Each Enter toggles the
146    /// highlighted item; the user picks `Done` when finished. This
147    /// replaces dialoguer's `MultiSelect`, which uses Space-to-toggle /
148    /// Enter-to-confirm -- a binding that confused testers who expected
149    /// Enter to toggle the selection.
150    async fn ask_multiselect(
151        &mut self,
152        field: &Field,
153        default_indices: &[usize],
154    ) -> Result<Vec<usize>> {
155        write_description(field.description).await?;
156        let mut selected: Vec<bool> = vec![false; field.options.len()];
157        for &i in default_indices {
158            if let Some(slot) = selected.get_mut(i) {
159                *slot = true;
160            }
161        }
162        let done_idx = field.options.len();
163        let mut cursor: usize = 0;
164        loop {
165            let mut items: Vec<String> = field
166                .options
167                .iter()
168                .enumerate()
169                .map(|(i, (val, _))| {
170                    let mark = if selected[i] { "[x]" } else { "[ ]" };
171                    format!("{mark} {val}")
172                })
173                .collect();
174            items.push("Done".to_string());
175
176            let prompt = field.name.to_owned();
177            let cursor_now = cursor.min(items.len() - 1);
178            let idx = task::spawn_blocking(move || {
179                Select::new()
180                    .with_prompt(prompt)
181                    .items(&items)
182                    .default(cursor_now)
183                    // Suppress dialoguer's post-interaction confirmation
184                    // line so the loop redraws cleanly without leaving
185                    // breadcrumbs each iteration.
186                    .report(false)
187                    .interact()
188            })
189            .await
190            .map_err(map_join_err)?
191            .map_err(map_dialoguer_err)?;
192
193            if idx == done_idx {
194                return Ok(selected
195                    .iter()
196                    .enumerate()
197                    .filter_map(|(i, &b)| if b { Some(i) } else { None })
198                    .collect());
199            }
200            selected[idx] = !selected[idx];
201            cursor = idx;
202        }
203    }
204}