1pub 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
23pub struct Field {
28 pub name: &'static str,
30 pub description: &'static str,
32 pub options: &'static [(&'static str, &'static str)],
34 pub doc_link: &'static str,
37}
38
39#[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
59pub 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 pub fn from_real_io() -> Self {
83 Self::new(BufReader::new(tokio::io::stdin()), tokio::io::stderr())
84 }
85}
86
87enum RawLine {
89 Default,
91 Help,
93 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 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 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
237pub(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
319pub 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
365pub 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}