Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
cargo run --features=cli -- examples/with-directory/ --no-input -o with-directory
cargo run --features=cli -- examples/derived/ --no-input -o derived
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions examples/derived/template.toml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions examples/derived/{{package_path}}/Main.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package {{package}};

public class Main {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
97 changes: 96 additions & 1 deletion src/definition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Only for questions with choices
pub choices: Option<Vec<Value>>,
/// A regex pattern to validate the input. Only used where the value is meant to be a string.
pub validation: Option<String>,
/// Only ask this variable if that condition is true
pub only_if: Option<Condition>,
/// Whether this variable is derived and should not be prompted
pub derived: Option<bool>,
}

/// A hook is a file that will get executed
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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")));
}
}
15 changes: 11 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,30 +54,37 @@ fn ask_questions(template: &Template, no_input: bool) -> Result<HashMap<String,
let mut vals = HashMap::new();

for var in &template.definition.variables {
if var.derived.unwrap_or(false) {
let default = template.get_default_for(&var.name, &vals)?;
vals.insert(var.name.clone(), default);
continue;
}

if !template.should_ask_variable(&var.name, &vals)? {
continue;
}
let default = template.get_default_for(&var.name, &vals)?;
let prompt_text = var.prompt.as_deref().unwrap_or("");

if let Some(ref choices) = var.choices {
let res = if no_input { default } else { ask_choices(&var.prompt, &default, choices)? };
let res = if no_input { default } else { ask_choices(prompt_text, &default, choices)? };
vals.insert(var.name.clone(), res);
continue;
}

match default {
Value::Boolean(b) => {
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;
}
Expand Down