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