Skip to main content

aperture_cli/interactive/
mod.rs

1use crate::error::Error;
2use std::time::Duration;
3
4pub mod mock;
5
6use mock::InputOutput;
7
8/// Maximum allowed input length to prevent memory exhaustion
9const MAX_INPUT_LENGTH: usize = 1024;
10
11/// Maximum number of retry attempts for invalid input
12const MAX_RETRIES: usize = 3;
13
14/// Default timeout for user input operations
15const INPUT_TIMEOUT: Duration = Duration::from_secs(30);
16
17/// Result of handling an empty selection in the selection prompt
18enum EmptySelectionResult {
19    /// User chose to continue with the retry loop
20    Continue,
21    /// User chose to cancel the operation
22    Cancel,
23    /// Selection is non-empty, proceed with processing
24    ProcessSelection,
25}
26
27/// Handle empty selection by prompting user for confirmation
28fn handle_empty_selection<IO: InputOutput>(
29    selection: &str,
30    io: &IO,
31    timeout: Duration,
32) -> Result<EmptySelectionResult, Error> {
33    if !selection.is_empty() {
34        return Ok(EmptySelectionResult::ProcessSelection);
35    }
36
37    let should_continue = confirm_with_io_and_timeout(
38        "Do you want to continue with the current operation?",
39        io,
40        timeout,
41    )?;
42
43    Ok(if should_continue {
44        EmptySelectionResult::Continue
45    } else {
46        EmptySelectionResult::Cancel
47    })
48}
49
50/// Reserved environment variable names that should not be used
51const RESERVED_ENV_VARS: &[&str] = &[
52    "PATH",
53    "HOME",
54    "USER",
55    "SHELL",
56    "PWD",
57    "LANG",
58    "LC_ALL",
59    "LC_CTYPE",
60    "LD_LIBRARY_PATH",
61    "DYLD_LIBRARY_PATH",
62    "RUST_LOG",
63    "RUST_BACKTRACE",
64    "CARGO_HOME",
65    "RUSTUP_HOME",
66    "TERM",
67    "DISPLAY",
68    "XDG_CONFIG_HOME",
69];
70
71/// Prompt the user for input with the given prompt message
72///
73/// # Errors
74/// Returns an error if stdin/stdout operations fail, input is too long,
75/// contains invalid characters, or times out
76pub fn prompt_for_input(prompt: &str) -> Result<String, Error> {
77    let io = mock::RealInputOutput;
78    prompt_for_input_with_io(prompt, &io)
79}
80
81/// Prompt the user for input with a custom timeout
82///
83/// # Errors
84/// Returns an error if stdin/stdout operations fail, input is too long,
85/// contains invalid characters, or times out
86pub fn prompt_for_input_with_timeout(prompt: &str, timeout: Duration) -> Result<String, Error> {
87    let io = mock::RealInputOutput;
88    prompt_for_input_with_io_and_timeout(prompt, &io, timeout)
89}
90
91/// Present a menu of options and return the selected value
92///
93/// # Errors
94/// Returns an error if no options are provided, if stdin operations fail,
95/// or if maximum retry attempts are exceeded
96pub fn select_from_options(prompt: &str, options: &[(String, String)]) -> Result<String, Error> {
97    let io = mock::RealInputOutput;
98    select_from_options_with_io(prompt, options, &io)
99}
100
101/// Present a menu of options with timeout and return the selected value
102///
103/// # Errors
104/// Returns an error if no options are provided, if stdin operations fail,
105/// maximum retry attempts are exceeded, or timeout occurs
106pub fn select_from_options_with_timeout(
107    prompt: &str,
108    options: &[(String, String)],
109    timeout: Duration,
110) -> Result<String, Error> {
111    let io = mock::RealInputOutput;
112    select_from_options_with_io_and_timeout(prompt, options, &io, timeout)
113}
114
115/// Ask for user confirmation with yes/no prompt
116///
117/// # Errors
118/// Returns an error if stdin operations fail or maximum retry attempts are exceeded
119pub fn confirm(prompt: &str) -> Result<bool, Error> {
120    let io = mock::RealInputOutput;
121    confirm_with_io(prompt, &io)
122}
123
124/// Ask for user confirmation with yes/no prompt and timeout
125///
126/// # Errors
127/// Returns an error if stdin operations fail, maximum retry attempts are exceeded, or timeout occurs
128pub fn confirm_with_timeout(prompt: &str, timeout: Duration) -> Result<bool, Error> {
129    let io = mock::RealInputOutput;
130    confirm_with_io_and_timeout(prompt, &io, timeout)
131}
132
133/// Validates an environment variable name
134///
135/// # Errors
136/// Returns an error if the environment variable name is invalid
137pub fn validate_env_var_name(name: &str) -> Result<(), Error> {
138    // Check if empty
139    if name.is_empty() {
140        return Err(Error::invalid_environment_variable_name(
141            name,
142            "name cannot be empty",
143            "Provide a non-empty environment variable name like 'API_TOKEN'",
144        ));
145    }
146
147    // Check length
148    if name.len() > MAX_INPUT_LENGTH {
149        return Err(Error::invalid_environment_variable_name(
150            name,
151            format!(
152                "too long: {} characters (maximum: {})",
153                name.len(),
154                MAX_INPUT_LENGTH
155            ),
156            format!("Shorten the name to {MAX_INPUT_LENGTH} characters or less"),
157        ));
158    }
159
160    // Check for reserved names (case insensitive)
161    let name_upper = name.to_uppercase();
162    if RESERVED_ENV_VARS
163        .iter()
164        .any(|&reserved| reserved == name_upper)
165    {
166        return Err(Error::invalid_environment_variable_name(
167            name,
168            "uses a reserved system variable name",
169            "Use a different name like 'MY_API_TOKEN' or 'APP_SECRET'",
170        ));
171    }
172
173    // Check format - must start with letter or underscore, followed by alphanumeric or underscore
174    if !name.chars().next().unwrap_or('_').is_ascii_alphabetic() && !name.starts_with('_') {
175        let first_char = name.chars().next().unwrap_or('?');
176        let suggested_name = if first_char.is_ascii_digit() {
177            format!("VAR_{name}")
178        } else {
179            format!("_{name}")
180        };
181        return Err(Error::invalid_environment_variable_name(
182            name,
183            "must start with a letter or underscore",
184            format!("Try '{suggested_name}' instead"),
185        ));
186    }
187
188    // Check all characters are valid - alphanumeric or underscore only
189    let invalid_chars: Vec<char> = name
190        .chars()
191        .filter(|c| !c.is_ascii_alphanumeric() && *c != '_')
192        .collect();
193    if !invalid_chars.is_empty() {
194        let invalid_chars_str: String = invalid_chars.iter().collect();
195        let suggested_name = name
196            .chars()
197            .map(|c| {
198                if c.is_ascii_alphanumeric() || c == '_' {
199                    c
200                } else {
201                    '_'
202                }
203            })
204            .collect::<String>();
205        return Err(Error::interactive_invalid_characters(
206            &invalid_chars_str,
207            format!("Try '{suggested_name}' instead"),
208        ));
209    }
210
211    Ok(())
212}
213
214/// Testable version of `prompt_for_input` that accepts an `InputOutput` trait
215///
216/// # Errors
217/// Returns an error if input operations fail, input is too long, or contains invalid characters
218pub fn prompt_for_input_with_io<T: InputOutput>(prompt: &str, io: &T) -> Result<String, Error> {
219    prompt_for_input_with_io_and_timeout(prompt, io, INPUT_TIMEOUT)
220}
221
222/// Testable version of `prompt_for_input` with configurable timeout
223///
224/// # Errors
225/// Returns an error if input operations fail, input is too long, contains invalid characters, or times out
226pub fn prompt_for_input_with_io_and_timeout<T: InputOutput>(
227    prompt: &str,
228    io: &T,
229    timeout: Duration,
230) -> Result<String, Error> {
231    io.print(prompt)?;
232    io.flush()?;
233
234    let input = io.read_line_with_timeout(timeout)?;
235    let trimmed_input = input.trim();
236
237    // Validate input length
238    if trimmed_input.len() > MAX_INPUT_LENGTH {
239        return Err(Error::interactive_input_too_long(MAX_INPUT_LENGTH));
240    }
241
242    // Sanitize input - check for control characters
243    let control_chars: Vec<char> = trimmed_input
244        .chars()
245        .filter(|c| c.is_control() && *c != '\t')
246        .collect();
247    if !control_chars.is_empty() {
248        let control_chars_str = control_chars
249            .iter()
250            .map(|c| format!("U+{:04X}", *c as u32))
251            .collect::<Vec<_>>()
252            .join(", ");
253        return Err(Error::interactive_invalid_characters(
254            &control_chars_str,
255            "Remove control characters and use only printable text",
256        ));
257    }
258
259    Ok(trimmed_input.to_string())
260}
261
262/// Testable version of `select_from_options` that accepts an `InputOutput` trait
263///
264/// # Errors
265/// Returns an error if no options provided, input operations fail, or maximum retries exceeded
266pub fn select_from_options_with_io<T: InputOutput>(
267    prompt: &str,
268    options: &[(String, String)],
269    io: &T,
270) -> Result<String, Error> {
271    select_from_options_with_io_and_timeout(prompt, options, io, INPUT_TIMEOUT)
272}
273
274/// Testable version of `select_from_options` with configurable timeout
275///
276/// # Errors
277/// Returns an error if no options provided, input operations fail, maximum retries exceeded, or timeout occurs
278pub fn select_from_options_with_io_and_timeout<T: InputOutput>(
279    prompt: &str,
280    options: &[(String, String)],
281    io: &T,
282    timeout: Duration,
283) -> Result<String, Error> {
284    if options.is_empty() {
285        return Err(Error::invalid_config("No options available for selection"));
286    }
287
288    io.println(prompt)?;
289    for (i, (key, description)) in options.iter().enumerate() {
290        io.println(&format!("  {}: {} - {}", i + 1, key, description))?;
291    }
292
293    for attempt in 1..=MAX_RETRIES {
294        let selection = prompt_for_input_with_io_and_timeout(
295            "Enter your choice (number or name): ",
296            io,
297            timeout,
298        )?;
299
300        // Handle empty input - prompt to continue or cancel
301        let proceed_with_selection = handle_empty_selection(&selection, io, timeout)?;
302        match proceed_with_selection {
303            EmptySelectionResult::Continue => continue,
304            EmptySelectionResult::Cancel => {
305                return Err(Error::invalid_config("Selection cancelled by user"))
306            }
307            EmptySelectionResult::ProcessSelection => {} // Fall through to process selection
308        }
309
310        // Try parsing as a number first - use match to avoid nested if
311        match selection.parse::<usize>() {
312            Ok(num) if num > 0 && num <= options.len() => {
313                return Ok(options[num - 1].0.clone());
314            }
315            _ => {
316                // Invalid number or parse error, fall through to name matching
317            }
318        }
319
320        // Try matching by name (case insensitive)
321        let selection_lower = selection.to_lowercase();
322        for (key, _) in options {
323            if key.to_lowercase() == selection_lower {
324                return Ok(key.clone());
325            }
326        }
327
328        if attempt < MAX_RETRIES {
329            io.println(&format!(
330                "Invalid selection. Please enter a number (1-{}) or a valid name. (Attempt {attempt} of {MAX_RETRIES})",
331                options.len()
332            ))?;
333        }
334    }
335
336    let suggestions = vec![
337        format!(
338            "Valid options: {}",
339            options
340                .iter()
341                .map(|(k, _)| k.clone())
342                .collect::<Vec<_>>()
343                .join(", ")
344        ),
345        "You can enter either a number or the exact name".to_string(),
346        "Leave empty and answer 'no' to cancel the operation".to_string(),
347    ];
348    Err(Error::interactive_retries_exhausted(
349        MAX_RETRIES,
350        "Invalid selection",
351        &suggestions,
352    ))
353}
354
355/// Testable version of `confirm` that accepts an `InputOutput` trait
356///
357/// # Errors
358/// Returns an error if input operations fail or maximum retries exceeded
359pub fn confirm_with_io<T: InputOutput>(prompt: &str, io: &T) -> Result<bool, Error> {
360    confirm_with_io_and_timeout(prompt, io, INPUT_TIMEOUT)
361}
362
363/// Testable version of `confirm` with configurable timeout
364///
365/// # Errors
366/// Returns an error if input operations fail, maximum retries exceeded, or timeout occurs
367pub fn confirm_with_io_and_timeout<T: InputOutput>(
368    prompt: &str,
369    io: &T,
370    timeout: Duration,
371) -> Result<bool, Error> {
372    for attempt in 1..=MAX_RETRIES {
373        let response =
374            prompt_for_input_with_io_and_timeout(&format!("{prompt} (y/n): "), io, timeout)?;
375
376        // Handle empty input as cancellation
377        if response.is_empty() {
378            return Ok(false);
379        }
380
381        match response.to_lowercase().as_str() {
382            "y" | "yes" => return Ok(true),
383            "n" | "no" => return Ok(false),
384            _ => {
385                if attempt < MAX_RETRIES {
386                    io.println(&format!(
387                        "Please enter 'y' for yes or 'n' for no. (Attempt {attempt} of {MAX_RETRIES})"
388                    ))?;
389                }
390            }
391        }
392    }
393
394    let suggestions = vec![
395        "Valid responses: 'y', 'yes', 'n', 'no' (case insensitive)".to_string(),
396        "Leave empty to default to 'no'".to_string(),
397    ];
398    Err(Error::interactive_retries_exhausted(
399        MAX_RETRIES,
400        "Invalid confirmation response",
401        &suggestions,
402    ))
403}
404
405/// Prompts for confirmation to exit/cancel an interactive session
406///
407/// # Errors
408/// Returns an error if stdin operations fail
409pub fn confirm_exit() -> Result<bool, Error> {
410    // ast-grep-ignore: no-println
411    println!("\nInteractive session interrupted.");
412    confirm("Do you want to exit without saving changes?")
413}
414
415/// Checks if the user wants to cancel the current operation
416/// This is called when empty input is provided as a cancellation signal
417///
418/// # Errors
419/// Returns an error if the confirmation input operation fails
420pub fn handle_cancellation_input() -> Result<bool, Error> {
421    // ast-grep-ignore: no-println
422    println!("Empty input detected. This will cancel the current operation.");
423    confirm("Do you want to continue with the current operation?")
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::constants;
430
431    #[test]
432    fn test_select_from_options_empty() {
433        let options = vec![];
434        let result = select_from_options("Choose:", &options);
435        assert!(result.is_err());
436    }
437
438    #[test]
439    fn test_select_from_options_structure() {
440        let options = [
441            (
442                "bearerAuth".to_string(),
443                "Bearer token authentication".to_string(),
444            ),
445            (
446                constants::AUTH_SCHEME_APIKEY.to_string(),
447                "API key authentication".to_string(),
448            ),
449        ];
450
451        // Test that the function accepts the correct input structure
452        // We can't test actual user input without mocking stdin
453        assert_eq!(options.len(), 2);
454        assert_eq!(options[0].0, "bearerAuth");
455        assert_eq!(options[1].0, constants::AUTH_SCHEME_APIKEY);
456    }
457
458    #[test]
459    fn test_validate_env_var_name_valid() {
460        assert!(validate_env_var_name("API_TOKEN").is_ok());
461        assert!(validate_env_var_name("MY_SECRET").is_ok());
462        assert!(validate_env_var_name("_PRIVATE_KEY").is_ok());
463        assert!(validate_env_var_name("TOKEN123").is_ok());
464        assert!(validate_env_var_name("a").is_ok());
465    }
466
467    #[test]
468    fn test_validate_env_var_name_empty() {
469        let result = validate_env_var_name("");
470        assert!(result.is_err());
471        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
472    }
473
474    #[test]
475    fn test_validate_env_var_name_too_long() {
476        let long_name = "A".repeat(MAX_INPUT_LENGTH + 1);
477        let result = validate_env_var_name(&long_name);
478        assert!(result.is_err());
479        assert!(result.unwrap_err().to_string().contains("too long"));
480    }
481
482    #[test]
483    fn test_validate_env_var_name_reserved() {
484        let result = validate_env_var_name("PATH");
485        assert!(result.is_err());
486        assert!(result.unwrap_err().to_string().contains("reserved"));
487
488        let result = validate_env_var_name("path"); // case insensitive
489        assert!(result.is_err());
490        assert!(result.unwrap_err().to_string().contains("reserved"));
491    }
492
493    #[test]
494    fn test_validate_env_var_name_invalid_start() {
495        let result = validate_env_var_name("123_TOKEN");
496        assert!(result.is_err());
497        assert!(result
498            .unwrap_err()
499            .to_string()
500            .contains("start with a letter"));
501
502        let result = validate_env_var_name("-TOKEN");
503        assert!(result.is_err());
504        assert!(result
505            .unwrap_err()
506            .to_string()
507            .contains("start with a letter"));
508    }
509
510    #[test]
511    fn test_validate_env_var_name_invalid_characters() {
512        let result = validate_env_var_name("API-TOKEN");
513        assert!(result.is_err());
514        let error_msg = result.unwrap_err().to_string();
515        assert!(
516            error_msg.contains("Invalid characters") || error_msg.contains("invalid characters")
517        );
518
519        let result = validate_env_var_name("API.TOKEN");
520        assert!(result.is_err());
521        let error_msg = result.unwrap_err().to_string();
522        assert!(
523            error_msg.contains("Invalid characters") || error_msg.contains("invalid characters")
524        );
525
526        let result = validate_env_var_name("API TOKEN");
527        assert!(result.is_err());
528        let error_msg = result.unwrap_err().to_string();
529        assert!(
530            error_msg.contains("Invalid characters") || error_msg.contains("invalid characters")
531        );
532    }
533}