Skip to main content

script_wizard/
ask.rs

1use std::process::Command;
2
3use chrono::{NaiveDate, Weekday};
4use clap::ValueEnum;
5use inquire::{
6    autocompletion::Replacement, error::CustomUserError, Confirm, DateSelect, Editor, InquireError,
7    MultiSelect, Select, Text,
8};
9
10#[derive(Clone, ValueEnum)]
11pub enum Confirmation {
12    Yes,
13    No,
14}
15
16fn read_json_array(json: &str) -> Result<Vec<String>, CustomUserError> {
17    let a: Vec<String> = serde_json::from_str(json).expect("invalid json array");
18    Ok(a)
19}
20
21#[derive(Clone, Default)]
22pub struct AskAutoCompleter {
23    input: String,
24    suggestions_json: String,
25    suggestions: Vec<String>,
26    suggestion_index: usize,
27}
28
29impl AskAutoCompleter {
30    fn update_input(&mut self, input: &str) -> Result<(), CustomUserError> {
31        if input == self.input {
32            // No change:
33            return Ok(());
34        }
35        self.input = input.to_string();
36        self.suggestion_index = 0;
37        Ok(())
38    }
39}
40
41impl inquire::Autocomplete for AskAutoCompleter {
42    fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, CustomUserError> {
43        self.update_input(input)?;
44        self.suggestions = read_json_array(&self.suggestions_json)
45            .expect("Couldn't parse suggestions")
46            .iter()
47            .filter(|s| s.to_lowercase().contains(&input.to_lowercase()))
48            .map(|s| String::from(s.clone()))
49            .collect();
50        Ok(self.suggestions.clone())
51    }
52
53    fn get_completion(
54        &mut self,
55        input: &str,
56        highlighted_suggestion: Option<String>,
57    ) -> Result<Replacement, CustomUserError> {
58        self.update_input(input)?;
59        match highlighted_suggestion {
60            Some(suggestion) => Ok(Replacement::Some(suggestion)),
61            None => {
62                if self.suggestions.len() > 0 {
63                    self.suggestion_index = (self.suggestion_index + 1) % self.suggestions.len();
64                    Ok(Replacement::Some(
65                        self.suggestions
66                            .get(self.suggestion_index)
67                            .unwrap()
68                            .to_string(),
69                    ))
70                } else {
71                    Ok(Replacement::None)
72                }
73            }
74        }
75    }
76}
77
78pub fn ask_prompt(
79    question: &str,
80    default: &str,
81    allow_blank: bool,
82    suggestions_json: &str,
83    cancel_code: u8,
84) -> String {
85    if question == "" {
86        panic!("Blank question")
87    }
88    let mut auto_completer = AskAutoCompleter::default();
89    auto_completer.suggestions_json = suggestions_json.to_string();
90    match allow_blank {
91        true => {
92            let r: Result<String, InquireError>;
93            match default {
94                "" => {
95                    r = Text::new(question)
96                        .with_autocomplete(auto_completer.clone())
97                        .prompt();
98                }
99                _ => {
100                    r = Text::new(question)
101                        .with_autocomplete(auto_completer.clone())
102                        .with_default(default)
103                        .prompt();
104                }
105            }
106            if r.is_err() {
107                std::process::exit(cancel_code.into());
108            }
109            r.unwrap()
110        }
111        false => {
112            let mut a = String::from("");
113            while a == "" {
114                let r: Result<String, InquireError>;
115                match default {
116                    "" => {
117                        r = Text::new(question)
118                            .with_autocomplete(auto_completer.clone())
119                            .prompt();
120                    }
121                    _ => {
122                        r = Text::new(question)
123                            .with_default(default)
124                            .with_autocomplete(auto_completer.clone())
125                            .prompt();
126                    }
127                }
128                if r.is_err() {
129                    std::process::exit(cancel_code.into());
130                }
131                a = r.unwrap();
132            }
133            a
134        }
135    }
136}
137
138#[macro_export]
139macro_rules! ask {
140    ($question: expr, $default: expr, $allow_blank: expr, $suggestions_json: expr, $cancel_code: expr) => {
141        ask::ask_prompt($question, $default, $allow_blank, $suggestions_json, $cancel_code)
142    };
143    ($question: expr, $default: expr, $allow_blank: expr, $suggestions_json: expr) => {
144        ask::ask_prompt($question, $default, $allow_blank, $suggestions_json, 1)
145    };
146    ($question: expr, $default: expr, $allow_blank: expr) => {
147        ask::ask_prompt($question, $default, $allow_blank, "", 1)
148    };
149    ($question: expr, $default: expr) => {
150        ask::ask_prompt($question, $default, false, "", 1)
151    };
152    ($question: expr) => {
153        ask::ask_prompt($question, "", false, "", 1)
154    };
155}
156pub use ask;
157
158pub fn confirm(question: &str, default_answer: Option<Confirmation>, cancel_code: u8) -> bool {
159    let mut c = Confirm::new(question);
160    match default_answer {
161        Some(Confirmation::Yes) => c = c.with_default(true),
162        Some(Confirmation::No) => c = c.with_default(false),
163        _ => (),
164    }
165    match c.prompt() {
166        Ok(true) => true,
167        Ok(false) => false,
168        Err(_) => std::process::exit(cancel_code.into()),
169    }
170}
171
172pub fn choose(
173    question: &str,
174    default: &str,
175    options: Vec<&str>,
176    numeric: &bool,
177    cancel_code: u8,
178) -> String {
179    // Resolve the default to a starting cursor index.
180    //   --numeric mode: default is an index into `options`.
181    //   value mode:     default is matched against option strings.
182    // Always clamp to the valid range so an out-of-bounds default doesn't
183    // cause inquire to Err immediately at startup (previously: default="8"
184    // on a 5-item list crashed the pod without user input, since inquire's
185    // Select rejects with_starting_cursor > options.len()).
186    let raw_index: usize = if *numeric {
187        default.trim().parse::<usize>().unwrap_or(0)
188    } else {
189        options
190            .iter()
191            .position(|&r| r == default)
192            .unwrap_or(0)
193    };
194    let default_index = raw_index.min(options.len().saturating_sub(1));
195    let ans: Result<&str, InquireError> = Select::new(question, options.clone())
196        .with_starting_cursor(default_index)
197        .with_help_message("up/down to move, enter to select, type to filter, ESC to cancel")
198        .prompt();
199    match ans {
200        Ok(selection) => match numeric {
201            true => {
202                let index = options.iter().position(|&r| r == selection).unwrap();
203                format!("{}", index)
204            }
205            false => String::from(selection),
206        },
207        Err(_) => std::process::exit(cancel_code.into()),
208    }
209}
210
211pub fn select(question: &str, default: &str, options: Vec<&str>, cancel_code: u8) -> Vec<String> {
212    let defaults: Vec<&str> = serde_json::from_str(default).unwrap_or(vec![]);
213    let mut default_indices = vec![];
214    for (index, item) in options.iter().enumerate() {
215        match defaults.iter().find(|&r| r == item) {
216            Some(_) => default_indices.append(&mut vec![index]),
217            None => {}
218        };
219    }
220    let ans = MultiSelect::new(question, options)
221        .with_default(&default_indices)
222        .with_help_message("spacebar: toggle one, right/left: select all/none, type to filter, ESC to cancel")
223        .prompt();
224    match ans {
225        Ok(selection) => selection.iter().map(|&x| x.into()).collect(),
226        Err(_) => std::process::exit(cancel_code.into()),
227    }
228}
229
230pub fn date(
231    question: &str,
232    default: &str,
233    min_date: &str,
234    max_date: &str,
235    starting_date: &str,
236    week_start: Weekday,
237    help_message: &str,
238    date_format: &str,
239    cancel_code: u8,
240) -> String {
241    let mut picker = DateSelect::new(question)
242        .with_starting_date(
243            NaiveDate::parse_from_str(default, date_format)
244                .unwrap_or(chrono::Local::now().naive_local().into()),
245        )
246        .with_min_date(NaiveDate::parse_from_str(min_date, date_format).unwrap_or(NaiveDate::MIN))
247        .with_max_date(NaiveDate::parse_from_str(max_date, date_format).unwrap_or(NaiveDate::MAX))
248        .with_week_start(week_start)
249        .with_help_message(help_message);
250    if let Ok(d) = NaiveDate::parse_from_str(starting_date, date_format) {
251        picker = picker.with_starting_date(d);
252    }
253    match picker.prompt() {
254        Ok(date) => date.format(date_format).to_string(),
255        Err(_) => std::process::exit(cancel_code.into()),
256    }
257}
258
259pub fn editor(message: &str, default: &str, help_message: &str, file_extension: &str, cancel_code: u8) -> String {
260    let ans = Editor::new(message)
261        .with_predefined_text(default)
262        .with_help_message(help_message)
263        .with_file_extension(file_extension)
264        .prompt();
265    match ans {
266        Ok(text) => text,
267        Err(_) => std::process::exit(cancel_code.into()),
268    }
269}
270
271pub fn menu(
272    heading: &str,
273    entries: &Vec<String>,
274    default: &Option<String>,
275    once: &bool,
276    cancel_code: u8,
277) -> Result<usize, u8> {
278    if cfg!(target_os = "windows") {
279        eprintln!("Error: the 'menu' subcommand is not supported on Windows.");
280        eprintln!("Use 'choose' with --numeric to implement your own menu loop.");
281        std::process::exit(1);
282    }
283    let mut new_default: String = default.clone().unwrap_or("".to_string());
284    loop {
285        eprintln!("");
286        let titles: Vec<&str> = entries
287            .iter()
288            .map(|e| e.split(" = ").collect::<Vec<&str>>()[0])
289            .collect();
290        let commands: Vec<&str> = entries
291            .iter()
292            .map(|e| e.split(" = ").collect::<Vec<&str>>()[1])
293            .collect();
294        let command_index = choose(heading, new_default.as_str(), titles, &true, cancel_code)
295            .parse::<usize>()
296            .unwrap_or(1);
297
298        new_default = command_index.to_string();
299
300        // Run the command:
301        let cmd = commands[command_index];
302        let status = Command::new("/bin/bash")
303            .args(["-c", cmd])
304            .status()
305            .unwrap();
306
307        match status.code().unwrap_or(1) {
308            0 => {
309                //Keep looping unless --once is given:
310                if *once {
311                    return Ok(0);
312                }
313            }
314            2 => {
315                // Ok(2) signals to quit the loop:
316                return Ok(2);
317            }
318            _ => {
319                return Err(1);
320            }
321        }
322    }
323}