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
|
//! 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`
|
//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET`
|
||||||
//! so integration tests (which don't have a TTY) can inject secrets.
|
//! 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 anyhow::Result;
|
||||||
|
use std::io::BufRead;
|
||||||
|
|
||||||
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
||||||
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
/// 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)
|
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}: ");
|
eprint!("{label}: ");
|
||||||
std::io::Write::flush(&mut std::io::stderr())?;
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
std::io::stdin().read_line(&mut s)?;
|
reader.read_line(&mut s)?;
|
||||||
let trimmed = s.trim().to_string();
|
let trimmed = s.trim().to_string();
|
||||||
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
|
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
|
||||||
Ok(trimmed)
|
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): ");
|
eprint!("{label} (leave blank to skip): ");
|
||||||
std::io::Write::flush(&mut std::io::stderr())?;
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
std::io::stdin().read_line(&mut s)?;
|
reader.read_line(&mut s)?;
|
||||||
let trimmed = s.trim().to_string();
|
let trimmed = s.trim().to_string();
|
||||||
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
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>> {
|
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
||||||
eprint!("{label} [{current}]: ");
|
eprint!("{label} [{current}]: ");
|
||||||
std::io::Write::flush(&mut std::io::stderr())?;
|
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)?;
|
std::io::stdin().read_line(&mut s)?;
|
||||||
Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
|
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