#![doc = include_str!("../README.md")]
#![warn(
//clippy::cargo_common_metadata,
clippy::branches_sharing_code,
clippy::cast_lossless,
clippy::cognitive_complexity,
clippy::get_unwrap,
clippy::if_then_some_else_none,
clippy::inefficient_to_string,
clippy::match_bool,
clippy::missing_const_for_fn,
clippy::missing_panics_doc,
clippy::option_if_let_else,
clippy::redundant_closure,
clippy::redundant_else,
clippy::redundant_pub_crate,
clippy::ref_binding_to_reference,
clippy::ref_option_ref,
clippy::same_functions_in_if_condition,
clippy::unneeded_field_pattern,
clippy::unnested_or_patterns,
clippy::use_self,
)]
mod absolute_path;
mod app_config;
mod args;
mod config;
mod copy;
mod emoji;
mod favorites;
mod fetch;
mod filenames;
mod git;
mod hooks;
mod ignore_me;
mod include_exclude;
mod interactive;
mod progressbar;
mod project_variables;
mod template;
mod template_filters;
mod template_source;
mod template_variables;
mod user_parsed_input;
mod utils;
mod workspace_member;
pub use crate::app_config::{app_config_path, AppConfig};
pub use crate::favorites::list_favorites;
use crate::template::create_liquid_engine;
pub use args::*;
use anyhow::{anyhow, bail, Result};
use config::{Config, CONFIG_FILE_NAME};
use console::style;
use copy::copy_files_recursively;
use env_logger::fmt::Formatter;
use hooks::{execute_hooks, RhaiHooksContext};
use ignore_me::remove_dir_files;
use interactive::LIST_SEP;
use log::Record;
use log::{info, warn};
use project_variables::{StringEntry, TemplateSlots, VarInfo};
use std::{
cell::RefCell,
collections::HashMap,
env,
io::Write,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use user_parsed_input::UserParsedInput;
use workspace_member::WorkspaceMemberStatus;
use crate::template_variables::{
load_env_and_args_template_values, CrateName, ProjectDir, ProjectNameInput,
};
use crate::{project_variables::ConversionError, template_variables::ProjectName};
use self::config::TemplateConfig;
use self::hooks::evaluate_script;
use self::template::{create_liquid_object, set_project_name_variables, LiquidObjectResource};
pub fn log_formatter(
buf: &mut Formatter,
record: &Record,
) -> std::result::Result<(), std::io::Error> {
let prefix = match record.level() {
log::Level::Error => format!("{} ", emoji::ERROR),
log::Level::Warn => format!("{} ", emoji::WARN),
_ => "".to_string(),
};
writeln!(buf, "{}{}", prefix, record.args())
}
pub fn generate(args: GenerateArgs) -> Result<PathBuf> {
let app_config = AppConfig::try_from(app_config_path(&args.config)?.as_path())?;
let mut user_parsed_input = UserParsedInput::try_from_args_and_config(app_config, &args)?;
user_parsed_input.ensure_git_feature_available()?;
user_parsed_input
.template_values_mut()
.extend(load_env_and_args_template_values(&args)?);
let fetched = fetch::prepare_local_template(&user_parsed_input)?;
let mut config = Config::from_path(
&locate_template_file(CONFIG_FILE_NAME, fetched.root(), fetched.template_dir()).ok(),
)?;
if config
.template
.as_ref()
.and_then(|c| c.init)
.unwrap_or(false)
&& !user_parsed_input.init
{
warn!(
"{}",
style("Template specifies --init, while not specified on the command line. Output location is affected!").bold().red(),
);
user_parsed_input.init = true;
};
check_cargo_generate_version(&config)?;
let project_dir = expand_template(
fetched.template_dir(),
&mut config,
&user_parsed_input,
&args,
)?;
let (mut should_initialize_git, with_force) = {
let vcs = &config
.template
.as_ref()
.and_then(|t| t.vcs)
.unwrap_or_else(|| user_parsed_input.vcs());
(
!vcs.is_none() && (!user_parsed_input.init || user_parsed_input.force_git_init()),
user_parsed_input.force_git_init(),
)
};
let target_path = if user_parsed_input.test() {
test_expanded_template(fetched.template_dir(), args.other_args)?
} else {
let project_path =
copy_expanded_template(fetched.template_dir(), project_dir, user_parsed_input)?;
if !args.no_workspace {
match workspace_member::add_to_workspace(&project_path)? {
WorkspaceMemberStatus::Added(workspace_cargo_toml) => {
should_initialize_git = with_force;
info!(
"{} {} `{}`",
emoji::WRENCH,
style("Project added as member to workspace").bold(),
style(workspace_cargo_toml.display()).bold().yellow(),
);
}
WorkspaceMemberStatus::AlreadyCoveredByGlob(_)
| WorkspaceMemberStatus::Excluded(_)
| WorkspaceMemberStatus::NoWorkspaceFound => {
}
}
}
project_path
};
if should_initialize_git {
info!(
"{} {}",
emoji::WRENCH,
style("Initializing a fresh Git repository").bold()
);
git::init(&target_path, fetched.branch(), with_force)?;
}
info!(
"{} {} {} {}",
emoji::SPARKLE,
style("Done!").bold().green(),
style("New project created").bold(),
style(&target_path.display()).underlined()
);
Ok(target_path)
}
fn copy_expanded_template(
template_dir: &Path,
project_dir: PathBuf,
user_parsed_input: UserParsedInput,
) -> Result<PathBuf> {
info!(
"{} {} `{}`{}",
emoji::WRENCH,
style("Moving generated files into:").bold(),
style(project_dir.display()).bold().yellow(),
style("...").bold()
);
copy_files_recursively(template_dir, &project_dir, user_parsed_input.overwrite())?;
Ok(project_dir)
}
fn test_expanded_template(template_dir: &Path, args: Option<Vec<String>>) -> Result<PathBuf> {
info!(
"{} {}{}{}",
emoji::WRENCH,
style("Running \"").bold(),
style("cargo test"),
style("\" ...").bold(),
);
let (cmd, cmd_args) = std::env::var("CARGO_GENERATE_TEST_CMD").map_or_else(
|_| (String::from("cargo"), vec![String::from("test")]),
|env_test_cmd| {
let mut split_cmd_args = env_test_cmd.split_whitespace().map(str::to_string);
(
split_cmd_args.next().unwrap(),
split_cmd_args.collect::<Vec<String>>(),
)
},
);
std::process::Command::new(cmd)
.current_dir(template_dir)
.args(cmd_args)
.args(args.unwrap_or_default())
.spawn()?
.wait()?
.success()
.then(PathBuf::new)
.ok_or_else(|| anyhow!("{} Testing failed", emoji::ERROR))
}
fn locate_template_file(
name: &str,
template_base_folder: impl AsRef<Path>,
template_folder: impl AsRef<Path>,
) -> Result<PathBuf> {
let template_base_folder = template_base_folder.as_ref();
let mut search_folder = template_folder.as_ref().to_path_buf();
loop {
let file_path = search_folder.join::<&str>(name);
if file_path.exists() {
return Ok(file_path);
}
if search_folder == template_base_folder {
bail!("File not found within template");
}
search_folder = search_folder
.parent()
.ok_or_else(|| anyhow!("Reached root folder"))?
.to_path_buf();
}
}
fn expand_template(
template_dir: &Path,
config: &mut Config,
user_parsed_input: &UserParsedInput,
args: &GenerateArgs,
) -> Result<PathBuf> {
let liquid_object = create_liquid_object(user_parsed_input)?;
let context = RhaiHooksContext {
liquid_object: liquid_object.clone(),
allow_commands: user_parsed_input.allow_commands(),
silent: user_parsed_input.silent(),
working_directory: template_dir.to_owned(),
destination_directory: user_parsed_input.destination().to_owned(),
};
execute_hooks(&context, &config.get_init_hooks())?;
let project_name_input = ProjectNameInput::try_from((&liquid_object, user_parsed_input))?;
let project_name = ProjectName::from((&project_name_input, user_parsed_input));
let crate_name = CrateName::from(&project_name_input);
let destination = ProjectDir::try_from((&project_name_input, user_parsed_input))?;
if !user_parsed_input.init() {
destination.create()?;
}
set_project_name_variables(&liquid_object, &destination, &project_name, &crate_name)?;
info!(
"{} {} {}",
emoji::WRENCH,
style(format!("Destination: {destination}")).bold(),
style("...").bold()
);
info!(
"{} {} {}",
emoji::WRENCH,
style(format!("project-name: {project_name}")).bold(),
style("...").bold()
);
project_variables::show_project_variables_with_value(&liquid_object, config);
info!(
"{} {} {}",
emoji::WRENCH,
style("Generating template").bold(),
style("...").bold()
);
fill_placeholders_and_merge_conditionals(
config,
&liquid_object,
user_parsed_input.template_values(),
args,
)?;
add_missing_provided_values(&liquid_object, user_parsed_input.template_values())?;
let context = RhaiHooksContext {
liquid_object: Arc::clone(&liquid_object),
destination_directory: destination.as_ref().to_owned(),
..context
};
execute_hooks(&context, &config.get_pre_hooks())?;
let all_hook_files = config.get_hook_files();
let mut template_config = config.template.take().unwrap_or_default();
ignore_me::remove_unneeded_files(template_dir, &template_config.ignore, args.verbose)?;
let mut pbar = progressbar::new();
let rhai_filter_files = Arc::new(Mutex::new(vec![]));
let rhai_engine = create_liquid_engine(
template_dir.to_owned(),
liquid_object.clone(),
user_parsed_input.allow_commands(),
user_parsed_input.silent(),
rhai_filter_files.clone(),
);
let result = template::walk_dir(
&mut template_config,
template_dir,
&all_hook_files,
&liquid_object,
rhai_engine,
&rhai_filter_files,
&mut pbar,
args.quiet,
);
match result {
Ok(()) => (),
Err(e) => {
if !args.quiet && args.continue_on_error {
warn!("{e}");
}
if !args.continue_on_error {
return Err(e);
}
}
};
execute_hooks(&context, &config.get_post_hooks())?;
let rhai_filter_files = rhai_filter_files
.lock()
.unwrap()
.iter()
.cloned()
.collect::<Vec<_>>();
remove_dir_files(
all_hook_files
.into_iter()
.map(|hook_file| template_dir.join(hook_file))
.chain(rhai_filter_files),
false,
);
config.template.replace(template_config);
Ok(destination.as_ref().to_owned())
}
const BUILTIN_PLACEHOLDER_NAMES: &[&str] = &[
"authors",
"username",
"os-arch",
"project-name",
"crate_name",
"crate_type",
"within_cargo_project",
"is_init",
];
pub(crate) fn add_missing_provided_values(
liquid_object: &LiquidObjectResource,
template_values: &HashMap<String, toml::Value>,
) -> Result<(), anyhow::Error> {
template_values.iter().try_for_each(|(k, v)| {
let already_present =
RefCell::borrow(&liquid_object.lock().unwrap()).contains_key(k.as_str());
let is_builtin = BUILTIN_PLACEHOLDER_NAMES.contains(&k.as_str());
if already_present && !is_builtin {
return Ok(());
}
let value = match v {
toml::Value::String(content) => liquid_core::Value::Scalar(content.clone().into()),
toml::Value::Boolean(content) => liquid_core::Value::Scalar((*content).into()),
_ => anyhow::bail!(format!(
"{} {}",
emoji::ERROR,
style("Unsupported value type. Only Strings and Booleans are supported.")
.bold()
.red(),
)),
};
if already_present && is_builtin {
info!(
"{} {}",
emoji::WARN,
style(format!(
"Overriding builtin placeholder `{k}` with value from `--define`"
))
.bold()
.yellow(),
);
}
liquid_object
.lock()
.unwrap()
.borrow_mut()
.insert(k.clone().into(), value);
Ok(())
})?;
Ok(())
}
pub(crate) fn read_default_variable_value_from_template(
slot: &TemplateSlots,
) -> Result<String, ()> {
let default_value = match &slot.var_info {
VarInfo::Bool {
default: Some(default),
} => default.to_string(),
VarInfo::String {
entry: string_entry,
} => match *string_entry.clone() {
StringEntry {
default: Some(default),
..
} => default.clone(),
_ => return Err(()),
},
VarInfo::Array { entry } => match &entry.default {
Some(default) => default.join(LIST_SEP),
None => return Err(()),
},
_ => return Err(()),
};
let (key, value) = (&slot.var_name, &default_value);
info!(
"{} {} (default value from template)",
emoji::WRENCH,
style(format!("{key}: {value:?}")).bold(),
);
Ok(default_value)
}
fn extract_toml_string(value: &toml::Value) -> Option<String> {
match value {
toml::Value::String(s) => Some(s.clone()),
toml::Value::Integer(s) => Some(s.to_string()),
toml::Value::Float(s) => Some(s.to_string()),
toml::Value::Boolean(s) => Some(s.to_string()),
toml::Value::Datetime(s) => Some(s.to_string()),
toml::Value::Array(s) => Some(
s.iter()
.filter_map(extract_toml_string)
.collect::<Vec<String>>()
.join(LIST_SEP),
),
toml::Value::Table(_) => None,
}
}
fn fill_placeholders_and_merge_conditionals(
config: &mut Config,
liquid_object: &LiquidObjectResource,
template_values: &HashMap<String, toml::Value>,
args: &GenerateArgs,
) -> Result<()> {
let mut conditionals = config.conditional.take().unwrap_or_default();
loop {
project_variables::fill_project_variables(liquid_object, config, |slot| {
let provided_value = template_values
.get(&slot.var_name)
.and_then(extract_toml_string);
if provided_value.is_none() && args.silent {
let default_value = match read_default_variable_value_from_template(slot) {
Ok(string) => string,
Err(()) => {
anyhow::bail!(ConversionError::MissingDefaultValueForPlaceholderVariable {
var_name: slot.var_name.clone()
})
}
};
interactive::variable(slot, Some(&default_value))
} else {
interactive::variable(slot, provided_value.as_ref())
}
})?;
let placeholders_changed = conditionals
.iter_mut()
.filter_map(|(key, cfg)| {
evaluate_script::<bool>(liquid_object, key)
.ok()
.filter(|&r| r)
.map(|_| cfg)
})
.map(|conditional_template_cfg| {
let template_cfg = config.template.get_or_insert_with(TemplateConfig::default);
if let Some(mut extras) = conditional_template_cfg.include.take() {
template_cfg
.include
.get_or_insert_with(Vec::default)
.append(&mut extras);
}
if let Some(mut extras) = conditional_template_cfg.exclude.take() {
template_cfg
.exclude
.get_or_insert_with(Vec::default)
.append(&mut extras);
}
if let Some(mut extras) = conditional_template_cfg.ignore.take() {
template_cfg
.ignore
.get_or_insert_with(Vec::default)
.append(&mut extras);
}
if let Some(extra_placeholders) = conditional_template_cfg.placeholders.take() {
match config.placeholders.as_mut() {
Some(placeholders) => {
for (k, v) in extra_placeholders.0 {
placeholders.0.insert(k, v);
}
}
None => {
config.placeholders = Some(extra_placeholders);
}
};
return true;
}
false
})
.fold(false, |acc, placeholders_changed| {
acc | placeholders_changed
});
if !placeholders_changed {
break;
}
}
Ok(())
}
fn check_cargo_generate_version(template_config: &Config) -> Result<(), anyhow::Error> {
if let Config {
template:
Some(config::TemplateConfig {
cargo_generate_version: Some(requirement),
..
}),
..
} = template_config
{
let version = semver::Version::parse(env!("CARGO_PKG_VERSION"))?;
if !requirement.matches(&version) {
bail!(
"{} {} {} {} {}",
emoji::ERROR,
style("Required cargo-generate version not met. Required:")
.bold()
.red(),
style(requirement).yellow(),
style(" was:").bold().red(),
style(version).yellow(),
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::extract_toml_string;
use std::{fs, io::Write, path::Path};
use tempfile::TempDir;
pub fn create_file(
base_path: &TempDir,
path: impl AsRef<Path>,
contents: impl AsRef<str>,
) -> anyhow::Result<()> {
let path = base_path.path().join(path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::File::create(&path)?.write_all(contents.as_ref().as_ref())?;
Ok(())
}
#[test]
fn test_extract_toml_string() {
assert_eq!(
extract_toml_string(&toml::Value::Integer(42)),
Some(String::from("42"))
);
assert_eq!(
extract_toml_string(&toml::Value::Float(42.0)),
Some(String::from("42"))
);
assert_eq!(
extract_toml_string(&toml::Value::Boolean(true)),
Some(String::from("true"))
);
assert_eq!(
extract_toml_string(&toml::Value::Array(vec![
toml::Value::Integer(1),
toml::Value::Array(vec![toml::Value::Array(vec![toml::Value::Integer(2)])]),
toml::Value::Integer(3),
toml::Value::Integer(4),
])),
Some(String::from("1,2,3,4"))
);
assert_eq!(
extract_toml_string(&toml::Value::Table(toml::map::Map::new())),
None
);
}
}