Skip to main content

outrig_cli/init/prompt/
mod.rs

1//! Interactive prompts with `[default: ...]` rendering and `?`-help.
2//!
3//! `PromptSource` is a trait so terminal, AI/LLM, and scripted-test answer
4//! sources are interchangeable. `TerminalPrompt` is the line-based impl
5//! used for tests and non-TTY (piped/CI) callers; `DialoguerPrompt` is the
6//! rich impl that drives `dialoguer` widgets on a real terminal. The
7//! `auto()` factory picks between them based on whether stdin is a TTY.
8
9pub mod dialoguer;
10
11use std::io;
12use std::io::IsTerminal;
13
14use tokio::io::{
15    AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader, Lines, Stderr, Stdin,
16};
17
18use self::dialoguer::DialoguerPrompt;
19use crate::error::{OutrigError, Result};
20
21const PUBLIC_DOC_BASE_URL: &str = "https://tgockel.github.io/outrig/";
22
23/// Static metadata for one interactive question.
24///
25/// Constants live next to the call sites (e.g. `init/container.rs`) and are
26/// passed by reference into `PromptSource` methods.
27pub struct Field {
28    /// User-facing question, e.g. `"Pick a provider style"`.
29    pub name: &'static str,
30    /// Long-form explanation shown when the user types `?`.
31    pub description: &'static str,
32    /// Discrete choices as `(value, blurb)` pairs. Empty for free-text fields.
33    pub options: &'static [(&'static str, &'static str)],
34    /// Path under the repo root, e.g. `"doc/concepts/llm-providers.md"`.
35    /// Help output renders this as a public documentation URL.
36    pub doc_link: &'static str,
37}
38
39/// Source of answers to interactive prompts.
40///
41/// Validation failures retry internally; only I/O errors (including
42/// EOF on stdin) surface as `Err`.
43//
44// The trait is consumed `&mut self` from a single-threaded init flow and is
45// never spawned across threads, so the lack of a `Send` bound on the
46// returned future is intentional.
47#[allow(async_fn_in_trait)]
48pub trait PromptSource {
49    async fn ask_string(&mut self, field: &Field, default: &str) -> Result<String>;
50    async fn ask_bool(&mut self, field: &Field, default: bool) -> Result<bool>;
51    async fn ask_select(&mut self, field: &Field, default_idx: usize) -> Result<usize>;
52    async fn ask_multiselect(
53        &mut self,
54        field: &Field,
55        default_indices: &[usize],
56    ) -> Result<Vec<usize>>;
57}
58
59/// `PromptSource` impl that renders to `stderr` and reads from `stdin`.
60///
61/// Generic over the streams so tests can drive it through `tokio::io::duplex`.
62pub struct TerminalPrompt<R, W> {
63    lines: Lines<R>,
64    stderr: W,
65}
66
67impl<R, W> TerminalPrompt<R, W>
68where
69    R: AsyncBufRead + Unpin,
70    W: AsyncWrite + Unpin,
71{
72    pub fn new(stdin: R, stderr: W) -> Self {
73        Self {
74            lines: stdin.lines(),
75            stderr,
76        }
77    }
78}
79
80impl TerminalPrompt<BufReader<Stdin>, Stderr> {
81    /// Build a `TerminalPrompt` over the process's real stdin/stderr.
82    pub fn from_real_io() -> Self {
83        Self::new(BufReader::new(tokio::io::stdin()), tokio::io::stderr())
84    }
85}
86
87/// One read-loop iteration result, before any answer-type-specific parsing.
88enum RawLine {
89    /// User pressed Enter on an empty line: caller returns the default.
90    Default,
91    /// User typed `?`: help has been printed and the loop should re-prompt.
92    Help,
93    /// A non-empty, non-`?` line that the caller must validate.
94    Value(String),
95}
96
97impl<R, W> TerminalPrompt<R, W>
98where
99    R: AsyncBufRead + Unpin,
100    W: AsyncWrite + Unpin,
101{
102    async fn write_prompt(&mut self, field: &Field, default_render: &str) -> Result<()> {
103        // An empty `default_render` means the caller has no useful default to
104        // suggest; drop the `[...]` suffix so the prompt reads cleanly.
105        let line = if default_render.is_empty() {
106            format!("? {}: ", field.name)
107        } else {
108            format!("? {} [{}]: ", field.name, default_render)
109        };
110        self.stderr.write_all(line.as_bytes()).await?;
111        self.stderr.flush().await?;
112        Ok(())
113    }
114
115    async fn write_help(&mut self, field: &Field) -> Result<()> {
116        let buf = format_field_help(field);
117        self.stderr.write_all(buf.as_bytes()).await?;
118        self.stderr.flush().await?;
119        Ok(())
120    }
121
122    async fn write_error(&mut self, msg: &str) -> Result<()> {
123        let line = format!("[outrig] {msg}\n");
124        self.stderr.write_all(line.as_bytes()).await?;
125        self.stderr.flush().await?;
126        Ok(())
127    }
128
129    /// Render the prompt, read one line, and dispatch on `?` / empty / value.
130    async fn read_one(&mut self, field: &Field, default_render: &str) -> Result<RawLine> {
131        self.write_prompt(field, default_render).await?;
132        let Some(line) = self.lines.next_line().await? else {
133            return Err(OutrigError::Io(io::Error::from(io::ErrorKind::UnexpectedEof)).into());
134        };
135        if line == "?" {
136            self.write_help(field).await?;
137            Ok(RawLine::Help)
138        } else if line.is_empty() {
139            Ok(RawLine::Default)
140        } else {
141            Ok(RawLine::Value(line))
142        }
143    }
144}
145
146impl<R, W> PromptSource for TerminalPrompt<R, W>
147where
148    R: AsyncBufRead + Unpin,
149    W: AsyncWrite + Unpin,
150{
151    async fn ask_string(&mut self, field: &Field, default: &str) -> Result<String> {
152        let render = if default.is_empty() {
153            String::new()
154        } else {
155            format!("default: {default}")
156        };
157        loop {
158            match self.read_one(field, &render).await? {
159                RawLine::Help => continue,
160                RawLine::Default => return Ok(default.to_string()),
161                RawLine::Value(s) => return Ok(s),
162            }
163        }
164    }
165
166    async fn ask_bool(&mut self, field: &Field, default: bool) -> Result<bool> {
167        let render = if default { "Y/n" } else { "y/N" };
168        loop {
169            match self.read_one(field, render).await? {
170                RawLine::Help => continue,
171                RawLine::Default => return Ok(default),
172                RawLine::Value(s) => match parse_bool(&s) {
173                    Some(b) => return Ok(b),
174                    None => {
175                        self.write_error("expected y/yes or n/no").await?;
176                        continue;
177                    }
178                },
179            }
180        }
181    }
182
183    async fn ask_select(&mut self, field: &Field, default_idx: usize) -> Result<usize> {
184        let default_value = field.options[default_idx].0;
185        loop {
186            match self
187                .read_one(field, &format!("default: {default_value}"))
188                .await?
189            {
190                RawLine::Help => continue,
191                RawLine::Default => return Ok(default_idx),
192                RawLine::Value(s) => match index_of(field.options, s.trim()) {
193                    Some(i) => return Ok(i),
194                    None => {
195                        let values = join_values(field.options);
196                        self.write_error(&format!("expected one of: {values}"))
197                            .await?;
198                        continue;
199                    }
200                },
201            }
202        }
203    }
204
205    async fn ask_multiselect(
206        &mut self,
207        field: &Field,
208        default_indices: &[usize],
209    ) -> Result<Vec<usize>> {
210        let default_render = {
211            let joined: Vec<&str> = default_indices
212                .iter()
213                .map(|&i| field.options[i].0)
214                .collect();
215            format!("default: {}", joined.join(","))
216        };
217        loop {
218            match self.read_one(field, &default_render).await? {
219                RawLine::Help => continue,
220                RawLine::Default => return Ok(default_indices.to_vec()),
221                RawLine::Value(s) => match parse_multiselect(field.options, &s) {
222                    Ok(indices) => return Ok(indices),
223                    Err(bad) => {
224                        let values = join_values(field.options);
225                        self.write_error(&format!(
226                            "unknown value `{bad}`; expected any of: {values}"
227                        ))
228                        .await?;
229                        continue;
230                    }
231                },
232            }
233        }
234    }
235}
236
237/// Render the `?`-help block: indented description (skipped if empty),
238/// then each option's `value  blurb` row, then a public `See: <url>` footer.
239/// Shared between `TerminalPrompt::write_help` and the dialoguer impl.
240pub(super) fn format_field_help(field: &Field) -> String {
241    let mut buf = String::new();
242    buf.push('\n');
243    if !field.description.is_empty() {
244        buf.push_str("  ");
245        buf.push_str(field.description);
246        buf.push('\n');
247    }
248    for (value, blurb) in field.options {
249        buf.push_str("  ");
250        buf.push_str(value);
251        buf.push_str("  ");
252        buf.push_str(blurb);
253        buf.push('\n');
254    }
255    buf.push('\n');
256    buf.push_str("  See: ");
257    buf.push_str(&public_doc_link(field.doc_link));
258    buf.push_str("\n\n");
259    buf
260}
261
262fn public_doc_link(doc_link: &str) -> String {
263    let Some(rest) = doc_link.strip_prefix("doc/") else {
264        return doc_link.to_string();
265    };
266    let (path, anchor) = rest.split_once('#').unwrap_or((rest, ""));
267    let (path, suffix) = path
268        .strip_suffix(".md")
269        .map_or((path, ""), |path| (path, ".html"));
270
271    let mut out = String::with_capacity(
272        PUBLIC_DOC_BASE_URL.len() + path.len() + suffix.len() + anchor.len() + 1,
273    );
274    out.push_str(PUBLIC_DOC_BASE_URL);
275    out.push_str(path);
276    out.push_str(suffix);
277    if !anchor.is_empty() {
278        out.push('#');
279        out.push_str(anchor);
280    }
281    out
282}
283
284pub(super) fn parse_bool(s: &str) -> Option<bool> {
285    match s.trim() {
286        "y" | "Y" | "yes" | "Yes" | "YES" => Some(true),
287        "n" | "N" | "no" | "No" | "NO" => Some(false),
288        _ => None,
289    }
290}
291
292fn index_of(options: &[(&str, &str)], needle: &str) -> Option<usize> {
293    options.iter().position(|(value, _)| *value == needle)
294}
295
296fn join_values(options: &[(&str, &str)]) -> String {
297    options
298        .iter()
299        .map(|(value, _)| *value)
300        .collect::<Vec<_>>()
301        .join(", ")
302}
303
304fn parse_multiselect(
305    options: &[(&str, &str)],
306    input: &str,
307) -> std::result::Result<Vec<usize>, String> {
308    let mut out = Vec::new();
309    for token in input.split(',') {
310        let trimmed = token.trim();
311        match index_of(options, trimmed) {
312            Some(i) => out.push(i),
313            None => return Err(trimmed.to_string()),
314        }
315    }
316    Ok(out)
317}
318
319/// `PromptSource` returned by [`auto`]: dialoguer-backed when stdin is a
320/// TTY, line-based otherwise.
321///
322/// `PromptSource` itself is not dyn-compatible (the trait uses
323/// `async fn`-in-trait), so `auto` returns this enum and forwards each
324/// trait method to whichever variant is live. Single-type return means
325/// no boxing and no type erasure at the call site.
326pub enum AutoPrompt {
327    Terminal(TerminalPrompt<BufReader<Stdin>, Stderr>),
328    Dialoguer(DialoguerPrompt),
329}
330
331impl PromptSource for AutoPrompt {
332    async fn ask_string(&mut self, field: &Field, default: &str) -> Result<String> {
333        match self {
334            Self::Terminal(p) => p.ask_string(field, default).await,
335            Self::Dialoguer(p) => p.ask_string(field, default).await,
336        }
337    }
338
339    async fn ask_bool(&mut self, field: &Field, default: bool) -> Result<bool> {
340        match self {
341            Self::Terminal(p) => p.ask_bool(field, default).await,
342            Self::Dialoguer(p) => p.ask_bool(field, default).await,
343        }
344    }
345
346    async fn ask_select(&mut self, field: &Field, default_idx: usize) -> Result<usize> {
347        match self {
348            Self::Terminal(p) => p.ask_select(field, default_idx).await,
349            Self::Dialoguer(p) => p.ask_select(field, default_idx).await,
350        }
351    }
352
353    async fn ask_multiselect(
354        &mut self,
355        field: &Field,
356        default_indices: &[usize],
357    ) -> Result<Vec<usize>> {
358        match self {
359            Self::Terminal(p) => p.ask_multiselect(field, default_indices).await,
360            Self::Dialoguer(p) => p.ask_multiselect(field, default_indices).await,
361        }
362    }
363}
364
365/// TTY stdin gets the rich dialoguer-backed picker; piped / CI stdin
366/// falls back to the line-based `TerminalPrompt` so scripted input keeps
367/// working.
368pub fn auto() -> AutoPrompt {
369    if std::io::stdin().is_terminal() {
370        AutoPrompt::Dialoguer(DialoguerPrompt::new())
371    } else {
372        AutoPrompt::Terminal(TerminalPrompt::from_real_io())
373    }
374}