cargo_generate/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(
3    //clippy::cargo_common_metadata,
4    clippy::branches_sharing_code,
5    clippy::cast_lossless,
6    clippy::cognitive_complexity,
7    clippy::get_unwrap,
8    clippy::if_then_some_else_none,
9    clippy::inefficient_to_string,
10    clippy::match_bool,
11    clippy::missing_const_for_fn,
12    clippy::missing_panics_doc,
13    clippy::option_if_let_else,
14    clippy::redundant_closure,
15    clippy::redundant_else,
16    clippy::redundant_pub_crate,
17    clippy::ref_binding_to_reference,
18    clippy::ref_option_ref,
19    clippy::same_functions_in_if_condition,
20    clippy::unneeded_field_pattern,
21    clippy::unnested_or_patterns,
22    clippy::use_self,
23)]
24
25mod absolute_path;
26mod app_config;
27mod args;
28mod config;
29mod copy;
30mod emoji;
31mod favorites;
32mod filenames;
33mod git;
34mod hooks;
35mod ignore_me;
36mod include_exclude;
37mod interactive;
38mod progressbar;
39mod project_variables;
40mod template;
41mod template_filters;
42mod template_variables;
43mod user_parsed_input;
44mod workspace_member;
45
46pub use crate::app_config::{app_config_path, AppConfig};
47pub use crate::favorites::list_favorites;
48use crate::template::create_liquid_engine;
49pub use args::*;
50
51use anyhow::{anyhow, bail, Context, Result};
52use config::{locate_template_configs, Config, CONFIG_FILE_NAME};
53use console::style;
54use copy::{copy_files_recursively, LIQUID_SUFFIX};
55use env_logger::fmt::Formatter;
56use fs_err as fs;
57use hooks::{execute_hooks, RhaiHooksContext};
58use ignore_me::remove_dir_files;
59use interactive::{prompt_and_check_variable, LIST_SEP};
60use log::Record;
61use log::{info, warn};
62use project_variables::{StringEntry, StringKind, TemplateSlots, VarInfo};
63use std::{
64    cell::RefCell,
65    collections::HashMap,
66    env,
67    io::Write,
68    path::{Path, PathBuf},
69    sync::{Arc, Mutex},
70};
71use tempfile::TempDir;
72use user_parsed_input::{TemplateLocation, UserParsedInput};
73use workspace_member::WorkspaceMemberStatus;
74
75use crate::git::tmp_dir;
76use crate::template_variables::{
77    load_env_and_args_template_values, CrateName, ProjectDir, ProjectNameInput,
78};
79use crate::{project_variables::ConversionError, template_variables::ProjectName};
80
81use self::config::TemplateConfig;
82use self::git::try_get_branch_from_path;
83use self::hooks::evaluate_script;
84use self::template::{create_liquid_object, set_project_name_variables, LiquidObjectResource};
85
86/// Logging formatter function
87pub fn log_formatter(
88    buf: &mut Formatter,
89    record: &Record,
90) -> std::result::Result<(), std::io::Error> {
91    let prefix = match record.level() {
92        log::Level::Error => format!("{} ", emoji::ERROR),
93        log::Level::Warn => format!("{} ", emoji::WARN),
94        _ => "".to_string(),
95    };
96
97    writeln!(buf, "{}{}", prefix, record.args())
98}
99
100/// # Panics
101pub fn generate(args: GenerateArgs) -> Result<PathBuf> {
102    let _working_dir_scope = ScopedWorkingDirectory::default();
103
104    let app_config = AppConfig::try_from(app_config_path(&args.config)?.as_path())?;
105
106    // mash AppConfig and CLI arguments together into UserParsedInput
107    let mut user_parsed_input = UserParsedInput::try_from_args_and_config(app_config, &args);
108    // let ENV vars provide values we don't have yet
109    user_parsed_input
110        .template_values_mut()
111        .extend(load_env_and_args_template_values(&args)?);
112
113    let (template_base_dir, template_dir, branch) = prepare_local_template(&user_parsed_input)?;
114
115    // read configuration in the template
116    let mut config = Config::from_path(
117        &locate_template_file(CONFIG_FILE_NAME, &template_base_dir, &template_dir).ok(),
118    )?;
119
120    // the `--init` parameter may also be set by the template itself
121    if config
122        .template
123        .as_ref()
124        .and_then(|c| c.init)
125        .unwrap_or(false)
126        && !user_parsed_input.init
127    {
128        warn!(
129            "{}",
130            style("Template specifies --init, while not specified on the command line. Output location is affected!").bold().red(),
131        );
132
133        user_parsed_input.init = true;
134    };
135
136    check_cargo_generate_version(&config)?;
137
138    let project_dir = expand_template(&template_dir, &mut config, &user_parsed_input, &args)?;
139    let (mut should_initialize_git, with_force) = {
140        let vcs = &config
141            .template
142            .as_ref()
143            .and_then(|t| t.vcs)
144            .unwrap_or_else(|| user_parsed_input.vcs());
145
146        (
147            !vcs.is_none() && (!user_parsed_input.init || user_parsed_input.force_git_init()),
148            user_parsed_input.force_git_init(),
149        )
150    };
151
152    let target_path = if user_parsed_input.test() {
153        test_expanded_template(&template_dir, args.other_args)?
154    } else {
155        let project_path = copy_expanded_template(template_dir, project_dir, user_parsed_input)?;
156
157        match workspace_member::add_to_workspace(&project_path)? {
158            WorkspaceMemberStatus::Added(workspace_cargo_toml) => {
159                should_initialize_git = with_force;
160                info!(
161                    "{} {} `{}`",
162                    emoji::WRENCH,
163                    style("Project added as member to workspace").bold(),
164                    style(workspace_cargo_toml.display()).bold().yellow(),
165                );
166            }
167            WorkspaceMemberStatus::NoWorkspaceFound => {
168                // not an issue, just a notification
169            }
170        }
171
172        project_path
173    };
174
175    if should_initialize_git {
176        info!(
177            "{} {}",
178            emoji::WRENCH,
179            style("Initializing a fresh Git repository").bold()
180        );
181
182        git::init(&target_path, branch.as_deref(), with_force)?;
183    }
184
185    info!(
186        "{} {} {} {}",
187        emoji::SPARKLE,
188        style("Done!").bold().green(),
189        style("New project created").bold(),
190        style(&target_path.display()).underlined()
191    );
192
193    Ok(target_path)
194}
195
196fn copy_expanded_template(
197    template_dir: PathBuf,
198    project_dir: PathBuf,
199    user_parsed_input: UserParsedInput,
200) -> Result<PathBuf> {
201    info!(
202        "{} {} `{}`{}",
203        emoji::WRENCH,
204        style("Moving generated files into:").bold(),
205        style(project_dir.display()).bold().yellow(),
206        style("...").bold()
207    );
208    copy_files_recursively(template_dir, &project_dir, user_parsed_input.overwrite())?;
209
210    Ok(project_dir)
211}
212
213fn test_expanded_template(template_dir: &PathBuf, args: Option<Vec<String>>) -> Result<PathBuf> {
214    info!(
215        "{} {}{}{}",
216        emoji::WRENCH,
217        style("Running \"").bold(),
218        style("cargo test"),
219        style("\" ...").bold(),
220    );
221    std::env::set_current_dir(template_dir)?;
222    let (cmd, cmd_args) = std::env::var("CARGO_GENERATE_TEST_CMD").map_or_else(
223        |_| (String::from("cargo"), vec![String::from("test")]),
224        |env_test_cmd| {
225            let mut split_cmd_args = env_test_cmd.split_whitespace().map(str::to_string);
226            (
227                split_cmd_args.next().unwrap(),
228                split_cmd_args.collect::<Vec<String>>(),
229            )
230        },
231    );
232    std::process::Command::new(cmd)
233        .args(cmd_args)
234        .args(args.unwrap_or_default().into_iter())
235        .spawn()?
236        .wait()?
237        .success()
238        .then(PathBuf::new)
239        .ok_or_else(|| anyhow!("{} Testing failed", emoji::ERROR))
240}
241
242fn prepare_local_template(
243    source_template: &UserParsedInput,
244) -> Result<(TempDir, PathBuf, Option<String>), anyhow::Error> {
245    let (temp_dir, branch) = get_source_template_into_temp(source_template.location())?;
246    let template_folder = resolve_template_dir(&temp_dir, source_template.subfolder())?;
247
248    Ok((temp_dir, template_folder, branch))
249}
250
251fn get_source_template_into_temp(
252    template_location: &TemplateLocation,
253) -> Result<(TempDir, Option<String>)> {
254    match template_location {
255        TemplateLocation::Git(git) => {
256            let result = git::clone_git_template_into_temp(
257                git.url(),
258                git.branch(),
259                git.tag(),
260                git.revision(),
261                git.identity(),
262                git.gitconfig(),
263                git.skip_submodules,
264            );
265            if let Ok((ref temp_dir, _)) = result {
266                git::remove_history(temp_dir.path())?;
267                strip_liquid_suffixes(temp_dir.path())?;
268            };
269            result
270        }
271        TemplateLocation::Path(path) => {
272            let temp_dir = tmp_dir()?;
273            copy_files_recursively(path, temp_dir.path(), false)?;
274            git::remove_history(temp_dir.path())?;
275            Ok((temp_dir, try_get_branch_from_path(path)))
276        }
277    }
278}
279
280/// remove .liquid suffixes from git templates for parity with path templates
281fn strip_liquid_suffixes(dir: impl AsRef<Path>) -> Result<()> {
282    for entry in fs::read_dir(dir.as_ref())? {
283        let entry = entry?;
284        let entry_type = entry.file_type()?;
285
286        if entry_type.is_dir() {
287            strip_liquid_suffixes(entry.path())?;
288        } else if entry_type.is_file() {
289            let path = entry.path().to_string_lossy().to_string();
290            if let Some(new_path) = path.clone().strip_suffix(LIQUID_SUFFIX) {
291                fs::rename(path, new_path)?;
292            }
293        }
294    }
295    Ok(())
296}
297
298/// resolve the template location for the actual template to expand
299fn resolve_template_dir(template_base_dir: &TempDir, subfolder: Option<&str>) -> Result<PathBuf> {
300    let template_dir = resolve_template_dir_subfolder(template_base_dir.path(), subfolder)?;
301    auto_locate_template_dir(template_dir, &mut |slots| {
302        prompt_and_check_variable(slots, None)
303    })
304}
305
306/// join the base-dir and the subfolder, ensuring that we stay within the template directory
307fn resolve_template_dir_subfolder(
308    template_base_dir: &Path,
309    subfolder: Option<impl AsRef<str>>,
310) -> Result<PathBuf> {
311    if let Some(subfolder) = subfolder {
312        let template_base_dir = fs::canonicalize(template_base_dir)?;
313        let template_dir = fs::canonicalize(template_base_dir.join(subfolder.as_ref()))
314            .with_context(|| {
315                format!(
316                    "not able to find subfolder '{}' in source template",
317                    subfolder.as_ref()
318                )
319            })?;
320
321        // make sure subfolder is not `../../subfolder`
322        if !template_dir.starts_with(&template_base_dir) {
323            return Err(anyhow!(
324                "{} {} {}",
325                emoji::ERROR,
326                style("Subfolder Error:").bold().red(),
327                style("Invalid subfolder. Must be part of the template folder structure.")
328                    .bold()
329                    .red(),
330            ));
331        }
332
333        if !template_dir.is_dir() {
334            return Err(anyhow!(
335                "{} {} {}",
336                emoji::ERROR,
337                style("Subfolder Error:").bold().red(),
338                style("The specified subfolder must be a valid folder.")
339                    .bold()
340                    .red(),
341            ));
342        }
343
344        Ok(template_dir)
345    } else {
346        Ok(template_base_dir.to_owned())
347    }
348}
349
350/// look through the template folder structure and attempt to find a suitable template.
351fn auto_locate_template_dir(
352    template_base_dir: PathBuf,
353    prompt: &mut impl FnMut(&TemplateSlots) -> Result<String>,
354) -> Result<PathBuf> {
355    let config_paths = locate_template_configs(&template_base_dir)?;
356    match config_paths.len() {
357        0 => {
358            // No configurations found, so this *must* be a template
359            Ok(template_base_dir)
360        }
361        1 => {
362            // A single configuration found, but it may contain multiple configured sub-templates
363            resolve_configured_sub_templates(&template_base_dir.join(&config_paths[0]), prompt)
364        }
365        _ => {
366            // Multiple configurations found, each in different "roots"
367            // let user select between them
368            let prompt_args = TemplateSlots {
369                prompt: "Which template should be expanded?".into(),
370                var_name: "Template".into(),
371                var_info: VarInfo::String {
372                    entry: Box::new(StringEntry {
373                        default: Some(config_paths[0].display().to_string()),
374                        kind: StringKind::Choices(
375                            config_paths
376                                .into_iter()
377                                .map(|p| p.display().to_string())
378                                .collect(),
379                        ),
380                        regex: None,
381                    }),
382                },
383            };
384            let path = prompt(&prompt_args)?;
385
386            // recursively retry to resolve the template,
387            // until we hit a single or no config, idetifying the final template folder
388            auto_locate_template_dir(template_base_dir.join(path), prompt)
389        }
390    }
391}
392
393fn resolve_configured_sub_templates(
394    config_path: &Path,
395    prompt: &mut impl FnMut(&TemplateSlots) -> Result<String>,
396) -> Result<PathBuf> {
397    Config::from_path(&Some(config_path.join(CONFIG_FILE_NAME)))
398        .ok()
399        .and_then(|config| config.template)
400        .and_then(|config| config.sub_templates)
401        .map_or_else(
402            || Ok(PathBuf::from(config_path)),
403            |sub_templates| {
404                // we have a config that defines sub-templates, let the user select
405                let prompt_args = TemplateSlots {
406                    prompt: "Which sub-template should be expanded?".into(),
407                    var_name: "Template".into(),
408                    var_info: VarInfo::String {
409                        entry: Box::new(StringEntry {
410                            default: Some(sub_templates[0].clone()),
411                            kind: StringKind::Choices(sub_templates.clone()),
412                            regex: None,
413                        }),
414                    },
415                };
416                let path = prompt(&prompt_args)?;
417
418                // recursively retry to resolve the template,
419                // until we hit a single or no config, idetifying the final template folder
420                auto_locate_template_dir(
421                    resolve_template_dir_subfolder(config_path, Some(path))?,
422                    prompt,
423                )
424            },
425        )
426}
427
428fn locate_template_file(
429    name: &str,
430    template_base_folder: impl AsRef<Path>,
431    template_folder: impl AsRef<Path>,
432) -> Result<PathBuf> {
433    let template_base_folder = template_base_folder.as_ref();
434    let mut search_folder = template_folder.as_ref().to_path_buf();
435    loop {
436        let file_path = search_folder.join::<&str>(name);
437        if file_path.exists() {
438            return Ok(file_path);
439        }
440        if search_folder == template_base_folder {
441            bail!("File not found within template");
442        }
443        search_folder = search_folder
444            .parent()
445            .ok_or_else(|| anyhow!("Reached root folder"))?
446            .to_path_buf();
447    }
448}
449
450fn expand_template(
451    template_dir: &Path,
452    config: &mut Config,
453    user_parsed_input: &UserParsedInput,
454    args: &GenerateArgs,
455) -> Result<PathBuf> {
456    let liquid_object = create_liquid_object(user_parsed_input)?;
457    let context = RhaiHooksContext {
458        liquid_object: liquid_object.clone(),
459        allow_commands: user_parsed_input.allow_commands(),
460        silent: user_parsed_input.silent(),
461        working_directory: template_dir.to_owned(),
462        destination_directory: user_parsed_input.destination().to_owned(),
463    };
464
465    // run init hooks - these won't have access to `crate_name`/`within_cargo_project`
466    // variables, as these are not set yet. Furthermore, if `project-name` is set, it is the raw
467    // user input!
468    // The init hooks are free to set `project-name` (but it will be validated before further
469    // use).
470    execute_hooks(&context, &config.get_init_hooks())?;
471
472    let project_name_input = ProjectNameInput::try_from((&liquid_object, user_parsed_input))?;
473    let project_name = ProjectName::from((&project_name_input, user_parsed_input));
474    let crate_name = CrateName::from(&project_name_input);
475    let destination = ProjectDir::try_from((&project_name_input, user_parsed_input))?;
476    if !user_parsed_input.init() {
477        destination.create()?;
478    }
479
480    set_project_name_variables(&liquid_object, &destination, &project_name, &crate_name)?;
481
482    info!(
483        "{} {} {}",
484        emoji::WRENCH,
485        style(format!("Destination: {destination}")).bold(),
486        style("...").bold()
487    );
488    info!(
489        "{} {} {}",
490        emoji::WRENCH,
491        style(format!("project-name: {project_name}")).bold(),
492        style("...").bold()
493    );
494    project_variables::show_project_variables_with_value(&liquid_object, config);
495
496    info!(
497        "{} {} {}",
498        emoji::WRENCH,
499        style("Generating template").bold(),
500        style("...").bold()
501    );
502
503    // evaluate config for placeholders and and any that are undefined
504    fill_placeholders_and_merge_conditionals(
505        config,
506        &liquid_object,
507        user_parsed_input.template_values(),
508        args,
509    )?;
510    add_missing_provided_values(&liquid_object, user_parsed_input.template_values())?;
511
512    let context = RhaiHooksContext {
513        liquid_object: Arc::clone(&liquid_object),
514        destination_directory: destination.as_ref().to_owned(),
515        ..context
516    };
517
518    // run pre-hooks
519    execute_hooks(&context, &config.get_pre_hooks())?;
520
521    // walk/evaluate the template
522    let all_hook_files = config.get_hook_files();
523    let mut template_config = config.template.take().unwrap_or_default();
524
525    ignore_me::remove_unneeded_files(template_dir, &template_config.ignore, args.verbose)?;
526    let mut pbar = progressbar::new();
527
528    let rhai_filter_files = Arc::new(Mutex::new(vec![]));
529    let rhai_engine = create_liquid_engine(
530        template_dir.to_owned(),
531        liquid_object.clone(),
532        user_parsed_input.allow_commands(),
533        user_parsed_input.silent(),
534        rhai_filter_files.clone(),
535    );
536    let result = template::walk_dir(
537        &mut template_config,
538        template_dir,
539        &all_hook_files,
540        &liquid_object,
541        rhai_engine,
542        &rhai_filter_files,
543        &mut pbar,
544        args.quiet,
545    );
546
547    match result {
548        Ok(()) => (),
549        Err(e) => {
550            // Don't print the error twice
551            if !args.quiet && args.continue_on_error {
552                warn!("{e}");
553            }
554            if !args.continue_on_error {
555                return Err(e);
556            }
557        }
558    };
559
560    // run post-hooks
561    execute_hooks(&context, &config.get_post_hooks())?;
562
563    // remove all hook and filter files as they are never part of the template output
564    let rhai_filter_files = rhai_filter_files
565        .lock()
566        .unwrap()
567        .iter()
568        .cloned()
569        .collect::<Vec<_>>();
570    remove_dir_files(
571        all_hook_files
572            .into_iter()
573            .map(PathBuf::from)
574            .chain(rhai_filter_files),
575        false,
576    );
577
578    config.template.replace(template_config);
579    Ok(destination.as_ref().to_owned())
580}
581
582/// Try to add all provided `template_values` to the `liquid_object`.
583///
584/// ## Note:
585/// Values for which a placeholder exists, should already be filled by `fill_project_variables`
586pub(crate) fn add_missing_provided_values(
587    liquid_object: &LiquidObjectResource,
588    template_values: &HashMap<String, toml::Value>,
589) -> Result<(), anyhow::Error> {
590    template_values.iter().try_for_each(|(k, v)| {
591        if RefCell::borrow(&liquid_object.lock().unwrap()).contains_key(k.as_str()) {
592            return Ok(());
593        }
594        // we have a value without a slot in the liquid object.
595        // try to create the slot from the provided value
596        let value = match v {
597            toml::Value::String(content) => liquid_core::Value::Scalar(content.clone().into()),
598            toml::Value::Boolean(content) => liquid_core::Value::Scalar((*content).into()),
599            _ => anyhow::bail!(format!(
600                "{} {}",
601                emoji::ERROR,
602                style("Unsupported value type. Only Strings and Booleans are supported.")
603                    .bold()
604                    .red(),
605            )),
606        };
607        liquid_object
608            .lock()
609            .unwrap()
610            .borrow_mut()
611            .insert(k.clone().into(), value);
612        Ok(())
613    })?;
614    Ok(())
615}
616
617fn read_default_variable_value_from_template(slot: &TemplateSlots) -> Result<String, ()> {
618    let default_value = match &slot.var_info {
619        VarInfo::Bool {
620            default: Some(default),
621        } => default.to_string(),
622        VarInfo::String {
623            entry: string_entry,
624        } => match *string_entry.clone() {
625            StringEntry {
626                default: Some(default),
627                ..
628            } => default.clone(),
629            _ => return Err(()),
630        },
631        _ => return Err(()),
632    };
633    let (key, value) = (&slot.var_name, &default_value);
634    info!(
635        "{} {} (default value from template)",
636        emoji::WRENCH,
637        style(format!("{key}: {value:?}")).bold(),
638    );
639    Ok(default_value)
640}
641
642/// Turn things into strings that can be turned into strings
643/// Tables are not allowed and will be ignored
644/// arrays are allowed but will be flattened like so
645/// [[[[a,b],[[c]]],[[[d]]]]] => "a,b,c,d"
646fn extract_toml_string(value: &toml::Value) -> Option<String> {
647    match value {
648        toml::Value::String(s) => Some(s.clone()),
649        toml::Value::Integer(s) => Some(s.to_string()),
650        toml::Value::Float(s) => Some(s.to_string()),
651        toml::Value::Boolean(s) => Some(s.to_string()),
652        toml::Value::Datetime(s) => Some(s.to_string()),
653        toml::Value::Array(s) => Some(
654            s.iter()
655                .filter_map(extract_toml_string)
656                .collect::<Vec<String>>()
657                .join(LIST_SEP),
658        ),
659        toml::Value::Table(_) => None,
660    }
661}
662
663// Evaluate the configuration, adding defined placeholder variables to the liquid object.
664fn fill_placeholders_and_merge_conditionals(
665    config: &mut Config,
666    liquid_object: &LiquidObjectResource,
667    template_values: &HashMap<String, toml::Value>,
668    args: &GenerateArgs,
669) -> Result<()> {
670    let mut conditionals = config.conditional.take().unwrap_or_default();
671
672    loop {
673        // keep evaluating for placeholder variables as long new ones are added.
674        project_variables::fill_project_variables(liquid_object, config, |slot| {
675            let provided_value = template_values
676                .get(&slot.var_name)
677                .and_then(extract_toml_string);
678            if provided_value.is_none() && args.silent {
679                let default_value = match read_default_variable_value_from_template(slot) {
680                    Ok(string) => string,
681                    Err(()) => {
682                        anyhow::bail!(ConversionError::MissingDefaultValueForPlaceholderVariable {
683                            var_name: slot.var_name.clone()
684                        })
685                    }
686                };
687                interactive::variable(slot, Some(&default_value))
688            } else {
689                interactive::variable(slot, provided_value.as_ref())
690            }
691        })?;
692
693        let placeholders_changed = conditionals
694            .iter_mut()
695            // filter each conditional config block by trueness of the expression, given the known variables
696            .filter_map(|(key, cfg)| {
697                evaluate_script::<bool>(liquid_object, key)
698                    .ok()
699                    .filter(|&r| r)
700                    .map(|_| cfg)
701            })
702            .map(|conditional_template_cfg| {
703                // append the conditional blocks configuration, returning true if any placeholders were added
704                let template_cfg = config.template.get_or_insert_with(TemplateConfig::default);
705                if let Some(mut extras) = conditional_template_cfg.include.take() {
706                    template_cfg
707                        .include
708                        .get_or_insert_with(Vec::default)
709                        .append(&mut extras);
710                }
711                if let Some(mut extras) = conditional_template_cfg.exclude.take() {
712                    template_cfg
713                        .exclude
714                        .get_or_insert_with(Vec::default)
715                        .append(&mut extras);
716                }
717                if let Some(mut extras) = conditional_template_cfg.ignore.take() {
718                    template_cfg
719                        .ignore
720                        .get_or_insert_with(Vec::default)
721                        .append(&mut extras);
722                }
723                if let Some(extra_placeholders) = conditional_template_cfg.placeholders.take() {
724                    match config.placeholders.as_mut() {
725                        Some(placeholders) => {
726                            for (k, v) in extra_placeholders.0 {
727                                placeholders.0.insert(k, v);
728                            }
729                        }
730                        None => {
731                            config.placeholders = Some(extra_placeholders);
732                        }
733                    };
734                    return true;
735                }
736                false
737            })
738            .fold(false, |acc, placeholders_changed| {
739                acc | placeholders_changed
740            });
741
742        if !placeholders_changed {
743            break;
744        }
745    }
746
747    Ok(())
748}
749
750fn check_cargo_generate_version(template_config: &Config) -> Result<(), anyhow::Error> {
751    if let Config {
752        template:
753            Some(config::TemplateConfig {
754                cargo_generate_version: Some(requirement),
755                ..
756            }),
757        ..
758    } = template_config
759    {
760        let version = semver::Version::parse(env!("CARGO_PKG_VERSION"))?;
761        if !requirement.matches(&version) {
762            bail!(
763                "{} {} {} {} {}",
764                emoji::ERROR,
765                style("Required cargo-generate version not met. Required:")
766                    .bold()
767                    .red(),
768                style(requirement).yellow(),
769                style(" was:").bold().red(),
770                style(version).yellow(),
771            );
772        }
773    }
774    Ok(())
775}
776
777#[derive(Debug)]
778struct ScopedWorkingDirectory(PathBuf);
779
780impl Default for ScopedWorkingDirectory {
781    fn default() -> Self {
782        Self(env::current_dir().unwrap())
783    }
784}
785
786impl Drop for ScopedWorkingDirectory {
787    fn drop(&mut self) {
788        env::set_current_dir(&self.0).unwrap();
789    }
790}
791
792#[cfg(test)]
793mod tests {
794    use crate::{
795        auto_locate_template_dir, extract_toml_string,
796        project_variables::{StringKind, VarInfo},
797        tmp_dir,
798    };
799    use anyhow::anyhow;
800    use std::{
801        fs,
802        io::Write,
803        path::{Path, PathBuf},
804    };
805    use tempfile::TempDir;
806
807    #[test]
808    fn auto_locate_template_returns_base_when_no_cargo_generate_is_found() -> anyhow::Result<()> {
809        let tmp = tmp_dir().unwrap();
810        create_file(&tmp, "dir1/Cargo.toml", "")?;
811        create_file(&tmp, "dir2/dir2_1/Cargo.toml", "")?;
812        create_file(&tmp, "dir3/Cargo.toml", "")?;
813
814        let actual =
815            auto_locate_template_dir(tmp.path().to_path_buf(), &mut |_slots| Err(anyhow!("test")))?
816                .canonicalize()?;
817        let expected = tmp.path().canonicalize()?;
818
819        assert_eq!(expected, actual);
820        Ok(())
821    }
822
823    #[test]
824    fn auto_locate_template_returns_path_when_single_cargo_generate_is_found() -> anyhow::Result<()>
825    {
826        let tmp = tmp_dir().unwrap();
827        create_file(&tmp, "dir1/Cargo.toml", "")?;
828        create_file(&tmp, "dir2/dir2_1/Cargo.toml", "")?;
829        create_file(&tmp, "dir2/dir2_2/cargo-generate.toml", "")?;
830        create_file(&tmp, "dir3/Cargo.toml", "")?;
831
832        let actual =
833            auto_locate_template_dir(tmp.path().to_path_buf(), &mut |_slots| Err(anyhow!("test")))?
834                .canonicalize()?;
835        let expected = tmp.path().join("dir2/dir2_2").canonicalize()?;
836
837        assert_eq!(expected, actual);
838        Ok(())
839    }
840
841    #[test]
842    fn auto_locate_template_can_resolve_configured_subtemplates() -> anyhow::Result<()> {
843        let tmp = tmp_dir().unwrap();
844        create_file(
845            &tmp,
846            "cargo-generate.toml",
847            indoc::indoc! {r#"
848                [template]
849                sub_templates = ["sub1", "sub2"]
850            "#},
851        )?;
852        create_file(&tmp, "sub1/Cargo.toml", "")?;
853        create_file(&tmp, "sub2/Cargo.toml", "")?;
854
855        let actual = auto_locate_template_dir(tmp.path().to_path_buf(), &mut |slots| match &slots
856            .var_info
857        {
858            VarInfo::Bool { .. } | VarInfo::Array { .. } => anyhow::bail!("Wrong prompt type"),
859            VarInfo::String { entry } => {
860                if let StringKind::Choices(choices) = entry.kind.clone() {
861                    let expected = vec!["sub1".to_string(), "sub2".to_string()];
862                    assert_eq!(expected, choices);
863                    Ok("sub2".to_string())
864                } else {
865                    anyhow::bail!("Missing choices")
866                }
867            }
868        })?
869        .canonicalize()?;
870        let expected = tmp.path().join("sub2").canonicalize()?;
871
872        assert_eq!(expected, actual);
873        Ok(())
874    }
875
876    #[test]
877    fn auto_locate_template_recurses_to_resolve_subtemplates() -> anyhow::Result<()> {
878        let tmp = tmp_dir().unwrap();
879        create_file(
880            &tmp,
881            "cargo-generate.toml",
882            indoc::indoc! {r#"
883                [template]
884                sub_templates = ["sub1", "sub2"]
885            "#},
886        )?;
887        create_file(&tmp, "sub1/Cargo.toml", "")?;
888        create_file(&tmp, "sub1/sub11/cargo-generate.toml", "")?;
889        create_file(
890            &tmp,
891            "sub1/sub12/cargo-generate.toml",
892            indoc::indoc! {r#"
893                [template]
894                sub_templates = ["sub122", "sub121"]
895            "#},
896        )?;
897        create_file(&tmp, "sub2/Cargo.toml", "")?;
898        create_file(&tmp, "sub1/sub11/Cargo.toml", "")?;
899        create_file(&tmp, "sub1/sub12/sub121/Cargo.toml", "")?;
900        create_file(&tmp, "sub1/sub12/sub122/Cargo.toml", "")?;
901
902        let mut prompt_num = 0;
903        let actual = auto_locate_template_dir(tmp.path().to_path_buf(), &mut |slots| match &slots
904            .var_info
905        {
906            VarInfo::Bool { .. } | VarInfo::Array { .. } => anyhow::bail!("Wrong prompt type"),
907            VarInfo::String { entry } => {
908                if let StringKind::Choices(choices) = entry.kind.clone() {
909                    let (expected, answer) = match prompt_num {
910                        0 => (vec!["sub1", "sub2"], "sub1"),
911                        1 => (vec!["sub11", "sub12"], "sub12"),
912                        2 => (vec!["sub122", "sub121"], "sub121"),
913                        _ => panic!("Unexpected number of prompts"),
914                    };
915                    prompt_num += 1;
916                    expected
917                        .into_iter()
918                        .zip(choices.iter())
919                        .for_each(|(a, b)| assert_eq!(a, b));
920                    Ok(answer.to_string())
921                } else {
922                    anyhow::bail!("Missing choices")
923                }
924            }
925        })?
926        .canonicalize()?;
927
928        let expected = tmp
929            .path()
930            .join("sub1")
931            .join("sub12")
932            .join("sub121")
933            .canonicalize()?;
934
935        assert_eq!(expected, actual);
936        Ok(())
937    }
938
939    #[test]
940    fn auto_locate_template_prompts_when_multiple_cargo_generate_is_found() -> anyhow::Result<()> {
941        let tmp = tmp_dir().unwrap();
942        create_file(&tmp, "dir1/Cargo.toml", "")?;
943        create_file(&tmp, "dir2/dir2_1/Cargo.toml", "")?;
944        create_file(&tmp, "dir2/dir2_2/cargo-generate.toml", "")?;
945        create_file(&tmp, "dir3/Cargo.toml", "")?;
946        create_file(&tmp, "dir4/cargo-generate.toml", "")?;
947
948        let actual = auto_locate_template_dir(tmp.path().to_path_buf(), &mut |slots| match &slots
949            .var_info
950        {
951            VarInfo::Bool { .. } | VarInfo::Array { .. } => anyhow::bail!("Wrong prompt type"),
952            VarInfo::String { entry } => {
953                if let StringKind::Choices(choices) = entry.kind.clone() {
954                    let expected = vec![
955                        Path::new("dir2").join("dir2_2").to_string(),
956                        "dir4".to_string(),
957                    ];
958                    assert_eq!(expected, choices);
959                    Ok("dir4".to_string())
960                } else {
961                    anyhow::bail!("Missing choices")
962                }
963            }
964        })?
965        .canonicalize()?;
966        let expected = tmp.path().join("dir4").canonicalize()?;
967
968        assert_eq!(expected, actual);
969
970        Ok(())
971    }
972
973    pub trait PathString {
974        fn to_string(&self) -> String;
975    }
976
977    impl PathString for PathBuf {
978        fn to_string(&self) -> String {
979            self.as_path().to_string()
980        }
981    }
982
983    impl PathString for Path {
984        fn to_string(&self) -> String {
985            self.display().to_string()
986        }
987    }
988
989    pub fn create_file(
990        base_path: &TempDir,
991        path: impl AsRef<Path>,
992        contents: impl AsRef<str>,
993    ) -> anyhow::Result<()> {
994        let path = base_path.path().join(path);
995        if let Some(parent) = path.parent() {
996            fs::create_dir_all(parent)?;
997        }
998
999        fs::File::create(&path)?.write_all(contents.as_ref().as_ref())?;
1000        Ok(())
1001    }
1002
1003    #[test]
1004    fn test_extract_toml_string() {
1005        assert_eq!(
1006            extract_toml_string(&toml::Value::Integer(42)),
1007            Some(String::from("42"))
1008        );
1009        assert_eq!(
1010            extract_toml_string(&toml::Value::Float(42.0)),
1011            Some(String::from("42"))
1012        );
1013        assert_eq!(
1014            extract_toml_string(&toml::Value::Boolean(true)),
1015            Some(String::from("true"))
1016        );
1017        assert_eq!(
1018            extract_toml_string(&toml::Value::Array(vec![
1019                toml::Value::Integer(1),
1020                toml::Value::Array(vec![toml::Value::Array(vec![toml::Value::Integer(2)])]),
1021                toml::Value::Integer(3),
1022                toml::Value::Integer(4),
1023            ])),
1024            Some(String::from("1,2,3,4"))
1025        );
1026        assert_eq!(
1027            extract_toml_string(&toml::Value::Table(toml::map::Map::new())),
1028            None
1029        );
1030    }
1031}