Plan B Phase 3 sub-step 1. The new helpers collapse the Option<T>::map(Ok).unwrap_or_else(|| prompt(...))? chain that the seven build_*_item builders repeat. Reader is injectable via the *_with_reader variants so the unit tests can drive both the flag-value and prompt paths from a Cursor without needing a TTY. prompt and prompt_optional are refactored to delegate to two private read_*_line helpers; semantics are unchanged. dead_code is allowed on the four new helpers until sub-step 2 wires them into commands/add.rs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
201 lines
6.7 KiB
Rust
201 lines
6.7 KiB
Rust
//! Interactive prompt helpers for the CLI.
|
|
//!
|
|
//! The `prompt`/`prompt_optional`/`prompt_secret` family reads from stdin /
|
|
//! the TTY; 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_secret` honours `RELICARIO_TEST_ITEM_SECRET`
|
|
//! so integration tests (which don't have a TTY) can inject secrets.
|
|
//! `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 from 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(label: &str) -> Result<String> {
|
|
let stdin = std::io::stdin();
|
|
let mut reader = std::io::BufReader::new(stdin.lock());
|
|
read_required_line(&mut reader, label)
|
|
}
|
|
|
|
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
|
|
let stdin = std::io::stdin();
|
|
let mut reader = std::io::BufReader::new(stdin.lock());
|
|
read_optional_line(&mut reader, label)
|
|
}
|
|
|
|
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"))
|
|
}
|
|
|
|
// dead_code allowed until sub-step 2 wires these into commands/add.rs.
|
|
#[allow(dead_code)]
|
|
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)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
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)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
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)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
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));
|
|
}
|
|
}
|