Skip to main content

raps_kernel/
prompts.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Interactive prompt utilities
5//!
6//! Provides centralized prompt handling with automatic non-interactive mode support.
7//! All prompts check for non-interactive mode and return appropriate errors.
8
9use anyhow::{Context, Result};
10use dialoguer::{Confirm, Input, MultiSelect, Select};
11
12use crate::interactive;
13
14/// Prompt for text input with validation
15///
16/// Returns an error in non-interactive mode if no default is provided.
17pub fn input<S: Into<String>>(prompt: S, default: Option<&str>) -> Result<String> {
18    let prompt_str = prompt.into();
19
20    if interactive::is_non_interactive() {
21        return default.map(|s| s.to_string()).ok_or_else(|| {
22            anyhow::anyhow!(
23                "{} is required in non-interactive mode",
24                prompt_str.trim_end_matches(':')
25            )
26        });
27    }
28
29    let mut input: Input<String> = Input::new();
30    input = input.with_prompt(&prompt_str);
31
32    if let Some(d) = default {
33        input = input.default(d.to_string());
34    }
35
36    input
37        .interact_text()
38        .context("Failed to read input from terminal")
39}
40
41/// Prompt for text input with custom validation
42///
43/// Returns an error in non-interactive mode if no default is provided.
44pub fn input_validated<S, V>(prompt: S, default: Option<&str>, validator: V) -> Result<String>
45where
46    S: Into<String>,
47    V: Fn(&String) -> Result<(), &'static str> + Clone,
48{
49    let prompt_str = prompt.into();
50
51    if interactive::is_non_interactive() {
52        return default.map(|s| s.to_string()).ok_or_else(|| {
53            anyhow::anyhow!(
54                "{} is required in non-interactive mode",
55                prompt_str.trim_end_matches(':')
56            )
57        });
58    }
59
60    let mut input: Input<String> = Input::new();
61    input = input.with_prompt(&prompt_str).validate_with(validator);
62
63    if let Some(d) = default {
64        input = input.default(d.to_string());
65    }
66
67    input
68        .interact_text()
69        .context("Failed to read input from terminal")
70}
71
72/// Prompt for selection from a list of options
73///
74/// Returns the selected index. Returns an error in non-interactive mode.
75pub fn select<S: Into<String>>(prompt: S, items: &[String]) -> Result<usize> {
76    let prompt_str = prompt.into();
77
78    if interactive::is_non_interactive() {
79        anyhow::bail!(
80            "Selection required for '{}' but running in non-interactive mode",
81            prompt_str
82        );
83    }
84
85    Select::new()
86        .with_prompt(&prompt_str)
87        .items(items)
88        .default(0)
89        .interact()
90        .context("Failed to read selection from terminal")
91}
92
93/// Prompt for selection with a default index
94///
95/// Returns the selected index. Returns the default in non-interactive mode.
96pub fn select_with_default<S: Into<String>>(
97    prompt: S,
98    items: &[String],
99    default: usize,
100) -> Result<usize> {
101    if interactive::is_non_interactive() {
102        return Ok(default);
103    }
104
105    let prompt_str = prompt.into();
106
107    Select::new()
108        .with_prompt(&prompt_str)
109        .items(items)
110        .default(default)
111        .interact()
112        .context("Failed to read selection from terminal")
113}
114
115/// Prompt for multiple selections
116///
117/// Returns an error in non-interactive mode.
118pub fn multi_select<S: Into<String>>(prompt: S, items: &[String]) -> Result<Vec<usize>> {
119    let prompt_str = prompt.into();
120
121    if interactive::is_non_interactive() {
122        anyhow::bail!(
123            "Multi-selection required for '{}' but running in non-interactive mode",
124            prompt_str
125        );
126    }
127
128    MultiSelect::new()
129        .with_prompt(&prompt_str)
130        .items(items)
131        .interact()
132        .context("Failed to read multi-selection from terminal")
133}
134
135/// Prompt for confirmation (yes/no)
136///
137/// Returns true if --yes flag is set, or prompts interactively.
138/// Returns false in non-interactive mode without --yes.
139pub fn confirm<S: Into<String>>(prompt: S, default: bool) -> Result<bool> {
140    // Auto-confirm if --yes flag is set
141    if interactive::is_yes() {
142        return Ok(true);
143    }
144
145    // Fail in non-interactive mode without --yes
146    if interactive::is_non_interactive() {
147        return Ok(false);
148    }
149
150    let prompt_str = prompt.into();
151
152    Confirm::new()
153        .with_prompt(&prompt_str)
154        .default(default)
155        .interact()
156        .context("Failed to read confirmation from terminal")
157}
158
159/// Prompt for confirmation with a required affirmative answer
160///
161/// Use this for destructive operations. Returns true only if user confirms
162/// or --yes flag is set. Always returns false in non-interactive mode
163/// without --yes.
164pub fn confirm_destructive<S: Into<String>>(prompt: S) -> Result<bool> {
165    // Auto-confirm if --yes flag is set
166    if interactive::is_yes() {
167        return Ok(true);
168    }
169
170    // Fail in non-interactive mode without --yes
171    if interactive::is_non_interactive() {
172        return Ok(false);
173    }
174
175    let prompt_str = prompt.into();
176
177    Confirm::new()
178        .with_prompt(&prompt_str)
179        .default(false) // Default to no for destructive operations
180        .interact()
181        .context("Failed to read confirmation from terminal")
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    // Helper to reset interactive state between tests
189    fn reset_state() {
190        interactive::init(false, false);
191    }
192
193    fn set_non_interactive() {
194        interactive::init(true, false);
195    }
196
197    fn set_yes_mode() {
198        interactive::init(false, true);
199    }
200
201    fn set_non_interactive_with_yes() {
202        interactive::init(true, true);
203    }
204
205    // ==================== Input Tests (Non-Interactive Mode) ====================
206
207    #[test]
208    fn test_input_non_interactive_with_default() {
209        set_non_interactive();
210        let result = input("Enter name:", Some("default_value"));
211        assert!(result.is_ok());
212        assert_eq!(result.unwrap(), "default_value");
213        reset_state();
214    }
215
216    #[test]
217    fn test_input_non_interactive_without_default() {
218        set_non_interactive();
219        let result = input("Enter name:", None);
220        assert!(result.is_err());
221        let err = result.unwrap_err().to_string();
222        assert!(err.contains("required"));
223        assert!(err.contains("non-interactive"));
224        reset_state();
225    }
226
227    #[test]
228    fn test_input_validated_non_interactive_with_default() {
229        set_non_interactive();
230        let result = input_validated("Enter email:", Some("test@example.com"), |_| Ok(()));
231        assert!(result.is_ok());
232        assert_eq!(result.unwrap(), "test@example.com");
233        reset_state();
234    }
235
236    #[test]
237    fn test_input_validated_non_interactive_without_default() {
238        set_non_interactive();
239        let result = input_validated::<_, fn(&String) -> Result<(), &'static str>>(
240            "Enter email:",
241            None,
242            |_| Ok(()),
243        );
244        assert!(result.is_err());
245        reset_state();
246    }
247
248    // ==================== Select Tests (Non-Interactive Mode) ====================
249
250    #[test]
251    fn test_select_non_interactive_fails() {
252        set_non_interactive();
253        let items = vec!["Option 1".to_string(), "Option 2".to_string()];
254        let result = select("Choose:", &items);
255        assert!(result.is_err());
256        let err = result.unwrap_err().to_string();
257        assert!(err.contains("non-interactive"));
258        reset_state();
259    }
260
261    #[test]
262    fn test_select_with_default_non_interactive() {
263        set_non_interactive();
264        let items = vec!["Option 1".to_string(), "Option 2".to_string()];
265        let result = select_with_default("Choose:", &items, 1);
266        assert!(result.is_ok());
267        assert_eq!(result.unwrap(), 1);
268        reset_state();
269    }
270
271    // ==================== MultiSelect Tests (Non-Interactive Mode) ====================
272
273    #[test]
274    fn test_multi_select_non_interactive_fails() {
275        set_non_interactive();
276        let items = vec!["Option 1".to_string(), "Option 2".to_string()];
277        let result = multi_select("Choose multiple:", &items);
278        assert!(result.is_err());
279        let err = result.unwrap_err().to_string();
280        assert!(err.contains("non-interactive"));
281        reset_state();
282    }
283
284    // ==================== Confirm Tests ====================
285
286    #[test]
287    fn test_confirm_yes_mode() {
288        set_yes_mode();
289        let result = confirm("Proceed?", false);
290        assert!(result.is_ok());
291        assert!(result.unwrap()); // --yes flag auto-confirms
292        reset_state();
293    }
294
295    #[test]
296    fn test_confirm_non_interactive_no_yes() {
297        set_non_interactive();
298        let result = confirm("Proceed?", true);
299        assert!(result.is_ok());
300        assert!(!result.unwrap()); // Returns false without --yes
301        reset_state();
302    }
303
304    #[test]
305    fn test_confirm_non_interactive_with_yes() {
306        set_non_interactive_with_yes();
307        let result = confirm("Proceed?", false);
308        assert!(result.is_ok());
309        assert!(result.unwrap()); // --yes takes precedence
310        reset_state();
311    }
312
313    // ==================== Confirm Destructive Tests ====================
314
315    #[test]
316    fn test_confirm_destructive_yes_mode() {
317        set_yes_mode();
318        let result = confirm_destructive("Delete all?");
319        assert!(result.is_ok());
320        assert!(result.unwrap());
321        reset_state();
322    }
323
324    #[test]
325    fn test_confirm_destructive_non_interactive_no_yes() {
326        set_non_interactive();
327        let result = confirm_destructive("Delete all?");
328        assert!(result.is_ok());
329        assert!(!result.unwrap()); // Fails safe - returns false
330        reset_state();
331    }
332
333    #[test]
334    fn test_confirm_destructive_non_interactive_with_yes() {
335        set_non_interactive_with_yes();
336        let result = confirm_destructive("Delete all?");
337        assert!(result.is_ok());
338        assert!(result.unwrap());
339        reset_state();
340    }
341
342    // ==================== Prompt String Trimming Tests ====================
343
344    #[test]
345    fn test_input_trims_colon_in_error() {
346        set_non_interactive();
347        let result = input("Enter name:", None);
348        let err = result.unwrap_err().to_string();
349        // Should say "Enter name is required" not "Enter name: is required"
350        assert!(err.contains("Enter name"));
351        assert!(!err.contains("Enter name:"));
352        reset_state();
353    }
354}