merge(cycle-2): land Stream A — Plan B Phase 3 (prompt_or_flag + builder compression)

2 commits from feature/cli-tail-stream-a-prompt-helpers:
- bfec232 feat(cli): add prompt_or_flag<T> + prompt_or_flag_optional<T>
- 8e791e4 refactor(cli): compress build_*_item with prompt_or_flag

Phase 3 complete. Helper signatures match the spec literal; all 7 build_*_item
builders converted (title in each + username and url in build_login_item).
Internal refactor extracts read_required_line / read_optional_line as
generic-over-BufRead helpers so prompt and prompt_optional both delegate to
them, unblocking Cursor-driven tests for the legacy callers.

Honest scope correction (per DEV-A PR description): the spec promised ~30
percent per-type body shrinkage but the actual outcome is 1-line-for-1-line
replacement. The win is intent clarity, not LOC. Worth calibrating Plan B
compression-claim heuristics in future planning.

Subtle behavior delta in build_login_item: the prior
prompt_optional(...).ok().flatten() silently mapped I/O errors to None;
the new prompt_or_flag_optional(...)? propagates them. Ctrl-D mid-prompt now
errors clearly instead of producing a half-empty item -- strictly better.

Pre-merge checklist on tip 8e791e4:
- cargo test --workspace: 261 tests, 0 failures (254 baseline + 7 new)
- cargo clippy --workspace --all-targets: silent
- cargo build -p relicario-wasm --target wasm32-unknown-unknown: clean
- Independent fresh-subagent code review: APPROVE (spec-conformant, well-tested)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-09 11:29:33 -04:00
2 changed files with 144 additions and 14 deletions

View File

@@ -11,7 +11,7 @@ use anyhow::{Context, Result};
use crate::AddKind; use crate::AddKind;
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year}; 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<()> { pub fn cmd_add(kind: AddKind) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?; let vault = crate::session::UnlockedVault::unlock_interactive()?;
@@ -69,9 +69,9 @@ fn build_login_item(
use relicario_core::{Item, ItemCore}; use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing; 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 username = username.or_else(|| prompt_optional("Username").ok().flatten()); let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
let url = url.or_else(|| prompt_optional("URL").ok().flatten()); let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
let parsed_url = match url { let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?), Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None, None => None,
@@ -115,7 +115,7 @@ fn build_secure_note_item(
use relicario_core::{Item, ItemCore}; use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing; 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 { let body = if body_prompt {
eprintln!("Enter note body; end with Ctrl-D on a blank line:"); eprintln!("Enter note body; end with Ctrl-D on a blank line:");
let mut s = String::new(); let mut s = String::new();
@@ -144,7 +144,7 @@ fn build_identity_item(
use relicario_core::item_types::IdentityCore; use relicario_core::item_types::IdentityCore;
use relicario_core::{Item, ItemCore}; 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 { let dob = match date_of_birth {
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d") Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?), .with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
@@ -170,7 +170,7 @@ fn build_card_item(
use relicario_core::{Item, ItemCore}; use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing; 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 number = Zeroizing::new(prompt_secret("Card number: ")?);
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?); let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
let cvv = if cvv.is_empty() { None } else { Some(cvv) }; let cvv = if cvv.is_empty() { None } else { Some(cvv) };
@@ -209,7 +209,7 @@ fn build_key_item(
use relicario_core::{Item, ItemCore}; use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing; 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:"); eprintln!("Paste key material; end with Ctrl-D on a blank line:");
let mut key_material = String::new(); let mut key_material = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?; 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 relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
use std::fs; 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) let bytes = fs::read(&file)
.with_context(|| format!("failed to read {}", file.display()))?; .with_context(|| format!("failed to read {}", file.display()))?;
let caps = vault.load_settings()?.attachment_caps; let caps = vault.load_settings()?.attachment_caps;
@@ -285,7 +285,7 @@ fn build_totp_item(
use relicario_core::{Item, ItemCore}; use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing; 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 { let secret_b32 = match secret {
Some(s) => s, Some(s) => s,
None => prompt_secret("TOTP secret (base32): ")?, None => prompt_secret("TOTP secret (base32): ")?,

View File

@@ -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,117 @@ 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"))
} }
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));
}
}