aperture_cli/interactive/
mod.rs1use crate::error::Error;
2use std::time::Duration;
3
4pub mod mock;
5
6use mock::InputOutput;
7
8const MAX_INPUT_LENGTH: usize = 1024;
10
11const MAX_RETRIES: usize = 3;
13
14const INPUT_TIMEOUT: Duration = Duration::from_secs(30);
16
17enum EmptySelectionResult {
19 Continue,
21 Cancel,
23 ProcessSelection,
25}
26
27fn 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
50const 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
71pub fn prompt_for_input(prompt: &str) -> Result<String, Error> {
77 let io = mock::RealInputOutput;
78 prompt_for_input_with_io(prompt, &io)
79}
80
81pub 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
91pub 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
101pub 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
115pub fn confirm(prompt: &str) -> Result<bool, Error> {
120 let io = mock::RealInputOutput;
121 confirm_with_io(prompt, &io)
122}
123
124pub 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
133pub fn validate_env_var_name(name: &str) -> Result<(), Error> {
138 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 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 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 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 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
214pub 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
222pub 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 if trimmed_input.len() > MAX_INPUT_LENGTH {
239 return Err(Error::interactive_input_too_long(MAX_INPUT_LENGTH));
240 }
241
242 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
262pub 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
274pub 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 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 => {} }
309
310 match selection.parse::<usize>() {
312 Ok(num) if num > 0 && num <= options.len() => {
313 return Ok(options[num - 1].0.clone());
314 }
315 _ => {
316 }
318 }
319
320 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
355pub 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
363pub 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 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
405pub fn confirm_exit() -> Result<bool, Error> {
410 println!("\nInteractive session interrupted.");
412 confirm("Do you want to exit without saving changes?")
413}
414
415pub fn handle_cancellation_input() -> Result<bool, Error> {
421 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 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"); 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}