cargo_generate/
interactive.rs

1use crate::{
2    emoji,
3    project_variables::{ArrayEntry, Prompt, StringEntry, StringKind, TemplateSlots, VarInfo},
4};
5use anyhow::{anyhow, bail, Result};
6use console::style;
7use dialoguer::{theme::ColorfulTheme, MultiSelect, Select};
8use dialoguer::{Editor, Input};
9use liquid_core::Value;
10use log::warn;
11use std::{
12    borrow::Cow,
13    io::{stdin, Read},
14    ops::Index,
15    str::FromStr,
16};
17
18pub const LIST_SEP: &str = ",";
19
20pub fn name() -> Result<String> {
21    let valid_ident = regex::Regex::new(r"^([a-zA-Z][a-zA-Z0-9_-]+)$")?;
22    let project_var = TemplateSlots {
23        var_name: "crate_name".into(),
24        prompt: "Project Name".into(),
25        var_info: VarInfo::String {
26            entry: Box::new(StringEntry {
27                default: None,
28                kind: StringKind::String,
29                regex: Some(valid_ident),
30            }),
31        },
32    };
33    prompt_and_check_variable(&project_var, None)
34}
35
36pub fn user_question(
37    prompt: &Prompt,
38    default: &Option<String>,
39    kind: &StringKind,
40) -> Result<String> {
41    match kind {
42        StringKind::String => {
43            let mut i = Input::<String>::new().with_prompt(&prompt.styled_with_default);
44            if let Some(s) = default {
45                i = i.default(s.to_owned());
46            }
47            i.interact().map_err(Into::<anyhow::Error>::into)
48        }
49        StringKind::Editor => {
50            println!("{} (in Editor)", prompt.styled_with_default);
51            Editor::new()
52                .edit(&prompt.with_default)?
53                .or_else(|| default.clone())
54                .ok_or(anyhow!("Aborted Editor without saving !"))
55        }
56        StringKind::Text => {
57            println!(
58                "{} (press Ctrl+d to stop reading)",
59                prompt.styled_with_default
60            );
61            let mut buffer = String::new();
62            stdin().read_to_string(&mut buffer)?;
63            Ok(buffer)
64        }
65        StringKind::Choices(_) => {
66            unreachable!("StringKind::Choices should be handled in the parent")
67        }
68    }
69}
70
71pub fn prompt_and_check_variable(
72    variable: &TemplateSlots,
73    provided_value: Option<String>,
74) -> Result<String> {
75    match &variable.var_info {
76        VarInfo::Bool { default } => handle_bool_input(provided_value, &variable.prompt, default),
77        VarInfo::String { entry } => match &entry.kind {
78            StringKind::Choices(choices) => handle_choice_input(
79                provided_value,
80                &variable.var_name,
81                choices,
82                entry,
83                &variable.prompt,
84            ),
85            StringKind::String | StringKind::Text | StringKind::Editor => {
86                handle_string_input(provided_value, &variable.var_name, entry, &variable.prompt)
87            }
88        },
89        VarInfo::Array { entry } => {
90            handle_multi_select_input(provided_value, &variable.var_name, entry, &variable.prompt)
91        }
92    }
93}
94
95pub fn variable(variable: &TemplateSlots, provided_value: Option<&impl ToString>) -> Result<Value> {
96    let user_entry = prompt_and_check_variable(variable, provided_value.map(|v| v.to_string()))?;
97    match &variable.var_info {
98        VarInfo::Bool { .. } => {
99            let as_bool = user_entry.parse::<bool>()?;
100            Ok(Value::Scalar(as_bool.into()))
101        }
102        VarInfo::String { .. } => Ok(Value::Scalar(user_entry.into())),
103        VarInfo::Array { .. } => {
104            let items = if user_entry.is_empty() {
105                Vec::new()
106            } else {
107                user_entry
108                    .split(LIST_SEP)
109                    .map(|s| Value::Scalar(s.to_string().into()))
110                    .collect()
111            };
112
113            Ok(Value::Array(items))
114        }
115    }
116}
117
118fn handle_string_input(
119    provided_value: Option<String>,
120    var_name: &str,
121    entry: &StringEntry,
122    prompt: &Prompt,
123) -> Result<String> {
124    if let Some(value) = provided_value {
125        if entry
126            .regex
127            .as_ref()
128            .map(|ex| ex.is_match(&value))
129            .unwrap_or(true)
130        {
131            return Ok(value);
132        }
133        bail!(
134            "{} {} \"{}\" {}",
135            emoji::WARN,
136            style("Sorry,").bold().red(),
137            style(&value).bold().yellow(),
138            style(format!("is not a valid value for {var_name}"))
139                .bold()
140                .red()
141        )
142    };
143    let mut prompt: Cow<'_, Prompt> = Cow::Borrowed(prompt);
144    match &entry.regex {
145        Some(regex) => loop {
146            let user_entry = user_question(&prompt, &entry.default, &entry.kind)?;
147            if regex.is_match(&user_entry) {
148                break Ok(user_entry);
149            }
150            // the user won't see the error in stdout if in a editor
151            match entry.kind {
152                StringKind::Editor => {
153                    // Editor use with_default
154                    prompt.to_mut().with_default = format!(
155                        "{}: \"{user_entry}\" is not a valid value for `{var_name}`",
156                        prompt
157                            .with_default
158                            .split_once(':')
159                            .map(|t| t.0)
160                            .unwrap_or(&prompt.with_default)
161                    );
162                }
163                _ => {
164                    warn!(
165                        "{} \"{}\" {}",
166                        style("Sorry,").bold().red(),
167                        style(&user_entry).bold().yellow(),
168                        style(format!("is not a valid value for {var_name}"))
169                            .bold()
170                            .red()
171                    );
172                }
173            };
174        },
175        None => Ok(user_question(&prompt, &entry.default, &entry.kind)?),
176    }
177}
178
179fn handle_choice_input(
180    provided_value: Option<String>,
181    var_name: &str,
182    choices: &Vec<String>,
183    entry: &StringEntry,
184    prompt: &Prompt,
185) -> Result<String> {
186    match provided_value {
187        Some(value) => {
188            if choices.contains(&value) {
189                Ok(value)
190            } else {
191                bail!(
192                    "{} {} \"{}\" {}",
193                    emoji::WARN,
194                    style("Sorry,").bold().red(),
195                    style(&value).bold().yellow(),
196                    style(format!("is not a valid value for {var_name}"))
197                        .bold()
198                        .red(),
199                )
200            }
201        }
202        None => {
203            let default = entry
204                .default
205                .as_ref()
206                .map_or(0, |default| choices.binary_search(default).unwrap_or(0));
207            let chosen = Select::with_theme(&ColorfulTheme::default())
208                .items(choices)
209                .with_prompt(&prompt.styled)
210                .default(default)
211                .interact()?;
212
213            Ok(choices.index(chosen).to_string())
214        }
215    }
216}
217
218// simple function so we can easily get more complicated later if we need to
219fn parse_list(provided_value: &str) -> Vec<String> {
220    provided_value
221        .split(LIST_SEP)
222        .filter(|e| !e.is_empty())
223        .map(|s| s.to_string())
224        .collect()
225}
226
227fn check_provided_selections(
228    provided_value: &str,
229    choices: &[String],
230) -> Result<Vec<String>, Vec<String>> {
231    let list = parse_list(provided_value);
232    if list.is_empty() {
233        return Ok(Vec::new());
234    }
235    let (ok_entries, bad_entries): (Vec<String>, Vec<String>) =
236        list.iter().cloned().partition(|e| choices.contains(e));
237    if bad_entries.is_empty() {
238        Ok(ok_entries)
239    } else {
240        Err(bad_entries)
241    }
242}
243
244fn handle_multi_select_input(
245    provided_value: Option<String>,
246    var_name: &str,
247    entry: &ArrayEntry,
248    prompt: &Prompt,
249) -> Result<String> {
250    let val = match provided_value {
251        // value is just provided
252        Some(value) => value,
253        // no value is provided so we have to be smarter
254        None => {
255            let mut selected_by_default = Vec::<bool>::with_capacity(entry.choices.len());
256            match &entry.default {
257                // if no defaults are provided everything is disselected by default
258                None => {
259                    selected_by_default.resize(entry.choices.len(), false);
260                }
261                Some(default_choices) => {
262                    for choice in &entry.choices {
263                        selected_by_default.push(default_choices.contains(choice));
264                    }
265                }
266            };
267
268            let choice_indices = MultiSelect::with_theme(&ColorfulTheme::default())
269                .items(&entry.choices)
270                .with_prompt(&prompt.styled)
271                .defaults(&selected_by_default)
272                .interact()?;
273
274            choice_indices
275                .iter()
276                .filter_map(|idx| entry.choices.get(*idx))
277                .cloned()
278                .collect::<Vec<String>>()
279                .join(LIST_SEP)
280        }
281    };
282
283    match check_provided_selections(&val, &entry.choices) {
284        Ok(s) => Ok(s.join(LIST_SEP)),
285        Err(s) => {
286            let err_string = if s.len() > 1 {
287                format!("are not valid values for {var_name}")
288            } else {
289                format!("is not a valid value for {var_name}")
290            };
291
292            bail!(
293                "{} {} \"{}\" {}",
294                emoji::WARN,
295                style("Sorry,").bold().red(),
296                style(&s.join(LIST_SEP)).bold().yellow(),
297                style(err_string).bold().red(),
298            )
299        }
300    }
301}
302
303fn handle_bool_input(
304    provided_value: Option<String>,
305    prompt: &Prompt,
306    default: &Option<bool>,
307) -> Result<String> {
308    match provided_value {
309        Some(value) => {
310            let value = bool::from_str(&value.to_lowercase())?;
311            Ok(value.to_string())
312        }
313        None => {
314            let choices = [false.to_string(), true.to_string()];
315            let chosen = Select::with_theme(&ColorfulTheme::default())
316                .items(&choices)
317                .with_prompt(&prompt.styled)
318                .default(usize::from(default.unwrap_or(false)))
319                .interact()?;
320
321            Ok(choices.index(chosen).to_string())
322        }
323    }
324}