1use anyhow::{anyhow, Result};
2use console::style;
3use dialoguer::{Confirm, Input, MultiSelect, Select};
4use std::io::{self, IsTerminal};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum NonInteractiveMode {
9 Fail,
11 Defaults,
13}
14
15impl std::str::FromStr for NonInteractiveMode {
16 type Err = anyhow::Error;
17
18 fn from_str(s: &str) -> Result<Self> {
19 match s.to_lowercase().as_str() {
20 "fail" => Ok(NonInteractiveMode::Fail),
21 "defaults" => Ok(NonInteractiveMode::Defaults),
22 other => Err(anyhow!(
23 "Invalid non-interactive mode: '{}'. Use 'fail' or 'defaults'",
24 other
25 )),
26 }
27 }
28}
29
30pub fn is_interactive() -> bool {
32 io::stdin().is_terminal() && io::stdout().is_terminal()
33}
34
35pub fn prompt_text(
57 prompt: &str,
58 default: Option<&str>,
59 allow_empty: bool,
60 non_interactive: NonInteractiveMode,
61) -> Result<String> {
62 if !is_interactive() {
63 return handle_non_interactive(non_interactive, prompt, default.map(|s| s.to_string()));
64 }
65
66 loop {
67 let mut input = Input::<String>::new();
68 input = input.with_prompt(format!("{}", style(format!("→ {}", prompt)).cyan()));
69
70 if let Some(default_val) = default {
71 input = input.default(default_val.to_string());
72 }
73
74 match input.interact_text() {
75 Ok(value) => {
76 if !allow_empty && value.trim().is_empty() {
77 eprintln!("{}", style(" ✗ Input cannot be empty").red());
78 continue;
79 }
80 return Ok(value);
81 }
82 Err(e) if is_eof_error(&e) => {
83 return Err(anyhow!("Cancelled by user"));
85 }
86 Err(e) => return Err(e.into()),
87 }
88 }
89}
90
91pub fn prompt_url(
99 prompt: &str,
100 default: Option<&str>,
101 required: bool,
102 non_interactive: NonInteractiveMode,
103) -> Result<Option<String>> {
104 if !is_interactive() {
105 let result =
106 handle_non_interactive(non_interactive, prompt, default.map(|s| s.to_string()));
107 match result {
108 Ok(val) => {
109 if val.is_empty() && !required {
110 return Ok(None);
111 }
112 Ok(Some(val))
113 }
114 Err(e) => Err(e),
115 }
116 } else {
117 let label = if required {
118 prompt.to_string()
119 } else {
120 format!("{} (optional)", prompt)
121 };
122
123 loop {
124 let mut input = Input::<String>::new();
125 input = input.with_prompt(format!("{}", style(format!("→ {}", label)).cyan()));
126
127 if let Some(default_val) = default {
128 input = input.default(default_val.to_string());
129 }
130
131 match input.interact_text() {
132 Ok(value) => {
133 if value.trim().is_empty() {
134 if !required {
135 return Ok(None);
136 }
137 eprintln!("{}", style(" ✗ URL cannot be empty").red());
138 continue;
139 }
140
141 if !is_valid_url(&value) {
143 eprintln!(
144 "{}",
145 style(
146 " ✗ Invalid URL format. Expected http(s)://, git@, or file path"
147 )
148 .red()
149 );
150 continue;
151 }
152
153 return Ok(Some(value));
154 }
155 Err(e) if is_eof_error(&e) => {
156 return Err(anyhow!("Cancelled by user"));
157 }
158 Err(e) => return Err(e.into()),
159 }
160 }
161 }
162}
163
164pub fn prompt_confirm(
171 prompt: &str,
172 default: bool,
173 non_interactive: NonInteractiveMode,
174) -> Result<bool> {
175 if !is_interactive() {
176 match non_interactive {
177 NonInteractiveMode::Fail => {
178 Err(anyhow!(
179 "Interactive confirmation required for: '{}'. Use --non-interactive=defaults or provide --force",
180 prompt
181 ))
182 }
183 NonInteractiveMode::Defaults => Ok(default),
184 }
185 } else {
186 let confirm = Confirm::new()
187 .with_prompt(format!("{}", style(format!("→ {}", prompt)).cyan()))
188 .default(default)
189 .interact_opt()?;
190
191 match confirm {
192 Some(value) => Ok(value),
193 None => Err(anyhow!("Cancelled by user")),
194 }
195 }
196}
197
198pub fn prompt_select<S: Into<String>>(
206 prompt: &str,
207 items: Vec<S>,
208 default_index: Option<usize>,
209 non_interactive: NonInteractiveMode,
210) -> Result<String> {
211 let items: Vec<String> = items.into_iter().map(|s| s.into()).collect();
212
213 if !is_interactive() {
214 return handle_non_interactive_select(non_interactive, prompt, &items, default_index);
215 }
216
217 if items.is_empty() {
218 return Err(anyhow!("No items to select from"));
219 }
220
221 let select = Select::new();
222 let select = select.with_prompt(format!("{}", style(format!("→ {}", prompt)).cyan()));
223
224 let mut select_with_items = select;
225 for item in &items {
226 select_with_items = select_with_items.item(item);
227 }
228
229 if let Some(idx) = default_index {
230 if idx < items.len() {
231 select_with_items = select_with_items.default(idx);
232 }
233 }
234
235 match select_with_items.interact_opt()? {
236 Some(idx) => Ok(items[idx].clone()),
237 None => Err(anyhow!("Cancelled by user")),
238 }
239}
240
241pub fn prompt_multiselect<S: Into<String>>(
249 prompt: &str,
250 items: Vec<S>,
251 defaults: Vec<usize>,
252 non_interactive: NonInteractiveMode,
253) -> Result<Vec<String>> {
254 let items: Vec<String> = items.into_iter().map(|s| s.into()).collect();
255
256 if !is_interactive() {
257 return handle_non_interactive_multiselect(non_interactive, prompt, &items, defaults);
258 }
259
260 if items.is_empty() {
261 return Err(anyhow!("No items to select from"));
262 }
263
264 let select = MultiSelect::new();
265 let select = select.with_prompt(format!("{}", style(format!("→ {}", prompt)).cyan()));
266
267 let mut select_with_items = select;
268 for item in &items {
269 select_with_items = select_with_items.item(item);
270 }
271
272 for idx in defaults {
273 if idx < items.len() {
274 select_with_items = select_with_items.item_checked(idx, true);
275 }
276 }
277
278 match select_with_items.interact_opt()? {
279 Some(indices) => {
280 if indices.is_empty() {
281 Err(anyhow!("At least one item must be selected"))
282 } else {
283 Ok(indices.into_iter().map(|i| items[i].clone()).collect())
284 }
285 }
286 None => Err(anyhow!("Cancelled by user")),
287 }
288}
289
290fn handle_non_interactive(
296 mode: NonInteractiveMode,
297 prompt: &str,
298 default: Option<String>,
299) -> Result<String> {
300 match mode {
301 NonInteractiveMode::Fail => {
302 Err(anyhow!(
303 "Interactive input required for '{}' and no default provided. Use --non-interactive=defaults or provide the value explicitly",
304 prompt
305 ))
306 }
307 NonInteractiveMode::Defaults => {
308 default.ok_or_else(|| anyhow!(
309 "No default value available for '{}' in non-interactive mode",
310 prompt
311 ))
312 }
313 }
314}
315
316fn handle_non_interactive_select(
318 mode: NonInteractiveMode,
319 prompt: &str,
320 items: &[String],
321 default: Option<usize>,
322) -> Result<String> {
323 match mode {
324 NonInteractiveMode::Fail => {
325 Err(anyhow!(
326 "Interactive selection required for '{}'. Use --non-interactive=defaults or provide the value explicitly",
327 prompt
328 ))
329 }
330 NonInteractiveMode::Defaults => {
331 let idx = default.ok_or_else(|| anyhow!(
332 "No default selection available for '{}' in non-interactive mode",
333 prompt
334 ))?;
335 Ok(items
336 .get(idx)
337 .ok_or_else(|| anyhow!("Default index {} out of range", idx))?
338 .clone())
339 }
340 }
341}
342
343fn handle_non_interactive_multiselect(
345 mode: NonInteractiveMode,
346 prompt: &str,
347 items: &[String],
348 defaults: Vec<usize>,
349) -> Result<Vec<String>> {
350 match mode {
351 NonInteractiveMode::Fail => {
352 Err(anyhow!(
353 "Interactive selection required for '{}'. Use --non-interactive=defaults or provide values explicitly",
354 prompt
355 ))
356 }
357 NonInteractiveMode::Defaults => {
358 if defaults.is_empty() {
359 Err(anyhow!(
360 "No default selection available for '{}' in non-interactive mode",
361 prompt
362 ))
363 } else {
364 Ok(defaults
365 .iter()
366 .map(|idx| {
367 items
368 .get(*idx)
369 .ok_or_else(|| anyhow!("Default index {} out of range", idx)).cloned()
370 })
371 .collect::<Result<Vec<_>>>()?)
372 }
373 }
374 }
375}
376
377fn is_valid_url(url: &str) -> bool {
379 let url = url.trim();
380 url.starts_with("http://")
381 || url.starts_with("https://")
382 || url.starts_with("git@")
383 || url.starts_with("./")
384 || url.starts_with("../")
385 || url.starts_with("/")
386 || !url.contains(' ')
387}
388
389fn is_eof_error(error: &dyn std::error::Error) -> bool {
391 error.to_string().contains("EOF")
392 || error.to_string().contains("end of file")
393 || error.to_string().contains("Ctrl+D")
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn test_non_interactive_mode_parsing() {
402 assert_eq!(
403 "fail".parse::<NonInteractiveMode>().unwrap(),
404 NonInteractiveMode::Fail
405 );
406 assert_eq!(
407 "defaults".parse::<NonInteractiveMode>().unwrap(),
408 NonInteractiveMode::Defaults
409 );
410 assert_eq!(
411 "FAIL".parse::<NonInteractiveMode>().unwrap(),
412 NonInteractiveMode::Fail
413 );
414 assert!("invalid".parse::<NonInteractiveMode>().is_err());
415 }
416
417 #[test]
418 fn test_valid_url() {
419 assert!(is_valid_url("https://github.com/user/repo.git"));
420 assert!(is_valid_url("http://example.com"));
421 assert!(is_valid_url("git@github.com:user/repo.git"));
422 assert!(is_valid_url("./local/path"));
423 assert!(is_valid_url("../relative/path"));
424 assert!(is_valid_url("/absolute/path"));
425 assert!(!is_valid_url("invalid url with spaces"));
426 }
427
428 #[test]
429 fn test_handle_non_interactive_fail() {
430 let result = handle_non_interactive(NonInteractiveMode::Fail, "test", None);
431 assert!(result.is_err());
432 assert!(result
433 .unwrap_err()
434 .to_string()
435 .contains("Interactive input required"));
436 }
437
438 #[test]
439 fn test_handle_non_interactive_defaults() {
440 let result = handle_non_interactive(
441 NonInteractiveMode::Defaults,
442 "test",
443 Some("default_value".to_string()),
444 );
445 assert_eq!(result.unwrap(), "default_value");
446 }
447
448 #[test]
449 fn test_handle_non_interactive_defaults_no_default() {
450 let result = handle_non_interactive(NonInteractiveMode::Defaults, "test", None);
451 assert!(result.is_err());
452 }
453}