Skip to main content

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}