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