Skip to main content

nex_pkg/
input.rs

1//! User input abstraction — decouples interactive prompts from dialoguer.
2//!
3//! Production uses [`TerminalInput`] (dialoguer). Tests inject [`ScriptedInput`]
4//! (pre-programmed responses). E2E tests (separate process via assert_cmd) use
5//! environment variables checked by TerminalInput before falling back to dialoguer.
6//!
7//! # E2E test environment variables
8//!
9//! These env vars are only consulted when `NEX_TESTING=1` is set,
10//! preventing abuse in production environments.
11//!
12//! - `NEX_TESTING` — guard: must be set for any test env vars to take effect
13//! - `NEX_TEST_PASSPHRASE` — bypass password prompts
14//! - `NEX_TEST_CONFIRM` — bypass confirm prompts ("y"/"true" = yes, anything else = no)
15//! - `NEX_TEST_INPUT` — bypass text input prompts
16
17use anyhow::{Context, Result};
18
19/// Abstract input provider for user interaction.
20pub trait InputProvider: Send + Sync {
21    /// Read a password (no echo).
22    fn password(&self, prompt: &str) -> Result<String>;
23
24    /// Read a password with confirmation (no echo, entered twice).
25    fn password_with_confirm(&self, prompt: &str) -> Result<String>;
26
27    /// Ask a yes/no question.
28    fn confirm(&self, prompt: &str, default: bool) -> Result<bool>;
29
30    /// Read a text input with optional default.
31    fn input_text(&self, prompt: &str, default: Option<&str>) -> Result<String>;
32
33    /// Select from a list of items.
34    fn select(&self, prompt: &str, items: &[String], default: usize) -> Result<usize>;
35}
36
37/// Production input — reads from terminal via dialoguer.
38/// Falls back to environment variables for e2e test support, but only
39/// when `NEX_TESTING` is explicitly set to prevent abuse in production.
40pub struct TerminalInput;
41
42/// Return the value of a test env var, but only if the `NEX_TESTING` guard is set.
43/// This prevents attackers from bypassing interactive prompts in production by
44/// setting `NEX_TEST_*` env vars.
45fn test_env_fallback(var: &str) -> Option<String> {
46    if std::env::var("NEX_TESTING").is_err() {
47        return None;
48    }
49    std::env::var(var).ok()
50}
51
52impl InputProvider for TerminalInput {
53    fn password(&self, prompt: &str) -> Result<String> {
54        if let Some(pp) = test_env_fallback("NEX_TEST_PASSPHRASE") {
55            return Ok(pp);
56        }
57        dialoguer::Password::new()
58            .with_prompt(prompt)
59            .interact()
60            .context("failed to read password")
61    }
62
63    fn password_with_confirm(&self, prompt: &str) -> Result<String> {
64        if let Some(pp) = test_env_fallback("NEX_TEST_PASSPHRASE") {
65            return Ok(pp);
66        }
67        dialoguer::Password::new()
68            .with_prompt(prompt)
69            .with_confirmation("Confirm", "Values do not match")
70            .interact()
71            .context("failed to read password")
72    }
73
74    fn confirm(&self, prompt: &str, default: bool) -> Result<bool> {
75        if let Some(val) = test_env_fallback("NEX_TEST_CONFIRM") {
76            return Ok(val == "y" || val == "yes" || val == "true");
77        }
78        if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
79            return Ok(default);
80        }
81        dialoguer::Confirm::new()
82            .with_prompt(prompt)
83            .default(default)
84            .interact()
85            .context("failed to read confirmation")
86    }
87
88    fn input_text(&self, prompt: &str, default: Option<&str>) -> Result<String> {
89        if let Some(val) = test_env_fallback("NEX_TEST_INPUT") {
90            return Ok(val);
91        }
92        let mut builder = dialoguer::Input::<String>::new().with_prompt(prompt);
93        if let Some(d) = default {
94            builder = builder.default(d.to_string());
95        }
96        builder.interact_text().context("failed to read input")
97    }
98
99    fn select(&self, prompt: &str, items: &[String], default: usize) -> Result<usize> {
100        if let Some(val) = test_env_fallback("NEX_TEST_SELECT") {
101            return Ok(val.parse().unwrap_or(default));
102        }
103        if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
104            return Ok(default);
105        }
106        dialoguer::Select::new()
107            .with_prompt(prompt)
108            .items(items)
109            .default(default)
110            .interact()
111            .context("failed to read selection")
112    }
113}
114
115/// Test input — returns pre-programmed responses in order.
116#[cfg(test)]
117#[allow(dead_code)]
118pub struct ScriptedInput {
119    responses: std::sync::Mutex<std::collections::VecDeque<String>>,
120}
121
122#[cfg(test)]
123#[allow(dead_code)]
124impl ScriptedInput {
125    pub fn new(responses: Vec<&str>) -> Self {
126        Self {
127            responses: std::sync::Mutex::new(responses.into_iter().map(String::from).collect()),
128        }
129    }
130
131    fn next_response(&self) -> Result<String> {
132        self.responses
133            .lock()
134            .expect("scripted input lock")
135            .pop_front()
136            .context("ScriptedInput: no more responses queued")
137    }
138}
139
140#[cfg(test)]
141impl InputProvider for ScriptedInput {
142    fn password(&self, _prompt: &str) -> Result<String> {
143        self.next_response()
144    }
145
146    fn password_with_confirm(&self, _prompt: &str) -> Result<String> {
147        self.next_response()
148    }
149
150    fn confirm(&self, _prompt: &str, default: bool) -> Result<bool> {
151        match self.responses.lock().expect("lock").pop_front() {
152            Some(r) => Ok(r == "y" || r == "yes" || r == "true"),
153            None => Ok(default),
154        }
155    }
156
157    fn input_text(&self, _prompt: &str, default: Option<&str>) -> Result<String> {
158        match self.responses.lock().expect("lock").pop_front() {
159            Some(r) if !r.is_empty() => Ok(r),
160            _ => Ok(default.unwrap_or("").to_string()),
161        }
162    }
163
164    fn select(&self, _prompt: &str, _items: &[String], default: usize) -> Result<usize> {
165        match self.responses.lock().expect("lock").pop_front() {
166            Some(r) => Ok(r.parse().unwrap_or(default)),
167            None => Ok(default),
168        }
169    }
170}
171
172/// Global input provider. Defaults to TerminalInput.
173static INPUT: std::sync::OnceLock<Box<dyn InputProvider>> = std::sync::OnceLock::new();
174
175/// Get the active input provider.
176pub fn input() -> &'static dyn InputProvider {
177    INPUT.get_or_init(|| Box::new(TerminalInput)).as_ref()
178}
179
180/// Set a custom input provider (for unit tests). Must be called before first use.
181#[cfg(test)]
182#[allow(dead_code)]
183pub fn set_input(provider: Box<dyn InputProvider>) {
184    let _ = INPUT.set(provider);
185}