Skip to main content

metarepo_core/
interactive.rs

1use anyhow::{anyhow, Result};
2use console::style;
3use dialoguer::{Confirm, Input, MultiSelect, Select};
4use std::io::{self, IsTerminal};
5
6/// Controls behavior when running in non-interactive mode
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum NonInteractiveMode {
9    /// Fail with an error when required input is missing
10    Fail,
11    /// Use sensible defaults for missing inputs (only for optional args)
12    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
30/// Detects if we're running in an interactive TTY
31pub fn is_interactive() -> bool {
32    io::stdin().is_terminal() && io::stdout().is_terminal()
33}
34
35/// Prompts for a required text input
36///
37/// # Arguments
38/// * `prompt` - The prompt text to display
39/// * `default` - Optional default value
40/// * `allow_empty` - Whether to allow empty input
41/// * `non_interactive` - How to behave when not in a TTY
42///
43/// # Examples
44/// ```no_run
45/// # use metarepo_core::interactive::*;
46/// # fn main() -> anyhow::Result<()> {
47/// let name = prompt_text(
48///     "Project name",
49///     None,
50///     false,
51///     NonInteractiveMode::Fail,
52/// )?;
53/// # Ok(())
54/// # }
55/// ```
56pub 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                // Handle Ctrl+D or EOF
84                return Err(anyhow!("Cancelled by user"));
85            }
86            Err(e) => return Err(e.into()),
87        }
88    }
89}
90
91/// Prompts for a URL input with optional validation
92///
93/// # Arguments
94/// * `prompt` - The prompt text
95/// * `default` - Optional default value
96/// * `required` - Whether the input is required
97/// * `non_interactive` - How to behave when not in a TTY
98pub 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                    // Basic URL validation
142                    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
164/// Prompts for a yes/no confirmation
165///
166/// # Arguments
167/// * `prompt` - The prompt text
168/// * `default` - Default value if user just presses enter
169/// * `non_interactive` - How to behave when not in a TTY
170pub 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
198/// Prompts for a single selection from a list
199///
200/// # Arguments
201/// * `prompt` - The prompt text
202/// * `items` - List of items to choose from
203/// * `default_index` - Index of the default selection
204/// * `non_interactive` - How to behave when not in a TTY
205pub 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
241/// Prompts for multiple selections from a list
242///
243/// # Arguments
244/// * `prompt` - The prompt text
245/// * `items` - List of items to choose from
246/// * `defaults` - Indices of default selections
247/// * `non_interactive` - How to behave when not in a TTY
248pub 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
290// ============================================================================
291// Private helper functions
292// ============================================================================
293
294/// Handles input when not in interactive mode
295fn 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
316/// Handles selection when not in interactive mode
317fn 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
343/// Handles multi-select when not in interactive mode
344fn 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
377/// Validates a URL format (basic check)
378fn 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
389/// Checks if an error is due to EOF (Ctrl+D)
390fn 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}