|
| 1 | +//! The yes/no prompt for an action worth asking about twice. |
| 2 | +//! |
| 3 | +//! Three commands had grown their own copy of this: deleting an OIDC provider, |
| 4 | +//! running a replication check that writes to every target, and clearing |
| 5 | +//! somebody's second factor. They differed only in their three strings, while |
| 6 | +//! agreeing on the parts that matter — that `--yes` skips the question, that a |
| 7 | +//! run with nobody to ask fails instead of assuming consent, and that anything |
| 8 | +//! other than `y`/`yes` is a decline rather than a default. |
| 9 | +//! |
| 10 | +//! Those are the rules a fourth copy would be most likely to get subtly wrong, |
| 11 | +//! so they live here once. |
| 12 | +
|
| 13 | +use std::io::{BufRead as _, IsTerminal as _, Write as _}; |
| 14 | + |
| 15 | +use rc_core::{Error, Result}; |
| 16 | + |
| 17 | +use crate::output::Formatter; |
| 18 | + |
| 19 | +/// What to say while confirming one particular action. |
| 20 | +pub(crate) struct Confirmation<'a> { |
| 21 | + /// The question, ending in `[y/N]`. |
| 22 | + /// |
| 23 | + /// Callers that interpolate a name are responsible for passing it through |
| 24 | + /// [`Formatter::sanitize_text`] first: it reaches a terminal from here with |
| 25 | + /// no further escaping. |
| 26 | + pub(crate) prompt: &'a str, |
| 27 | + /// Why `--yes` is required when there is no terminal to ask on. |
| 28 | + pub(crate) requires_yes: &'a str, |
| 29 | + /// Reported when the answer is anything but yes. |
| 30 | + pub(crate) declined: &'a str, |
| 31 | +} |
| 32 | + |
| 33 | +/// Ask, unless `yes` was passed. |
| 34 | +/// |
| 35 | +/// `Ok(())` means go ahead. A refusal is [`Error::Interrupted`]; a run that |
| 36 | +/// could not ask at all is [`Error::InvalidPath`], which is a usage problem and |
| 37 | +/// exits as one. |
| 38 | +pub(crate) fn confirm(request: &Confirmation<'_>, yes: bool, formatter: &Formatter) -> Result<()> { |
| 39 | + if yes { |
| 40 | + return Ok(()); |
| 41 | + } |
| 42 | + // Refuse rather than proceed: a machine-readable run has nobody to answer, |
| 43 | + // and treating silence as consent is how a destructive command becomes a |
| 44 | + // surprise in someone's CI log. |
| 45 | + if formatter.is_json() || !std::io::stdin().is_terminal() { |
| 46 | + return Err(Error::InvalidPath(request.requires_yes.to_string())); |
| 47 | + } |
| 48 | + |
| 49 | + // The question goes to stderr so stdout stays usable in a pipeline. |
| 50 | + let mut stderr = std::io::stderr().lock(); |
| 51 | + write!(stderr, "{} ", request.prompt).map_err(Error::Io)?; |
| 52 | + stderr.flush().map_err(Error::Io)?; |
| 53 | + |
| 54 | + let mut answer = String::new(); |
| 55 | + std::io::stdin() |
| 56 | + .lock() |
| 57 | + .read_line(&mut answer) |
| 58 | + .map_err(Error::Io)?; |
| 59 | + |
| 60 | + if matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") { |
| 61 | + Ok(()) |
| 62 | + } else { |
| 63 | + Err(Error::Interrupted(request.declined.to_string())) |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +#[cfg(test)] |
| 68 | +mod tests { |
| 69 | + use super::*; |
| 70 | + use crate::output::OutputConfig; |
| 71 | + |
| 72 | + fn request() -> Confirmation<'static> { |
| 73 | + Confirmation { |
| 74 | + prompt: "Delete everything? [y/N]", |
| 75 | + requires_yes: "Deleting everything requires --yes in non-interactive or JSON mode", |
| 76 | + declined: "Deleting everything was declined", |
| 77 | + } |
| 78 | + } |
| 79 | + |
| 80 | + fn formatter(json: bool) -> Formatter { |
| 81 | + Formatter::new(OutputConfig { |
| 82 | + json, |
| 83 | + no_color: true, |
| 84 | + ..Default::default() |
| 85 | + }) |
| 86 | + } |
| 87 | + |
| 88 | + #[test] |
| 89 | + fn yes_skips_the_question_entirely() { |
| 90 | + // True even in JSON mode, where there would be nobody to ask. |
| 91 | + confirm(&request(), true, &formatter(true)).expect("--yes must be honoured"); |
| 92 | + } |
| 93 | + |
| 94 | + #[test] |
| 95 | + fn json_mode_refuses_instead_of_assuming_consent() { |
| 96 | + let error = confirm(&request(), false, &formatter(true)).expect_err("must refuse"); |
| 97 | + |
| 98 | + assert!(matches!(error, Error::InvalidPath(_)), "{error:?}"); |
| 99 | + assert_eq!(error.exit_code(), 2, "a missing --yes is a usage error"); |
| 100 | + assert!(error.to_string().contains("--yes"), "{error}"); |
| 101 | + } |
| 102 | + |
| 103 | + #[test] |
| 104 | + fn a_declined_answer_is_reported_as_an_interruption() { |
| 105 | + // Not asserted through the prompt, which needs a terminal: this pins the |
| 106 | + // contract the callers rely on for their exit code. |
| 107 | + let error = Error::Interrupted(request().declined.to_string()); |
| 108 | + assert_eq!(error.exit_code(), 130); |
| 109 | + } |
| 110 | +} |
0 commit comments