//! 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 { if let Some(s) = crate::test_item_secret_override() { return Ok(s); } rpassword::prompt_password(label).map_err(Into::into) } 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(); 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(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(); 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())?; 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> { 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 { 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( 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)); } }