Files
relicario/crates/relicario-cli/src/prompt.rs
adlee-was-taken 751e4e9bb1 chore(cli): remove now-dead prompt/prompt_optional helpers
A3 routed personal `add` through the shared item_build builders, which use
prompt_secret / resolve_secret_*; the generic single-line prompt() and
prompt_optional() lost their last callers. read_required_line /
read_optional_line stay (used by prompt_or_flag*).
2026-06-20 17:40:52 -04:00

183 lines
6.1 KiB
Rust

//! Interactive prompt helpers for the CLI.
//!
//! `prompt_secret` reads a masked secret from the TTY (honouring
//! `RELICARIO_TEST_ITEM_SECRET` so integration tests without a TTY can inject
//! secrets); the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
//! used by the edit handlers to keep current values when the user hits enter
//! at a blank prompt. `prompt_or_flag` and `prompt_or_flag_optional` thread a
//! CLI-flag value through the same path so command handlers can use one call
//! site whether the value came from the command line or an interactive prompt.
use anyhow::Result;
use std::io::BufRead;
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
/// for integration-test use (rpassword reads /dev/tty by default, which is
/// unavailable in assert_cmd-spawned children).
pub(crate) fn prompt_secret(label: &str) -> Result<String> {
if let Some(s) = crate::test_item_secret_override() {
return Ok(s);
}
rpassword::prompt_password(label).map_err(Into::into)
}
fn read_required_line<R: BufRead>(reader: &mut R, label: &str) -> Result<String> {
eprint!("{label}: ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
reader.read_line(&mut s)?;
let trimmed = s.trim().to_string();
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
Ok(trimmed)
}
fn read_optional_line<R: BufRead>(reader: &mut R, label: &str) -> Result<Option<String>> {
eprint!("{label} (leave blank to skip): ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
reader.read_line(&mut s)?;
let trimmed = s.trim().to_string();
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
eprint!("{label} [{current}]: ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim().to_string();
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}
pub(crate) fn prompt_keep_opt(label: &str, current: Option<&str>) -> Result<Option<String>> {
let display = current.unwrap_or("(none)");
eprint!("{label} [{display}]: ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim().to_string();
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}
pub(crate) fn prompt_yesno(label: &str) -> Result<bool> {
eprint!("{label} [y/N] ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
}
pub(crate) fn prompt_or_flag<T>(
flag: Option<T>,
label: &str,
parser: impl FnOnce(&str) -> Result<T>,
) -> Result<T> {
let stdin = std::io::stdin();
let mut reader = std::io::BufReader::new(stdin.lock());
prompt_or_flag_with_reader(flag, label, parser, &mut reader)
}
pub(crate) fn prompt_or_flag_optional<T>(
flag: Option<T>,
label: &str,
parser: impl FnOnce(&str) -> Result<T>,
) -> Result<Option<T>> {
let stdin = std::io::stdin();
let mut reader = std::io::BufReader::new(stdin.lock());
prompt_or_flag_optional_with_reader(flag, label, parser, &mut reader)
}
pub(crate) fn prompt_or_flag_with_reader<T, R: BufRead>(
flag: Option<T>,
label: &str,
parser: impl FnOnce(&str) -> Result<T>,
reader: &mut R,
) -> Result<T> {
if let Some(t) = flag {
return Ok(t);
}
let line = read_required_line(reader, label)?;
parser(&line)
}
pub(crate) fn prompt_or_flag_optional_with_reader<T, R: BufRead>(
flag: Option<T>,
label: &str,
parser: impl FnOnce(&str) -> Result<T>,
reader: &mut R,
) -> Result<Option<T>> {
if let Some(t) = flag {
return Ok(Some(t));
}
match read_optional_line(reader, label)? {
None => Ok(None),
Some(line) => parser(&line).map(Some),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn prompt_or_flag_uses_flag_value_when_some() {
let mut reader = Cursor::new(Vec::<u8>::new());
let got = prompt_or_flag_with_reader::<String, _>(
Some("from-flag".to_string()),
"Title",
|_| panic!("parser must not run when flag is Some"),
&mut reader,
).expect("flag value path should succeed");
assert_eq!(got, "from-flag");
}
#[test]
fn prompt_or_flag_prompts_when_none() {
let mut reader = Cursor::new(b"prompted\n".to_vec());
let got = prompt_or_flag_with_reader::<String, _>(
None,
"Title",
|s| Ok(s.to_string()),
&mut reader,
).expect("prompt path should succeed");
assert_eq!(got, "prompted");
}
#[test]
fn prompt_or_flag_optional_returns_some_from_flag_without_reading() {
let mut reader = Cursor::new(Vec::<u8>::new());
let got = prompt_or_flag_optional_with_reader::<String, _>(
Some("flag-val".to_string()),
"URL",
|_| panic!("parser must not run when flag is Some"),
&mut reader,
).expect("flag value path should succeed");
assert_eq!(got, Some("flag-val".to_string()));
}
#[test]
fn prompt_or_flag_optional_prompts_and_blank_yields_none() {
let mut reader = Cursor::new(b"\n".to_vec());
let got = prompt_or_flag_optional_with_reader::<String, _>(
None,
"URL",
|_| panic!("parser must not run on blank input"),
&mut reader,
).expect("blank prompt should succeed with None");
assert_eq!(got, None);
}
#[test]
fn prompt_or_flag_optional_prompts_and_value_runs_parser() {
let mut reader = Cursor::new(b" 42 \n".to_vec());
let got = prompt_or_flag_optional_with_reader::<u32, _>(
None,
"Number",
|s| s.parse::<u32>().map_err(Into::into),
&mut reader,
).expect("value should parse");
assert_eq!(got, Some(42));
}
}