Repository: cargo-generate/cargo-generate
Pull request #392 (2021-07-30 14:03:05)

Allow `--define` to specify values
Relates to #389.

Just couldn't help myself 😄 

taurr: Hmmm, just noticed - there's not even any tests for the `--template-values-file` flag 😮 

Will create one tomorrow then!
taurr: > It's a very good start!
> 
> Could you please add a test case that would define a variable of a choice parameter with a value that is not part of the choices?
> 
> Same goes for boolean values that are no actual booleans.

Regarding the tests for values of the wrong type - I feel like we should still have them, but probably in another PR, as this one doesn't mess with that behavior.

Should I uncover errors (I don't even know what the expected outcome is right now), it'll affect the `--template-values-file` flag and maybe interactive mode too.

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index ec66a9b..88ee1d9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -209,6 +209,7 @@ dependencies = [
  "home",
  "ignore",
  "indicatif",
+ "indoc",
  "liquid",
  "liquid-core",
  "liquid-derive",
@@ -785,6 +786,15 @@ dependencies = [
  "regex",
 ]
 
+[[package]]
+name = "indoc"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5a75aeaaef0ce18b58056d306c27b07436fbb34b8816c53094b76dd81803136"
+dependencies = [
+ "unindent",
+]
+
 [[package]]
 name = "itertools"
 version = "0.10.0"
@@ -1780,6 +1790,12 @@ version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
 
+[[package]]
+name = "unindent"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f14ee04d9415b52b3aeab06258a3f07093182b88ba0f9b8d203f211a7a7d41c7"
+
 [[package]]
 name = "url"
 version = "2.2.2"
diff --git a/Cargo.toml b/Cargo.toml
index a0bd4ac..33cd5cf 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -48,6 +48,7 @@ features = ["derive"]
 [dev-dependencies]
 predicates = "2.0.1"
 assert_cmd = "1.0.8"
+indoc = "1.0.3"
 
 [features]
 vendored-openssl = ['openssl/vendored']
diff --git a/README.md b/README.md
index 222dd13..ffc892f 100644
--- a/README.md
+++ b/README.md
@@ -70,7 +70,7 @@ cargo generate --git https://github.com/githubusername/mytemplate.git <relative-
 
 > NOTE: The specified `relative-template-path` will be used as the actual template root, whether or not this is actually true!
 
-> NOTE: When using the `subfolder` feature, `cargo-generate` will search for
+> NOTE: When using the `subfolder` feature, `cargo-generate` will search for the `cargo-generate.toml` file in the subfolder first, traversing back towards the template root in case it is not found.
 
 ## git over ssh
 
@@ -100,6 +100,8 @@ will be used for cloning a http(s) template repository.
 Favorite templates can be defined in a config file, that by default is placed at `$CARGO_HOME/cargo-generate`. 
 To specify an alternative configuration file, use the `--config <config-file>` option.
 
+> NOTE: A relative `--config` option, will be relative to the template root during expansion.
+
 Each favorite template is specified in its own section, e.g.:
 
 ```toml
@@ -292,6 +294,8 @@ phone_number = { type = "string", prompt = "What's your phone number?", regex =
 
 For automation purposes the user of the template may provide provide a file containing the values for the keys in the template by using the `--template-values-file` flag.
 
+> NOTE: A relative path will be relative to current working dir, which is *not* inside the expanding template!
+
 The file should be a toml file containing the following (for the example template provided above):
 
 ```toml
@@ -300,6 +304,14 @@ hypervisor = "qemu"
 network_enabled = true
 ```
 
+Values can also be specified on the command line using the `--define | -d` flag. Values specified like this will override any other.
+
+```sh
+cargo generate --git https://github.com/githubusername/mytemplate.git --name myproject --define hypervisor=none -d network_enabled=false
+```
+
+If a value has been specified before, the last `-d` of a variable will overide any previous value.
+
 ## Include / Exclude
 
 Templates support a `cargo-generate.toml`, with a "template" section that allows you to configure the files that will be processed by `cargo-generate`.
diff --git a/src/lib.rs b/src/lib.rs
index db4a389..7c2a269 100755
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -94,7 +94,7 @@ mod template;
 mod template_variables;
 
 use anyhow::{anyhow, bail, Context, Result};
-use config::{Config, ConfigValues, CONFIG_FILE_NAME};
+use config::{Config, CONFIG_FILE_NAME};
 use console::style;
 use favorites::{list_favorites, resolve_favorite_args};
 use std::{
@@ -107,11 +107,11 @@ use std::{
 use structopt::StructOpt;
 use tempfile::TempDir;
 
-use crate::template_variables::{CrateType, ProjectName};
 use crate::{
     app_config::{app_config_path, AppConfig},
-    git::GitConfig,
+    template_variables::{CrateType, ProjectName},
 };
+use crate::{git::GitConfig, template_variables::resolve_template_values};
 
 #[derive(StructOpt)]
 #[structopt(bin_name = "cargo")]
@@ -192,10 +192,13 @@ pub struct Args {
     /// Use a different ssh identity
     #[structopt(short = "i", long = "identity", parse(from_os_str))]
     pub ssh_identity: Option<PathBuf>,
+
+    /// Define a value for use during template expansion
+    #[structopt(long, short, number_of_values = 1)]
+    pub define: Vec<String>,
 }
 
-//
-#[derive(Debug, StructOpt)]
+#[derive(Debug, StructOpt, Clone, Copy)]
 pub enum Vcs {
     None,
     Git,
@@ -213,6 +216,18 @@ impl FromStr for Vcs {
     }
 }
 
+impl Vcs {
+    fn initialize(&self, project_dir: &Path, branch: String) -> Result<()> {
+        match self {
+            Vcs::None => {}
+            Vcs::Git => {
+                git::init(project_dir, &branch)?;
+            }
+        };
+        Ok(())
+    }
+}
+
 pub fn generate(mut args: Args) -> Result<()> {
     let path = &app_config_path(&args.config)?;
     let app_config = AppConfig::from_path(path)?;
@@ -224,21 +239,25 @@ pub fn generate(mut args: Args) -> Result<()> {
     resolve_favorite_args(&app_config, &mut args)?;
 
     let project_name = resolve_project_name(&args)?;
-    let project_dir = create_project_dir(&project_name, args.force)?;
+    let project_dir = resolve_project_dir(&project_name, args.force)?;
 
     let (template_base_dir, branch) = clone_git_template_into_temp(&args)?;
     let template_folder = resolve_template_dir(&template_base_dir, &args)?;
 
-    let template_values = args
-        .template_values_file
-        .as_ref()
-        .map(|p| Path::new(p))
-        .map_or(Ok(Default::default()), |path| get_config_file_values(path))?;
-
     let template_config = Config::from_path(
         &locate_template_file(CONFIG_FILE_NAME, &template_base_dir, &args.subfolder).ok(),
     )?;
 
+    let template_values = resolve_template_values(&args)?;
+
+    println!(
+        "{} {} `{}`{}",
+        emoji::WRENCH,
+        style("Creating project called").bold(),
+        style(project_dir.display()).bold().yellow(),
+        style("...").bold()
+    );
+
     expand_template(
         &project_name,
         &template_folder,
@@ -246,10 +265,8 @@ pub fn generate(mut args: Args) -> Result<()> {
         template_config,
         &args,
     )?;
-
     copy_dir_all(&template_folder, &project_dir)?;
-
-    initialize_vcs(args, &project_dir, branch)?;
+    args.vcs.initialize(&project_dir, branch)?;
 
     println!(
         "{} {} {} {}",
@@ -261,16 +278,6 @@ pub fn generate(mut args: Args) -> Result<()> {
     Ok(())
 }
 
-fn initialize_vcs(args: Args, project_dir: &Path, branch: String) -> Result<()> {
-    match args.vcs {
-        Vcs::None => {}
-        Vcs::Git => {
-            git::init(project_dir, &branch)?;
-        }
-    };
-    Ok(())
-}
-
 fn resolve_project_name(args: &Args) -> Result<ProjectName> {
     match args.name {
         Some(ref n) => Ok(ProjectName::new(n)),
@@ -399,21 +406,7 @@ where
     }
 }
 
-fn get_config_file_values(path: &Path) -> Result<HashMap<String, toml::Value>> {
-    match fs::read_to_string(path) {
-        Ok(ref contents) => toml::from_str::<ConfigValues>(contents)
-            .map(|v| v.values)
-            .map_err(|e| e.into()),
-        Err(e) => bail!(
-            "{} {} {}",
-            emoji::ERROR,
-            style("Values File Error:").bold().red(),
-            style(e).bold().red(),
-        ),
-    }
-}
-
-fn create_project_dir(name: &ProjectName, force: bool) -> Result<PathBuf> {
+fn resolve_project_dir(name: &ProjectName, force: bool) -> Result<PathBuf> {
     let dir_name = if force {
         name.raw()
     } else {
@@ -424,13 +417,6 @@ fn create_project_dir(name: &ProjectName, force: bool) -> Result<PathBuf> {
         .unwrap_or_else(|_e| ".".into())
         .join(&dir_name);
 
-    println!(
-        "{} {} `{}`{}",
-        emoji::WRENCH,
-        style("Creating project called").bold(),
-        style(&dir_name).bold().yellow(),
-        style("...").bold()
-    );
     if project_dir.exists() {
         Err(anyhow!(
             "{} {}",
diff --git a/src/template_variables/mod.rs b/src/template_variables/mod.rs
index 8dd178e..1faf4aa 100644
--- a/src/template_variables/mod.rs
+++ b/src/template_variables/mod.rs
@@ -3,7 +3,154 @@ mod crate_type;
 mod os_arch;
 mod project_name;
 
+use crate::{config::ConfigValues, emoji, Args};
+
+use anyhow::Result;
+use console::style;
+use regex::Regex;
+use std::{collections::HashMap, fmt::Display, fs, path::Path};
+use toml::Value;
+
 pub(crate) use authors::{get_authors, Authors};
 pub(crate) use crate_type::CrateType;
 pub(crate) use os_arch::get_os_arch;
 pub(crate) use project_name::ProjectName;
+
+pub(crate) fn resolve_template_values(args: &Args) -> Result<HashMap<String, Value>> {
+    let mut template_values = args
+        .template_values_file
+        .as_ref()
+        .map(|p| Path::new(p))
+        .map_or(Ok(Default::default()), |path| {
+            read_template_values_file(path)
+        })?;
+
+    add_cli_defined_values(&mut template_values, &args.define)?;
+
+    Ok(template_values)
+}
+
+fn read_template_values_file(path: &Path) -> Result<HashMap<String, Value>> {
+    match fs::read_to_string(path) {
+        Ok(ref contents) => toml::from_str::<ConfigValues>(contents)
+            .map(|v| v.values)
+            .map_err(|e| e.into()),
+        Err(e) => anyhow::bail!(
+            "{} {} {}",
+            emoji::ERROR,
+            style("Values File Error:").bold().red(),
+            style(e).bold().red(),
+        ),
+    }
+}
+
+fn add_cli_defined_values<S: AsRef<str> + Display>(
+    template_values: &mut HashMap<String, Value>,
+    definitions: &[S],
+) -> Result<()> {
+    let key_value_regex = Regex::new(r"^([a-zA-Z]+[a-zA-Z0-9\-_]*)\s*=\s*(.+)$").unwrap();
+
+    definitions
+        .iter()
+        .try_fold(
+            template_values,
+            |template_values, definition| match key_value_regex.captures(definition.as_ref()) {
+                Some(cap) => {
+                    let key = cap.get(1).unwrap().as_str().to_string();
+                    let value = cap.get(2).unwrap().as_str().to_string();
+                    println!("{} => '{}'", key, value);
+                    template_values.insert(key, Value::from(value));
+                    Ok(template_values)
+                }
+                None => Err(anyhow::anyhow!(
+                    "{} {} {}",
+                    emoji::ERROR,
+                    style("Failed to parse value:").bold().red(),
+                    style(definition).bold().red(),
+                )),
+            },
+        )?;
+    Ok(())
+}
+
+#[cfg(test)]
+mod test {
+    use super::add_cli_defined_values;
+    use std::collections::HashMap;
+
+    #[test]
+    fn names_must_start_with_word_char() {
+        let mut template_values = HashMap::new();
+        let definitions = vec!["0key=42"];
+        let result = add_cli_defined_values(&mut template_values, &definitions);
+        assert!(result.is_err());
+
+        let definitions = vec!["$key=42"];
+        let result = add_cli_defined_values(&mut template_values, &definitions);
+        assert!(result.is_err());
+
+        let definitions = vec!["-key=42"];
+        let result = add_cli_defined_values(&mut template_values, &definitions);
+        assert!(result.is_err());
+
+        let definitions = vec!["_key=42"];
+        let result = add_cli_defined_values(&mut template_values, &definitions);
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn names_may_contain_digits() {
+        let mut template_values = HashMap::new();
+        let definitions = vec!["my0123456789key=42"];
+        let result = add_cli_defined_values(&mut template_values, &definitions);
+        assert!(result.is_ok());
+
+        let val = template_values
+            .get("my0123456789key")
+            .unwrap()
+            .as_str()
+            .unwrap();
+        assert_eq!(val, "42");
+    }
+
+    #[test]
+    fn names_may_contain_dash() {
+        let mut template_values = HashMap::new();
+        let definitions = vec!["my-key=42"];
+        let result = add_cli_defined_values(&mut template_values, &definitions);
+        assert!(result.is_ok());
+
+        let val = template_values.get("my-key").unwrap().as_str().unwrap();
+        assert_eq!(val, "42");
+    }
+
+    #[test]
+    fn names_may_contain_underscore() {
+        let mut template_values = HashMap::new();
+        let definitions = vec!["my_key=42"];
+        let result = add_cli_defined_values(&mut template_values, &definitions);
+        assert!(result.is_ok());
+
+        let val = template_values.get("my_key").unwrap().as_str().unwrap();
+        assert_eq!(val, "42");
+    }
+
+    #[test]
+    fn spaces_are_not_allowed_in_names() {
+        let mut template_values = HashMap::new();
+        let definitions = vec!["my key=42"];
+        let result = add_cli_defined_values(&mut template_values, &definitions);
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn spaces_around_assignment_is_ok() {
+        let mut template_values = HashMap::new();
+        let definitions = vec!["key   =      42"];
+        let result = add_cli_defined_values(&mut template_values, &definitions);
+        assert!(result.is_ok());
+
+        let val = template_values.get("key").unwrap().as_str().unwrap();
+        assert_eq!(val, "42");
+    }
+}


Test patch:
diff --git a/tests/integration/favorites.rs b/tests/integration/favorites.rs
index 8a1c0a5..efa1f28 100644
--- a/tests/integration/favorites.rs
+++ b/tests/integration/favorites.rs
@@ -1,25 +1,23 @@
 use predicates::prelude::*;
 
+use crate::helpers::project::binary;
 use crate::helpers::{project::Project, project_builder::tmp_dir};
 
 use assert_cmd::prelude::*;
-use std::env;
-use std::{path::PathBuf, process::Command};
-
-fn binary() -> Command {
-    Command::cargo_bin(env!("CARGO_PKG_NAME")).unwrap()
-}
+use indoc::indoc;
+use std::path::PathBuf;
 
 fn create_template(name: &str) -> Project {
     tmp_dir()
         .file(
             "Cargo.toml",
             format!(
-                r#"[package]
-name = "{{project-name}}"
-description = "{}"
-version = "0.1.0"
-"#,
+                indoc! {r#"
+                    [package]
+                    name = "{{project-name}}"
+                    description = "{}"
+                    version = "0.1.0"
+                    "#},
                 name
             )
             .as_str(),
@@ -33,12 +31,12 @@ fn create_favorite_config(name: &str, template: &Project) -> (Project, PathBuf)
         .file(
             "cargo-generate",
             format!(
-                r#"
-[favorites.{name}]
-description = "Favorite for the {name} template"
-git = "{git}"
-branch = "{branch}"
-"#,
+                indoc! {r#"
+                    [favorites.{name}]
+                    description = "Favorite for the {name} template"
+                    git = "{git}"
+                    branch = "{branch}"
+                    "#},
                 name = name,
                 git = template.path().display().to_string().escape_default(),
                 branch = "main"
@@ -77,12 +75,12 @@ fn favorite_subfolder_must_be_valid() {
         .file("Cargo.toml", "")
         .file(
             "inner/Cargo.toml",
-            r#"
+            indoc! {r#"
                 [package]
                 name = "{{project-name}}"
                 description = "A wonderful project"
                 version = "0.1.0"
-            "#,
+            "#},
         )
         .init_git()
         .build();
@@ -127,12 +125,12 @@ fn favorite_with_subfolder() -> anyhow::Result<()> {
         .file("Cargo.toml", "")
         .file(
             "inner/Cargo.toml",
-            r#"
+            indoc! {r#"
                 [package]
                 name = "{{project-name}}"
                 description = "A wonderful project"
                 version = "0.1.0"
-            "#,
+            "#},
         )
         .init_git()
         .build();
diff --git a/tests/integration/library.rs b/tests/integration/library.rs
index 0973960..c398707 100644
--- a/tests/integration/library.rs
+++ b/tests/integration/library.rs
@@ -33,6 +33,7 @@ version = "0.1.0"
         bin: true,
         lib: false,
         ssh_identity: None,
+        define: vec![],
     };
     // need to cd to the dir as we aren't running in the cargo shell.
     assert!(std::env::set_current_dir(&dir.root).is_ok());
diff --git a/tests/integration/main.rs b/tests/integration/main.rs
index 0ed9493..19ec3a8 100644
--- a/tests/integration/main.rs
+++ b/tests/integration/main.rs
@@ -5,3 +5,4 @@ mod basics;
 mod favorites;
 mod filenames;
 mod library;
+mod values;
diff --git a/tests/integration/values.rs b/tests/integration/values.rs
new file mode 100644
index 0000000..84ffdda
--- /dev/null
+++ b/tests/integration/values.rs
@@ -0,0 +1,155 @@
+use predicates::prelude::*;
+
+use crate::helpers::project::binary;
+use crate::helpers::project_builder::tmp_dir;
+
+use assert_cmd::prelude::*;
+use indoc::indoc;
+
+#[test]
+fn it_accepts_template_values_file() {
+    let template = tmp_dir()
+        .file(
+            "my-values.toml",
+            indoc! {r#"
+                [values]
+                my_value = "content of my-value"
+            "#},
+        )
+        .file(
+            "Cargo.toml",
+            indoc! {r#"
+                [package]
+                name = "{{project-name}}"
+                description = "{{my_value}}"
+                version = "0.1.0"
+            "#},
+        )
+        .init_git()
+        .build();
+
+    let dir = tmp_dir().build();
+
+    binary()
+        .arg("generate")
+        .arg("--name")
+        .arg("foobar-project")
+        .arg("--template-values-file")
+        .arg(template.path().join("my-values.toml"))
+        .arg(template.path())
+        .current_dir(&dir.path())
+        .assert()
+        .success()
+        .stdout(predicates::str::contains("Done!").from_utf8());
+
+    let cargo_toml = dir.read("foobar-project/Cargo.toml");
+    assert!(cargo_toml.contains("content of my-value"));
+}
+
+#[test]
+fn it_accepts_values_via_long_option() {
+    let template = tmp_dir()
+        .file(
+            "Cargo.toml",
+            indoc! {r#"
+                [package]
+                name = "{{project-name}}"
+                description = "{{my_value}}"
+                version = "0.1.0"
+            "#},
+        )
+        .init_git()
+        .build();
+
+    let dir = tmp_dir().build();
+
+    binary()
+        .arg("generate")
+        .arg("--name")
+        .arg("foobar-project")
+        .arg("--define")
+        .arg(r#"my_value="content of my-value""#)
+        .arg(template.path())
+        .current_dir(&dir.path())
+        .assert()
+        .success()
+        .stdout(predicates::str::contains("Done!").from_utf8());
+
+    let cargo_toml = dir.read("foobar-project/Cargo.toml");
+    assert!(cargo_toml.contains("content of my-value"));
+}
+
+#[test]
+fn it_accepts_values_via_short_option() {
+    let template = tmp_dir()
+        .file(
+            "Cargo.toml",
+            indoc! {r#"
+                [package]
+                name = "{{project-name}}"
+                description = "{{my_value}}"
+                version = "0.1.0"
+            "#},
+        )
+        .init_git()
+        .build();
+
+    let dir = tmp_dir().build();
+
+    binary()
+        .arg("generate")
+        .arg("--name")
+        .arg("foobar-project")
+        .arg("-d")
+        .arg(r#"my_value="content of my-value""#)
+        .arg(template.path())
+        .current_dir(&dir.path())
+        .assert()
+        .success()
+        .stdout(predicates::str::contains("Done!").from_utf8());
+
+    let cargo_toml = dir.read("foobar-project/Cargo.toml");
+    assert!(cargo_toml.contains("content of my-value"));
+}
+
+#[test]
+fn cli_value_overrides_others() {
+    let template = tmp_dir()
+        .file(
+            "my-values.toml",
+            indoc! {r#"
+                [values]
+                my_value = "content of file-value"
+            "#},
+        )
+        .file(
+            "Cargo.toml",
+            indoc! {r#"
+                [package]
+                name = "{{project-name}}"
+                description = "{{my_value}}"
+                version = "0.1.0"
+            "#},
+        )
+        .init_git()
+        .build();
+
+    let dir = tmp_dir().build();
+
+    binary()
+        .arg("generate")
+        .arg("--name")
+        .arg("foobar-project")
+        .arg("--template-values-file")
+        .arg(template.path().join("my-values.toml"))
+        .arg("-d")
+        .arg(r#"my_value="content of cli-value""#)
+        .arg(template.path())
+        .current_dir(&dir.path())
+        .assert()
+        .success()
+        .stdout(predicates::str::contains("Done!").from_utf8());
+
+    let cargo_toml = dir.read("foobar-project/Cargo.toml");
+    assert!(cargo_toml.contains("content of cli-value"));
+}