pub mod dialoguer;
use std::io;
use std::io::IsTerminal;
use tokio::io::{
AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader, Lines, Stderr, Stdin,
};
use self::dialoguer::DialoguerPrompt;
use crate::error::{OutrigError, Result};
const PUBLIC_DOC_BASE_URL: &str = "https://tgockel.github.io/outrig/";
pub struct Field {
pub name: &'static str,
pub description: &'static str,
pub options: &'static [(&'static str, &'static str)],
pub doc_link: &'static str,
}
#[allow(async_fn_in_trait)]
pub trait PromptSource {
async fn ask_string(&mut self, field: &Field, default: &str) -> Result<String>;
async fn ask_bool(&mut self, field: &Field, default: bool) -> Result<bool>;
async fn ask_select(&mut self, field: &Field, default_idx: usize) -> Result<usize>;
async fn ask_multiselect(
&mut self,
field: &Field,
default_indices: &[usize],
) -> Result<Vec<usize>>;
}
pub struct TerminalPrompt<R, W> {
lines: Lines<R>,
stderr: W,
}
impl<R, W> TerminalPrompt<R, W>
where
R: AsyncBufRead + Unpin,
W: AsyncWrite + Unpin,
{
pub fn new(stdin: R, stderr: W) -> Self {
Self {
lines: stdin.lines(),
stderr,
}
}
}
impl TerminalPrompt<BufReader<Stdin>, Stderr> {
pub fn from_real_io() -> Self {
Self::new(BufReader::new(tokio::io::stdin()), tokio::io::stderr())
}
}
enum RawLine {
Default,
Help,
Value(String),
}
impl<R, W> TerminalPrompt<R, W>
where
R: AsyncBufRead + Unpin,
W: AsyncWrite + Unpin,
{
async fn write_prompt(&mut self, field: &Field, default_render: &str) -> Result<()> {
let line = if default_render.is_empty() {
format!("? {}: ", field.name)
} else {
format!("? {} [{}]: ", field.name, default_render)
};
self.stderr.write_all(line.as_bytes()).await?;
self.stderr.flush().await?;
Ok(())
}
async fn write_help(&mut self, field: &Field) -> Result<()> {
let buf = format_field_help(field);
self.stderr.write_all(buf.as_bytes()).await?;
self.stderr.flush().await?;
Ok(())
}
async fn write_error(&mut self, msg: &str) -> Result<()> {
let line = format!("[outrig] {msg}\n");
self.stderr.write_all(line.as_bytes()).await?;
self.stderr.flush().await?;
Ok(())
}
async fn read_one(&mut self, field: &Field, default_render: &str) -> Result<RawLine> {
self.write_prompt(field, default_render).await?;
let Some(line) = self.lines.next_line().await? else {
return Err(OutrigError::Io(io::Error::from(io::ErrorKind::UnexpectedEof)).into());
};
if line == "?" {
self.write_help(field).await?;
Ok(RawLine::Help)
} else if line.is_empty() {
Ok(RawLine::Default)
} else {
Ok(RawLine::Value(line))
}
}
}
impl<R, W> PromptSource for TerminalPrompt<R, W>
where
R: AsyncBufRead + Unpin,
W: AsyncWrite + Unpin,
{
async fn ask_string(&mut self, field: &Field, default: &str) -> Result<String> {
let render = if default.is_empty() {
String::new()
} else {
format!("default: {default}")
};
loop {
match self.read_one(field, &render).await? {
RawLine::Help => continue,
RawLine::Default => return Ok(default.to_string()),
RawLine::Value(s) => return Ok(s),
}
}
}
async fn ask_bool(&mut self, field: &Field, default: bool) -> Result<bool> {
let render = if default { "Y/n" } else { "y/N" };
loop {
match self.read_one(field, render).await? {
RawLine::Help => continue,
RawLine::Default => return Ok(default),
RawLine::Value(s) => match parse_bool(&s) {
Some(b) => return Ok(b),
None => {
self.write_error("expected y/yes or n/no").await?;
continue;
}
},
}
}
}
async fn ask_select(&mut self, field: &Field, default_idx: usize) -> Result<usize> {
let default_value = field.options[default_idx].0;
loop {
match self
.read_one(field, &format!("default: {default_value}"))
.await?
{
RawLine::Help => continue,
RawLine::Default => return Ok(default_idx),
RawLine::Value(s) => match index_of(field.options, s.trim()) {
Some(i) => return Ok(i),
None => {
let values = join_values(field.options);
self.write_error(&format!("expected one of: {values}"))
.await?;
continue;
}
},
}
}
}
async fn ask_multiselect(
&mut self,
field: &Field,
default_indices: &[usize],
) -> Result<Vec<usize>> {
let default_render = {
let joined: Vec<&str> = default_indices
.iter()
.map(|&i| field.options[i].0)
.collect();
format!("default: {}", joined.join(","))
};
loop {
match self.read_one(field, &default_render).await? {
RawLine::Help => continue,
RawLine::Default => return Ok(default_indices.to_vec()),
RawLine::Value(s) => match parse_multiselect(field.options, &s) {
Ok(indices) => return Ok(indices),
Err(bad) => {
let values = join_values(field.options);
self.write_error(&format!(
"unknown value `{bad}`; expected any of: {values}"
))
.await?;
continue;
}
},
}
}
}
}
pub(super) fn format_field_help(field: &Field) -> String {
let mut buf = String::new();
buf.push('\n');
if !field.description.is_empty() {
buf.push_str(" ");
buf.push_str(field.description);
buf.push('\n');
}
for (value, blurb) in field.options {
buf.push_str(" ");
buf.push_str(value);
buf.push_str(" ");
buf.push_str(blurb);
buf.push('\n');
}
buf.push('\n');
buf.push_str(" See: ");
buf.push_str(&public_doc_link(field.doc_link));
buf.push_str("\n\n");
buf
}
fn public_doc_link(doc_link: &str) -> String {
let Some(rest) = doc_link.strip_prefix("doc/") else {
return doc_link.to_string();
};
let (path, anchor) = rest.split_once('#').unwrap_or((rest, ""));
let (path, suffix) = path
.strip_suffix(".md")
.map_or((path, ""), |path| (path, ".html"));
let mut out = String::with_capacity(
PUBLIC_DOC_BASE_URL.len() + path.len() + suffix.len() + anchor.len() + 1,
);
out.push_str(PUBLIC_DOC_BASE_URL);
out.push_str(path);
out.push_str(suffix);
if !anchor.is_empty() {
out.push('#');
out.push_str(anchor);
}
out
}
pub(super) fn parse_bool(s: &str) -> Option<bool> {
match s.trim() {
"y" | "Y" | "yes" | "Yes" | "YES" => Some(true),
"n" | "N" | "no" | "No" | "NO" => Some(false),
_ => None,
}
}
fn index_of(options: &[(&str, &str)], needle: &str) -> Option<usize> {
options.iter().position(|(value, _)| *value == needle)
}
fn join_values(options: &[(&str, &str)]) -> String {
options
.iter()
.map(|(value, _)| *value)
.collect::<Vec<_>>()
.join(", ")
}
fn parse_multiselect(
options: &[(&str, &str)],
input: &str,
) -> std::result::Result<Vec<usize>, String> {
let mut out = Vec::new();
for token in input.split(',') {
let trimmed = token.trim();
match index_of(options, trimmed) {
Some(i) => out.push(i),
None => return Err(trimmed.to_string()),
}
}
Ok(out)
}
pub enum AutoPrompt {
Terminal(TerminalPrompt<BufReader<Stdin>, Stderr>),
Dialoguer(DialoguerPrompt),
}
impl PromptSource for AutoPrompt {
async fn ask_string(&mut self, field: &Field, default: &str) -> Result<String> {
match self {
Self::Terminal(p) => p.ask_string(field, default).await,
Self::Dialoguer(p) => p.ask_string(field, default).await,
}
}
async fn ask_bool(&mut self, field: &Field, default: bool) -> Result<bool> {
match self {
Self::Terminal(p) => p.ask_bool(field, default).await,
Self::Dialoguer(p) => p.ask_bool(field, default).await,
}
}
async fn ask_select(&mut self, field: &Field, default_idx: usize) -> Result<usize> {
match self {
Self::Terminal(p) => p.ask_select(field, default_idx).await,
Self::Dialoguer(p) => p.ask_select(field, default_idx).await,
}
}
async fn ask_multiselect(
&mut self,
field: &Field,
default_indices: &[usize],
) -> Result<Vec<usize>> {
match self {
Self::Terminal(p) => p.ask_multiselect(field, default_indices).await,
Self::Dialoguer(p) => p.ask_multiselect(field, default_indices).await,
}
}
}
pub fn auto() -> AutoPrompt {
if std::io::stdin().is_terminal() {
AutoPrompt::Dialoguer(DialoguerPrompt::new())
} else {
AutoPrompt::Terminal(TerminalPrompt::from_real_io())
}
}