mermaid_model/utils/confirm.rs
1//! Shared interactive-confirmation gate for destructive / untrusted actions.
2//!
3//! One fail-closed y/N primitive, reused by `mermaid update` (#110), `mermaid
4//! restore` (#113), and the MCP untrusted-package gate (#10) so the
5//! TTY-detection + `--yes`/`--force` policy lives in exactly one place.
6
7use std::io::{self, IsTerminal, Write};
8
9use anyhow::{Result, bail};
10
11/// Whether `s` is `y`/`yes` (case-insensitive). Default is NO — anything else,
12/// including empty input / EOF, is declined.
13#[must_use]
14pub fn is_affirmative(s: &str) -> bool {
15 s.eq_ignore_ascii_case("y") || s.eq_ignore_ascii_case("yes")
16}
17
18/// Fail-closed policy: refuse a destructive/untrusted action when there is no
19/// interactive terminal to confirm at and the explicit opt-in was not passed.
20/// Also defeats `yes | mermaid …`, since a pipe is not a TTY.
21#[must_use]
22pub fn should_refuse_noninteractive(is_tty: bool, assume_yes: bool) -> bool {
23 !is_tty && !assume_yes
24}
25
26/// Confirm a destructive action (default NO). `assume_yes` (a `--yes`/`--force`
27/// flag) is the explicit opt-in for scripted use; without it a non-interactive
28/// session refuses rather than proceeding silently. `prompt` is printed
29/// verbatim, followed by ` [y/N]: `.
30///
31/// # Errors
32///
33/// Returns an error when there is no TTY to confirm at and `assume_yes` was
34/// not passed — the refusal itself, not an I/O failure — and when writing the
35/// prompt or reading the reply fails. A declined action is `Ok(false)`, so a
36/// caller that treats every `Err` as "user said no" would silently swallow a
37/// broken terminal.
38pub fn confirm_or_refuse(prompt: &str, assume_yes: bool) -> Result<bool> {
39 if assume_yes {
40 return Ok(true);
41 }
42 if should_refuse_noninteractive(io::stdin().is_terminal(), assume_yes) {
43 bail!(
44 "Refusing to proceed without confirmation in a non-interactive session. \
45 Re-run in an interactive terminal, or pass --force/--yes to allow it."
46 );
47 }
48 print!("{prompt} [y/N]: ");
49 io::stdout().flush()?;
50 let mut input = String::new();
51 io::stdin().read_line(&mut input)?;
52 Ok(is_affirmative(input.trim()))
53}
54
55#[cfg(test)]
56mod tests {
57 use super::*;
58
59 #[test]
60 fn affirmative_accepts_only_y_yes() {
61 assert!(is_affirmative("y"));
62 assert!(is_affirmative("Y"));
63 assert!(is_affirmative("yes"));
64 assert!(is_affirmative("YES"));
65 assert!(!is_affirmative("n"));
66 assert!(!is_affirmative(""));
67 assert!(!is_affirmative("sure"));
68 }
69
70 #[test]
71 fn refuses_noninteractive_without_optin() {
72 // No TTY + no --yes → refuse (fail closed).
73 assert!(should_refuse_noninteractive(false, false));
74 // --yes overrides the missing TTY.
75 assert!(!should_refuse_noninteractive(false, true));
76 // A real TTY is fine either way.
77 assert!(!should_refuse_noninteractive(true, false));
78 }
79
80 #[test]
81 fn confirm_returns_true_immediately_when_assume_yes() {
82 // assume_yes short-circuits before any stdin/TTY interaction.
83 assert!(confirm_or_refuse("do the thing?", true).expect("assume_yes is infallible"));
84 }
85}