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