feat(cli): add prompt_or_flag<T> + prompt_or_flag_optional<T>
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>
This commit is contained in:
@@ -5,8 +5,12 @@
|
||||
//! 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
|
||||
@@ -18,25 +22,37 @@ pub(crate) fn prompt_secret(label: &str) -> Result<String> {
|
||||
rpassword::prompt_password(label).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub(crate) fn prompt(label: &str) -> Result<String> {
|
||||
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();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
reader.read_line(&mut s)?;
|
||||
let trimmed = s.trim().to_string();
|
||||
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
|
||||
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();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
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())?;
|
||||
@@ -63,3 +79,122 @@ pub(crate) fn prompt_yesno(label: &str) -> Result<bool> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user