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
17const 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
38pub fn prompt_for_input(prompt: &str) -> Result<String, Error> {
44 let io = mock::RealInputOutput;
45 prompt_for_input_with_io(prompt, &io)
46}
47
48pub 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
58pub 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
68pub 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
82pub fn confirm(prompt: &str) -> Result<bool, Error> {
87 let io = mock::RealInputOutput;
88 confirm_with_io(prompt, &io)
89}
90
91pub 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
100pub fn validate_env_var_name(name: &str) -> Result<(), Error> {
105 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 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 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 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 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
181pub 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
189pub 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 if trimmed_input.len() > MAX_INPUT_LENGTH {
206 return Err(Error::interactive_input_too_long(MAX_INPUT_LENGTH));
207 }
208
209 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
229pub 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
241pub 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 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 continue;
278 }
279
280 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 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
322pub 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
330pub 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 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
372pub fn confirm_exit() -> Result<bool, Error> {
377 println!("\nInteractive session interrupted.");
378 confirm("Do you want to exit without saving changes?")
379}
380
381pub 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 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"); 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}