From bfec232f117a685d00740585a4d14a6a1ba40973 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 9 May 2026 10:53:34 -0400 Subject: [PATCH 1/2] feat(cli): add prompt_or_flag + prompt_or_flag_optional Plan B Phase 3 sub-step 1. The new helpers collapse the Option::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 --- crates/relicario-cli/src/prompt.rs | 143 ++++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 4 deletions(-) diff --git a/crates/relicario-cli/src/prompt.rs b/crates/relicario-cli/src/prompt.rs index 8e21f90..655a90d 100644 --- a/crates/relicario-cli/src/prompt.rs +++ b/crates/relicario-cli/src/prompt.rs @@ -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 { rpassword::prompt_password(label).map_err(Into::into) } -pub(crate) fn prompt(label: &str) -> Result { +fn read_required_line(reader: &mut R, label: &str) -> Result { 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> { +fn read_optional_line(reader: &mut R, label: &str) -> Result> { 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 { + 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> { + 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> { eprint!("{label} [{current}]: "); std::io::Write::flush(&mut std::io::stderr())?; @@ -63,3 +79,122 @@ pub(crate) fn prompt_yesno(label: &str) -> Result { 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( + flag: Option, + label: &str, + parser: impl FnOnce(&str) -> Result, +) -> Result { + 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( + flag: Option, + label: &str, + parser: impl FnOnce(&str) -> Result, +) -> Result> { + 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( + flag: Option, + label: &str, + parser: impl FnOnce(&str) -> Result, + reader: &mut R, +) -> Result { + 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( + flag: Option, + label: &str, + parser: impl FnOnce(&str) -> Result, + reader: &mut R, +) -> Result> { + 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::::new()); + let got = prompt_or_flag_with_reader::( + 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::( + 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::::new()); + let got = prompt_or_flag_optional_with_reader::( + 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::( + 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::( + None, + "Number", + |s| s.parse::().map_err(Into::into), + &mut reader, + ).expect("value should parse"); + assert_eq!(got, Some(42)); + } +} From 8e791e4853ca2c7071d792891fb88e40f62a62e3 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 9 May 2026 11:12:26 -0400 Subject: [PATCH 2/2] refactor(cli): compress build_*_item with prompt_or_flag Plan B Phase 3 sub-step 2. Replaces the title.map(Ok).unwrap_or_else(|| prompt("Title"))? chain in all seven build_*_item functions with prompt_or_flag, and folds login's or_else(|| prompt_optional(...).ok().flatten()) for username and url into prompt_or_flag_optional. prompt_secret sites and the parse-on-Some-only patterns (expiry, dob, card kind, totp algorithm) stay as-is per spec. Removes the #[allow(dead_code)] attributes from the four helpers in prompt.rs now that callers exist. Co-Authored-By: Claude Opus 4.7 --- crates/relicario-cli/src/commands/add.rs | 20 ++++++++++---------- crates/relicario-cli/src/prompt.rs | 5 ----- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/crates/relicario-cli/src/commands/add.rs b/crates/relicario-cli/src/commands/add.rs index 6d58154..9f62d97 100644 --- a/crates/relicario-cli/src/commands/add.rs +++ b/crates/relicario-cli/src/commands/add.rs @@ -11,7 +11,7 @@ use anyhow::{Context, Result}; use crate::AddKind; use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year}; -use crate::prompt::{prompt, prompt_optional, prompt_secret}; +use crate::prompt::{prompt, prompt_optional, prompt_or_flag, prompt_or_flag_optional, prompt_secret}; pub fn cmd_add(kind: AddKind) -> Result<()> { let vault = crate::session::UnlockedVault::unlock_interactive()?; @@ -69,9 +69,9 @@ fn build_login_item( use relicario_core::{Item, ItemCore}; use zeroize::Zeroizing; - let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; - let username = username.or_else(|| prompt_optional("Username").ok().flatten()); - let url = url.or_else(|| prompt_optional("URL").ok().flatten()); + let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?; + let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?; + let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?; let parsed_url = match url { Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?), None => None, @@ -115,7 +115,7 @@ fn build_secure_note_item( use relicario_core::{Item, ItemCore}; use zeroize::Zeroizing; - let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?; let body = if body_prompt { eprintln!("Enter note body; end with Ctrl-D on a blank line:"); let mut s = String::new(); @@ -144,7 +144,7 @@ fn build_identity_item( use relicario_core::item_types::IdentityCore; use relicario_core::{Item, ItemCore}; - let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?; let dob = match date_of_birth { Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d") .with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?), @@ -170,7 +170,7 @@ fn build_card_item( use relicario_core::{Item, ItemCore}; use zeroize::Zeroizing; - let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?; let number = Zeroizing::new(prompt_secret("Card number: ")?); let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?); let cvv = if cvv.is_empty() { None } else { Some(cvv) }; @@ -209,7 +209,7 @@ fn build_key_item( use relicario_core::{Item, ItemCore}; use zeroize::Zeroizing; - let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?; eprintln!("Paste key material; end with Ctrl-D on a blank line:"); let mut key_material = String::new(); std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?; @@ -236,7 +236,7 @@ fn build_document_item( use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore}; use std::fs; - let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?; let bytes = fs::read(&file) .with_context(|| format!("failed to read {}", file.display()))?; let caps = vault.load_settings()?.attachment_caps; @@ -285,7 +285,7 @@ fn build_totp_item( use relicario_core::{Item, ItemCore}; use zeroize::Zeroizing; - let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?; let secret_b32 = match secret { Some(s) => s, None => prompt_secret("TOTP secret (base32): ")?, diff --git a/crates/relicario-cli/src/prompt.rs b/crates/relicario-cli/src/prompt.rs index 655a90d..6f3e52e 100644 --- a/crates/relicario-cli/src/prompt.rs +++ b/crates/relicario-cli/src/prompt.rs @@ -80,8 +80,6 @@ pub(crate) fn prompt_yesno(label: &str) -> Result { 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( flag: Option, label: &str, @@ -92,7 +90,6 @@ pub(crate) fn prompt_or_flag( prompt_or_flag_with_reader(flag, label, parser, &mut reader) } -#[allow(dead_code)] pub(crate) fn prompt_or_flag_optional( flag: Option, label: &str, @@ -103,7 +100,6 @@ pub(crate) fn prompt_or_flag_optional( prompt_or_flag_optional_with_reader(flag, label, parser, &mut reader) } -#[allow(dead_code)] pub(crate) fn prompt_or_flag_with_reader( flag: Option, label: &str, @@ -117,7 +113,6 @@ pub(crate) fn prompt_or_flag_with_reader( parser(&line) } -#[allow(dead_code)] pub(crate) fn prompt_or_flag_optional_with_reader( flag: Option, label: &str,