Skip to main content

raps_kernel/
interactive.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Interactive mode control
5//!
6//! Provides functions to check if interactive mode is enabled and handle
7//! prompts appropriately based on the --non-interactive flag.
8
9#[cfg(not(test))]
10use std::io::IsTerminal;
11use std::sync::atomic::{AtomicBool, Ordering};
12
13static NON_INTERACTIVE: AtomicBool = AtomicBool::new(false);
14static YES: AtomicBool = AtomicBool::new(false);
15
16#[cfg(test)]
17pub(crate) static MOCK_IS_TERMINAL: AtomicBool = AtomicBool::new(true);
18
19/// Initialize interactive mode flags
20pub fn init(non_interactive: bool, yes: bool) {
21    NON_INTERACTIVE.store(non_interactive, Ordering::Relaxed);
22    YES.store(yes, Ordering::Relaxed);
23}
24
25/// Check if non-interactive mode is enabled (explicit flag or no TTY detected)
26pub fn is_non_interactive() -> bool {
27    #[cfg(test)]
28    let is_term = MOCK_IS_TERMINAL.load(Ordering::Relaxed);
29    #[cfg(not(test))]
30    let is_term = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
31
32    NON_INTERACTIVE.load(Ordering::Relaxed) || !is_term
33}
34
35/// Check if --yes flag is set (auto-confirm)
36#[allow(dead_code)] // May be used in future
37pub fn is_yes() -> bool {
38    YES.load(Ordering::Relaxed)
39}
40
41/// Detect if the environment is headless (no display server / browser available).
42///
43/// Returns `true` when browser-based OAuth is unlikely to work:
44/// - SSH sessions (`SSH_CONNECTION` or `SSH_TTY` set)
45/// - No display server on Linux (`DISPLAY` and `WAYLAND_DISPLAY` both unset)
46/// - Docker / CI containers (`container` env var or `/.dockerenv` exists)
47/// - Explicit non-interactive flag
48pub fn is_headless() -> bool {
49    if is_non_interactive() {
50        return true;
51    }
52
53    // SSH session
54    if std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() {
55        return true;
56    }
57
58    // Linux without a display server
59    #[cfg(target_os = "linux")]
60    if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() {
61        return true;
62    }
63
64    // Container environment
65    if std::env::var_os("container").is_some()
66        || std::path::Path::new("/.dockerenv").exists()
67        || std::env::var_os("CI").is_some()
68    {
69        return true;
70    }
71
72    false
73}
74
75/// Require a value in non-interactive mode
76///
77/// Returns an error if non-interactive mode is enabled and the value is None
78#[allow(dead_code)] // May be used in future
79pub fn require_value<T>(value: Option<T>, name: &str) -> Result<T, anyhow::Error> {
80    match value {
81        Some(v) => Ok(v),
82        None => {
83            if is_non_interactive() {
84                anyhow::bail!(
85                    "{} is required in non-interactive mode. Use --{} flag or set environment variable.",
86                    name,
87                    name.replace('_', "-")
88                );
89            }
90            // In interactive mode, return None wrapped in error to trigger prompt
91            anyhow::bail!("{name} is required");
92        }
93    }
94}
95
96/// Check if a destructive action should proceed
97///
98/// Returns true if --yes is set or if the user confirms interactively.
99/// Returns false in non-interactive mode without --yes.
100pub fn should_proceed_destructive(action: &str) -> bool {
101    if is_yes() {
102        return true;
103    }
104
105    if is_non_interactive() {
106        return false; // Fail in non-interactive mode without --yes
107    }
108
109    // In interactive mode, prompt the user for confirmation
110    dialoguer::Confirm::new()
111        .with_prompt(format!("Are you sure you want to {}?", action))
112        .default(false)
113        .interact()
114        .unwrap_or(false)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    // Note: These tests manipulate global state, so they should be run with --test-threads=1
122    // or they may interfere with each other
123
124    fn reset_state() {
125        init(false, false);
126        MOCK_IS_TERMINAL.store(true, Ordering::Relaxed);
127    }
128
129    #[test]
130    fn test_init_non_interactive() {
131        reset_state();
132        init(true, false);
133        assert!(is_non_interactive());
134        assert!(!is_yes());
135        reset_state();
136    }
137
138    #[test]
139    fn test_init_yes() {
140        reset_state();
141        init(false, true);
142        assert!(!is_non_interactive());
143        assert!(is_yes());
144        reset_state();
145    }
146
147    #[test]
148    fn test_init_both() {
149        reset_state();
150        init(true, true);
151        assert!(is_non_interactive());
152        assert!(is_yes());
153        reset_state();
154    }
155
156    #[test]
157    fn test_default_state() {
158        reset_state();
159        assert!(!is_non_interactive());
160        assert!(!is_yes());
161    }
162
163    #[test]
164    fn test_require_value_some() {
165        reset_state();
166        let result = require_value(Some("test"), "name");
167        assert!(result.is_ok());
168        assert_eq!(result.unwrap(), "test");
169    }
170
171    #[test]
172    fn test_require_value_none_interactive() {
173        reset_state();
174        let result = require_value::<String>(None, "name");
175        assert!(result.is_err());
176        // In interactive mode, should just say it's required (to trigger prompt)
177        let err_msg = result.unwrap_err().to_string();
178        assert!(err_msg.contains("required"));
179    }
180
181    #[test]
182    fn test_require_value_none_non_interactive() {
183        reset_state();
184        init(true, false);
185        let result = require_value::<String>(None, "name");
186        assert!(result.is_err());
187        // In non-interactive mode, should mention the flag
188        let err_msg = result.unwrap_err().to_string();
189        assert!(err_msg.contains("non-interactive"));
190        reset_state();
191    }
192
193    #[test]
194    fn test_should_proceed_destructive_yes() {
195        reset_state();
196        init(false, true); // --yes flag set
197        assert!(should_proceed_destructive("delete bucket"));
198        reset_state();
199    }
200
201    #[test]
202    fn test_should_proceed_destructive_non_interactive_no_yes() {
203        reset_state();
204        init(true, false); // non-interactive but no --yes
205        assert!(!should_proceed_destructive("delete bucket"));
206        reset_state();
207    }
208
209    #[test]
210    fn test_should_proceed_destructive_interactive() {
211        reset_state();
212        init(false, false); // interactive mode
213        assert!(!should_proceed_destructive("delete bucket")); // Should prompt
214        reset_state();
215    }
216
217    #[test]
218    fn test_should_proceed_destructive_non_interactive_with_yes() {
219        reset_state();
220        init(true, true); // non-interactive with --yes
221        assert!(should_proceed_destructive("delete bucket"));
222        reset_state();
223    }
224}