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::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 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 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 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 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
182pub 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
190pub 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 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 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
235pub 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
247pub 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 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 continue;
288 }
289
290 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 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
332pub 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
340pub 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 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
382pub fn confirm_exit() -> Result<bool, Error> {
387 println!("\nInteractive session interrupted.");
388 confirm("Do you want to exit without saving changes?")
389}
390
391pub 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 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"); 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}