diff --git a/Cargo.toml b/Cargo.toml index 48fa22e..0ca0221 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,9 +18,8 @@ heck = "0.5" term = "1" toml = "0.8" walkdir = "2" - -[dev-dependencies] tempfile = "3" + [badges] maintenance = {status = "actively-developed"} diff --git a/README.md b/README.md index 809829e..2ad6e96 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,25 @@ cleanup = [ { name = "auth_method", value = "none", paths = ["{{ project_name }}/docs/auth.md"]}, ] +# A list of hooks we can run at various stages of the template. +# This will execute the given files in the given order and they will be templated with access to all the variables. +# Hooks can also be run conditionally depending on a variable value. +# If a hook is meant to fail, make sure to exit with a non 0 error code. +# The files need to be executable, no restrictions otherwise. It can be python, bash, bat etc. + +# pre-gen hooks are run after all the questions have been answered. This can be used for example to do more complex +# validations +pre_gen_hooks = [ + { name = "validate", path = "validate_vars.py" }, +] + +# post-gen hooks are run after the generation is done. This can be used for additional cleanup or running other things +# like `git init`, install git hooks, downloading dependencies etc +post_gen_hooks = [ + { name = "finish setup", path = "finish_setup.sh" }, + { name = "install frontend dependencies", path = "install_spa_deps.sh", only_if = { name = "spa", value = true} }, +] + # A list of variables, the schema is explained in detail below [[variables]] name = "project_name" diff --git a/src/bin/kickstart.rs b/src/bin/kickstart.rs index 7d7cd37..5303d49 100644 --- a/src/bin/kickstart.rs +++ b/src/bin/kickstart.rs @@ -1,11 +1,18 @@ +use std::collections::HashMap; use std::error::Error; use std::path::PathBuf; use clap::{Parser, Subcommand}; +use tera::Context; +use toml::Value; -use kickstart::generation::Template; -use kickstart::terminal; -use kickstart::validate::validate_file; +use kickstart::errors::{new_error, ErrorKind, Result}; +use kickstart::prompt::{ask_bool, ask_choices, ask_integer, ask_string}; +use kickstart::utils::render_one_off_template; +use kickstart::validate_file; +use kickstart::Template; +use kickstart::TemplateDefinition; +use kickstart::{terminal, HookFile}; #[derive(Parser)] #[clap(version, author, about, subcommand_negates_reqs = true)] @@ -41,44 +48,142 @@ pub enum Command { }, } +/// Ask all the questions of that template and return the answers. +/// If `no_input` is `true`, it will automatically pick the defaults without +/// prompting the user +fn ask_questions( + definition: &TemplateDefinition, + no_input: bool, +) -> Result> { + let mut vals = HashMap::new(); + + for var in &definition.variables { + // Skip the question if the value is different from the condition + if let Some(ref cond) = var.only_if { + if let Some(val) = vals.get(&cond.name) { + if *val != cond.value { + continue; + } + } else { + // Not having it means we didn't even ask the question + continue; + } + } + + if let Some(ref choices) = var.choices { + let res = if no_input { + var.default.clone() + } else { + ask_choices(&var.prompt, &var.default, choices)? + }; + vals.insert(var.name.clone(), res); + continue; + } + + match &var.default { + Value::Boolean(b) => { + let res = if no_input { *b } else { ask_bool(&var.prompt, *b)? }; + vals.insert(var.name.clone(), Value::Boolean(res)); + continue; + } + Value::String(s) => { + let default_value = if s.contains("{{") && s.contains("}}") { + let mut context = Context::new(); + for (key, val) in &vals { + context.insert(key, val); + } + + let rendered_default = render_one_off_template(s, &context, None); + match rendered_default { + Err(e) => return Err(e), + Ok(v) => v, + } + } else { + s.clone() + }; + + let res = if no_input { + default_value + } else { + ask_string(&var.prompt, &default_value, &var.validation)? + }; + + vals.insert(var.name.clone(), Value::String(res)); + continue; + } + Value::Integer(i) => { + let res = if no_input { *i } else { ask_integer(&var.prompt, *i)? }; + vals.insert(var.name.clone(), Value::Integer(res)); + continue; + } + _ => return Err(new_error(ErrorKind::InvalidTemplate)), + } + } + + Ok(vals) +} + fn bail(e: &dyn Error) -> ! { terminal::error(&format!("Error: {}", e)); let mut cause = e.source(); while let Some(e) = cause { - terminal::error(&format!("Reason: {}", e)); + terminal::error(&format!("\nReason: {}", e)); cause = e.source(); } ::std::process::exit(1) } +macro_rules! bail_if_err { + ($expr:expr) => {{ + match $expr { + Ok(v) => v, + Err(e) => bail(&e), + } + }}; +} + +fn execute_hook(hook: &HookFile) -> Result<()> { + todo!("write me") +} + fn main() { let cli = Cli::parse(); - if let Some(Command::Validate { path }) = cli.command { - let errs = match validate_file(path) { - Ok(e) => e, - Err(e) => bail(&e), - }; + match cli.command { + Some(Command::Validate { path }) => { + let errs = bail_if_err!(validate_file(path)); - if !errs.is_empty() { - terminal::error("The template.toml is invalid:\n"); - for err in errs { - terminal::error(&format!("- {}\n", err)); + if !errs.is_empty() { + terminal::error("The template.toml is invalid:\n"); + for err in errs { + terminal::error(&format!("- {}\n", err)); + } + ::std::process::exit(1); + } else { + terminal::success("The template.toml file is valid!\n"); } - ::std::process::exit(1); - } else { - terminal::success("The template.toml file is valid!\n"); } - } else { - let template = match Template::from_input(&cli.template.unwrap(), cli.directory.as_deref()) - { - Ok(t) => t, - Err(e) => bail(&e), - }; + None => { + let template = bail_if_err!(Template::from_input( + &cli.template.unwrap(), + cli.directory.as_deref() + )); - match template.generate(&cli.output_dir, cli.no_input) { - Ok(_) => terminal::success("\nEverything done, ready to go!\n"), - Err(e) => bail(&e), - }; + // 1. ask questions + let variables = bail_if_err!(ask_questions(&template.definition, cli.no_input)); + + // 2. run pre-gen hooks + let pre_gen_hooks = bail_if_err!(template.get_pre_gen_hooks()); + if !pre_gen_hooks.is_empty() {} + + // 3. generate + bail_if_err!(template.generate(&cli.output_dir, &variables)); + + // 4. run post-gen hooks + let post_gen_hooks = bail_if_err!(template.get_post_gen_hooks()); + if !post_gen_hooks.is_empty() {} + + terminal::success("\nEverything done, ready to go!\n"); + } } } diff --git a/src/definition.rs b/src/definition.rs index b1e1769..22aa06c 100644 --- a/src/definition.rs +++ b/src/definition.rs @@ -1,16 +1,16 @@ use std::collections::HashMap; +use std::path::PathBuf; use serde::Deserialize; use tera::Context; use toml::Value; use crate::errors::{new_error, ErrorKind, Result}; -use crate::prompt::{ask_bool, ask_choices, ask_integer, ask_string}; use crate::utils::render_one_off_template; /// A condition for a question to be asked #[derive(Debug, Clone, PartialEq, Deserialize)] -pub struct VariableCondition { +pub struct Condition { pub name: String, pub value: Value, } @@ -37,11 +37,23 @@ pub struct Variable { /// A regex pattern to validate the input pub validation: Option, /// Only ask this variable if that condition is true - pub only_if: Option, + pub only_if: Option, +} + +/// A hook that should be ran +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Hook { + /// The display name for that hook + pub name: String, + /// The path to the executable file + pub path: PathBuf, + /// Only run this hook if that condition is true + pub only_if: Option, } /// The full template struct we get fom loading a TOML file #[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TemplateDefinition { /// Name of the template pub name: String, @@ -72,19 +84,21 @@ pub struct TemplateDefinition { /// http://cookiecutter.readthedocs.io/en/latest/advanced/copy_without_render.html #[serde(default)] pub copy_without_render: Vec, + /// Hooks that should be ran after collecting all variables but before generating the template + #[serde(default)] + pub pre_gen_hooks: Vec, + /// Hooks that should be ran after generating the template + #[serde(default)] + pub post_gen_hooks: Vec, /// All the questions for that template pub variables: Vec, } impl TemplateDefinition { - /// Ask all the questions of that template and return the answers. - /// If `no_input` is `true`, it will automatically pick the default without - /// prompting the user - pub fn ask_questions(&self, no_input: bool) -> Result> { - // Tera context doesn't expose a way to get value from a context - // so we store them in another hashmap + /// Returns the default values for all the variables that have one while following conditions + /// TODO: probably remove that fn? see how to test things + pub fn default_values(&self) -> Result> { let mut vals = HashMap::new(); - for var in &self.variables { // Skip the question if the value is different from the condition if let Some(ref cond) = var.only_if { @@ -98,51 +112,20 @@ impl TemplateDefinition { } } - if let Some(ref choices) = var.choices { - let res = if no_input { - var.default.clone() - } else { - ask_choices(&var.prompt, &var.default, choices)? - }; - vals.insert(var.name.clone(), res); - continue; - } - match &var.default { Value::Boolean(b) => { - let res = if no_input { *b } else { ask_bool(&var.prompt, *b)? }; - vals.insert(var.name.clone(), Value::Boolean(res)); - continue; + vals.insert(var.name.clone(), Value::Boolean(*b)); } Value::String(s) => { - let default_value = if s.contains("{{") && s.contains("}}") { - let mut context = Context::new(); - for (key, val) in &vals { - context.insert(key, val); - } - - let rendered_default = render_one_off_template(s, &context, None); - match rendered_default { - Err(e) => return Err(e), - Ok(v) => v, - } - } else { - s.clone() - }; - - let res = if no_input { - default_value - } else { - ask_string(&var.prompt, &default_value, &var.validation)? - }; - - vals.insert(var.name.clone(), Value::String(res)); - continue; + let mut context = Context::new(); + for (key, val) in &vals { + context.insert(key, val); + } + let rendered_default = render_one_off_template(s, &context, None)?; + vals.insert(var.name.clone(), Value::String(rendered_default)); } Value::Integer(i) => { - let res = if no_input { *i } else { ask_integer(&var.prompt, *i)? }; - vals.insert(var.name.clone(), Value::Integer(res)); - continue; + vals.insert(var.name.clone(), Value::Integer(*i)); } _ => return Err(new_error(ErrorKind::InvalidTemplate)), } @@ -189,7 +172,7 @@ mod tests { .unwrap(); assert_eq!(tpl.variables.len(), 3); - let res = tpl.ask_questions(true); + let res = tpl.default_values(); assert!(res.is_ok()); } @@ -208,7 +191,7 @@ mod tests { [[variables]] name = "database" - default = "postgres" + default = "mysql" prompt = "Which database to use?" choices = ["postgres", "mysql"] @@ -217,14 +200,14 @@ mod tests { prompt = "Which version of Postgres?" default = "10.4" choices = ["10.4", "9.3"] - only_if = { name = "database", value = "mysql" } + only_if = { name = "database", value = "postgres" } "#, ) .unwrap(); assert_eq!(tpl.variables.len(), 3); - let res = tpl.ask_questions(true); + let res = tpl.default_values(); assert!(res.is_ok()); let res = res.unwrap(); assert!(!res.contains_key("pg_version")); @@ -266,7 +249,7 @@ mod tests { .unwrap(); assert_eq!(tpl.variables.len(), 4); - let res = tpl.ask_questions(true); + let res = tpl.default_values(); assert!(res.is_ok()); let res = res.unwrap(); assert!(!res.contains_key("pg_version")); @@ -301,7 +284,7 @@ mod tests { assert_eq!(tpl.variables.len(), 3); - let res = tpl.ask_questions(true); + let res = tpl.default_values(); assert!(res.is_ok()); let res = res.unwrap(); diff --git a/src/errors.rs b/src/errors.rs index 79585df..58c6f05 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::result; /// A crate private constructor for `Error`. -pub(crate) fn new_error(kind: ErrorKind) -> Error { +pub fn new_error(kind: ErrorKind) -> Error { Error { kind, source: None } } diff --git a/src/generation.rs b/src/generation.rs index fc57e85..99d8d77 100644 --- a/src/generation.rs +++ b/src/generation.rs @@ -1,23 +1,50 @@ +use std::collections::HashMap; use std::env; use std::fs::{self, File}; -use std::io::Read; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use std::str; use glob::Pattern; +use tempfile::NamedTempFile; use tera::Context; +use toml::Value; use walkdir::WalkDir; -use crate::definition::TemplateDefinition; +use crate::definition::{Hook, TemplateDefinition}; use crate::errors::{map_io_err, new_error, ErrorKind, Result}; use crate::utils::{ create_directory, get_source, is_binary, read_file, render_one_off_template, write_file, Source, }; +/// Contains information about a given hook: what's the original path and what's the path +/// to the templated version +#[derive(Debug)] +pub struct HookFile { + original_path: PathBuf, + file: NamedTempFile, +} + +impl HookFile { + /// The hook original path in the template folder + pub fn original_path(&self) -> &Path { + self.original_path.as_path() + } + + /// The rendered hook file, this is what you want to execute. + pub fn path(&self) -> &Path { + self.file.path() + } +} + /// The current template being generated #[derive(Debug, PartialEq)] pub struct Template { + /// The parsed template definition + pub definition: TemplateDefinition, + /// The variables set by the user, either interactively or through the library + variables: HashMap, /// Local path to the template folder path: PathBuf, } @@ -29,7 +56,7 @@ impl Template { pub fn from_input(input: &str, directory: Option<&str>) -> Result