diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e8f2032..7e7cf89 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -44,4 +44,5 @@ jobs: cargo run --features=cli -- examples/default-from-variable/ --no-input -o default cargo run --features=cli -- examples/slugify/ --no-input -o slugify cargo run --features=cli -- examples/super-basic/ --no-input -o super-basic - cargo run --features=cli -- examples/with-directory/ --no-input -o with-directory \ No newline at end of file + cargo run --features=cli -- examples/with-directory/ --no-input -o with-directory + cargo run --features=cli -- examples/derived/ --no-input -o derived \ No newline at end of file diff --git a/README.md b/README.md index 759418d..14d754c 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,11 @@ default = "my-project" prompt = "What is the name of this project?" validation = "^([a-zA-Z][a-zA-Z0-9_-]+)$" +[[variables]] +name = "slug" +default = "{{ project_name | slugify }}" +derived = true + [[variables]] name = "database" default = "postgres" @@ -204,10 +209,11 @@ A variable has the following required fields: - `name`: the name of the variable in Tera context - `default`: the default value for that question, `kickstart` uses that to deduce the type of that value (only string, bool and integer are currently supported). You can use previous variables in the default, eg `"{{ project_name | lower }}"` will replace `project_name` with the value of the variable. -- `prompt`: the text to display to the user +- `prompt`: the text to display to the user unless the variable is derived -And three more optional fields: +and four more optional fields: +- `derived`: set to `true` if the variable should not prompt the user and instead be computed from default - `choices`: a list of potential values, `kickstart` will make the user pick one - `only_if`: this question will only be asked if the variable `name` has the value `value` - `validation`: a Regex pattern to check when getting a string value @@ -230,6 +236,11 @@ You can use these like any other filter, e.g. `{{variable_name | camel_case}}`. ## Changelog +### 0.5.1 (2025-12-08) + +- New `derived = true` flag allows variables to be computed from default without prompting the user +- `prompt` remains required for non-derived variables, but is optional when `derived` is set to true + ### 0.5.0 (2024-12-13) - The `sub-dir` parameter has been renamed to `directory` in the CLI diff --git a/examples/derived/template.toml b/examples/derived/template.toml new file mode 100644 index 0000000..3d35fae --- /dev/null +++ b/examples/derived/template.toml @@ -0,0 +1,14 @@ +name = "Java domain" +description = "Dynamic folder structure" +kickstart_version = 1 + +[[variables]] +name = "package" +default = "my.domain.test" +prompt = "Enter your package name (dot-separated):" +validation = "^[a-z]+(\\.[a-z][a-z0-9_]*)*$" + +[[variables]] +name = "package_path" +default = "{{ package | replace(from='.', to='/')}}" +derived = true diff --git a/examples/derived/{{package_path}}/Main.java b/examples/derived/{{package_path}}/Main.java new file mode 100644 index 0000000..14d1670 --- /dev/null +++ b/examples/derived/{{package_path}}/Main.java @@ -0,0 +1,7 @@ +package {{package}}; + +public class Main { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} diff --git a/src/definition.rs b/src/definition.rs index a788eae..8c6d8f5 100644 --- a/src/definition.rs +++ b/src/definition.rs @@ -35,13 +35,15 @@ pub struct Variable { /// A default value is required. It can be a Tera expression if it is a string. pub(crate) default: Value, /// The text asked to the user - pub prompt: String, + pub prompt: Option, /// Only for questions with choices pub choices: Option>, /// A regex pattern to validate the input. Only used where the value is meant to be a string. pub validation: Option, /// Only ask this variable if that condition is true pub only_if: Option, + /// Whether this variable is derived and should not be prompted + pub derived: Option, } /// A hook is a file that will get executed @@ -132,6 +134,22 @@ impl TemplateDefinition { } for var in &self.variables { + if var.prompt.is_none() && !var.derived.unwrap_or(false) { + errs.push(format!( + "Variable `{}` must have either a prompt or be marked as derived", + var.name + )); + } + + if let Some(ref prompt) = var.prompt { + if prompt.trim().is_empty() { + errs.push(format!( + "Variable `{}` has an empty prompt, which is not allowed", + var.name + )); + } + } + let type_str = var.default.type_str(); types.insert(var.name.to_string(), type_str); @@ -421,4 +439,81 @@ mod tests { assert_eq!(got_value, &Value::String(expected_value)) } + + #[test] + fn can_handle_derived_variable() { + let tpl: TemplateDefinition = toml::from_str( + r#" + name = "Test template" + description = "Testing derived variable behavior" + kickstart_version = 1 + + [[variables]] + name = "project_name" + default = "My project" + prompt = "What's the name of your project?" + + [[variables]] + name = "slug" + default = "{{project_name | slugify}}" + derived = true + "#, + ) + .unwrap(); + + assert_eq!(tpl.variables.len(), 2); + + let res = tpl.default_values(); + assert!(res.is_ok()); + let res = res.unwrap(); + + // Check that both variables exist + assert!(res.contains_key("project_name")); + assert!(res.contains_key("slug")); + + // Check that slug was rendered from project_name + let expected_slug = Value::String("my-project".to_string()); + assert_eq!(res.get("slug"), Some(&expected_slug)); + } + + #[test] + fn fails_if_prompt_and_derived_missing() { + let tpl: TemplateDefinition = toml::from_str( + r#" + name = "Test template" + kickstart_version = 1 + + [[variables]] + name = "broken_var" + default = "some_value" + "#, + ) + .unwrap(); + + let errs = tpl.validate(); + assert!(!errs.is_empty()); + assert!(errs + .iter() + .any(|e| e.contains("must have either a prompt or be marked as derived"))); + } + + #[test] + fn fails_if_prompt_is_empty() { + let tpl: TemplateDefinition = toml::from_str( + r#" + name = "Test template" + kickstart_version = 1 + + [[variables]] + name = "broken_var" + default = "some_value" + prompt = "" + "#, + ) + .unwrap(); + + let errs = tpl.validate(); + assert!(!errs.is_empty()); + assert!(errs.iter().any(|e| e.contains("empty prompt"))); + } } diff --git a/src/main.rs b/src/main.rs index 82ff73f..090991b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,30 +54,37 @@ fn ask_questions(template: &Template, no_input: bool) -> Result { - let res = if no_input { b } else { ask_bool(&var.prompt, b)? }; + let res = if no_input { b } else { ask_bool(prompt_text, b)? }; vals.insert(var.name.clone(), Value::Boolean(res)); continue; } Value::String(s) => { - let res = if no_input { s } else { ask_string(&var.prompt, &s, &var.validation)? }; + let res = if no_input { s } else { ask_string(prompt_text, &s, &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)? }; + let res = if no_input { i } else { ask_integer(prompt_text, i)? }; vals.insert(var.name.clone(), Value::Integer(res)); continue; }