diff --git a/Cargo.lock b/Cargo.lock
index 44528cf..00458ee 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1200,7 +1200,7 @@ dependencies = [
[[package]]
name = "rona"
-version = "2.33.0"
+version = "2.34.0"
dependencies = [
"arboard",
"assert_cmd",
diff --git a/Cargo.toml b/Cargo.toml
index 50d59b2..5808172 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,7 +16,7 @@ license = "Apache-2.0 OR MIT"
name = "rona"
readme = "README.md"
repository = "https://github.com/rona-rs/rona"
-version = "2.33.0"
+version = "2.34.0"
[[bin]]
doc = true
diff --git a/README.md b/README.md
index 2f14857..02e4e0c 100644
--- a/README.md
+++ b/README.md
@@ -474,12 +474,14 @@ This replaces the need for a separate tool when a project requires additional st
| `kind` | Prefetch result | Behaviour |
| -------- | ----------------------------- | ------------------------------------------------------------ |
-| `select` | non-empty list | Select from list + `(none)` (if optional) + `Other (enter manually)` |
+| `select` | non-empty list | Fuzzy picker over the candidates, plus `(none)` when the field is optional. Typing filters the list and offers the typed text as a new value, so a value the list does not hold is one Enter away. |
| `select` | empty | Falls back to a free-text prompt |
| `text` | non-empty list from `command` | Same as `select` with non-empty list |
| `text` | 0–1 values from `branch` | Free-text prompt with the extracted value as the default |
| `text` | nothing | Plain free-text prompt |
+**Entering a value the list does not hold**: the candidates are suggestions, not a closed set. Type the value you want; the list filters as you type, and as soon as nothing matches, a `Create "..."` row takes its place. Pressing Enter accepts it, so a new scope costs the same keystrokes as an existing one. Arrow keys still walk the list, Esc cancels the prompt.
+
When a field is skipped (optional + user chose `(none)`), the variable is simply absent. Use a conditional block in your template to handle this cleanly: `{?scope}({scope}){/scope}`.
#### Prompt order
@@ -544,7 +546,6 @@ $ Select scope
> api
auth
(none)
- Other (enter manually)
$ Ticket reference (PROJ-42)
> PROJ-42
@@ -575,7 +576,7 @@ feat: Add login endpoint [PROJ-42]
#### Example: static select options (no prefetch)
-If you just want a fixed list without any prefetching, omit the `prefetch` block and list `kind = "select"` — but note that without prefetch, an empty candidate list causes the prompt to fall back to a free-text input. For a true fixed list, provide the options via `prefetch.command` using a shell command like `echo`:
+If you just want a fixed list without any prefetching, omit the `prefetch` block and list `kind = "select"` — but note that without prefetch, an empty candidate list causes the prompt to fall back to a free-text input. Provide the options via `prefetch.command` using a shell command like `echo`, and add a `validation` regex when only those options may be accepted, since the picker otherwise takes any value the user types:
```toml
[[extra_fields]]
diff --git a/src/commands/generate.rs b/src/commands/generate.rs
index 41eb081..e1bf09b 100644
--- a/src/commands/generate.rs
+++ b/src/commands/generate.rs
@@ -199,8 +199,14 @@ fn build_commit_message(
let template = commit_template(config);
- // Validate the template, including any extra field variable names.
- let extra_names: Vec<&str> = extra_values.keys().map(String::as_str).collect();
+ // Validate the template against every declared extra field, not just the answered ones: a
+ // skipped optional field is absent from `extra_values`, but its `{?field}` block stays valid.
+ let extra_names: Vec<&str> = config
+ .project_config
+ .commit_extra_fields
+ .iter()
+ .map(|field| field.name.as_str())
+ .collect();
if let Err(e) = validate_template(template, &extra_names) {
println!(
"{} Template validation error: {e}",
@@ -317,6 +323,33 @@ mod tests {
Ok(())
}
+ /// REGRESSION TEST: skipping an optional extra field used to invalidate the whole template,
+ /// because validation only knew the fields that were answered, and the message fell back to
+ /// the built-in format. A declared field keeps its `{?field}` block valid either way.
+ #[test]
+ fn test_a_skipped_extra_field_keeps_the_template_valid() -> TestResult {
+ let template = "{commit_type}{?scope}({scope}){/scope}: {message}";
+
+ validate_template(template, &["scope"])?;
+
+ let variables = TemplateVariables {
+ commit_number: None,
+ commit_type: "feat".to_string(),
+ branch_name: "main".to_string(),
+ message: "Add login".to_string(),
+ date: "2024-01-15".to_string(),
+ time: "14:30:00".to_string(),
+ author: "Test User".to_string(),
+ email: "test@example.com".to_string(),
+ };
+
+ // The user skipped the scope, so it is absent from the values.
+ let result = process_template(template, &variables, &HashMap::new())?;
+
+ assert_eq!(result, "feat: Add login");
+ Ok(())
+ }
+
#[test]
fn test_fallback_message_without_a_commit_number() {
let message = fallback_message(None, Some("fix"), "bugfix", "Fix issue");
diff --git a/src/extra_fields.rs b/src/extra_fields.rs
index c865074..3995ab1 100644
--- a/src/extra_fields.rs
+++ b/src/extra_fields.rs
@@ -5,7 +5,7 @@
use std::collections::{HashMap, HashSet};
-use dialoguer::{FuzzySelect, Input};
+use dialoguer::Input;
use regex::Regex;
use serde::{Deserialize, Serialize};
@@ -21,7 +21,7 @@ pub enum FieldKind {
/// Free-form text input.
#[default]
Text,
- /// Selection from a list, with an "Other (enter manually)" fallback.
+ /// Fuzzy pick from the candidate list, or a value typed on the spot.
Select,
}
@@ -157,9 +157,6 @@ fn extract_matches<'a>(
results
}
-const NONE_OPTION: &str = "(none)";
-const OTHER_OPTION: &str = "Other (enter manually)";
-
/// Prompt the user for an extra field value.
///
/// Returns `None` when the field is optional and the user chose to skip it.
@@ -202,7 +199,7 @@ pub fn prompt_extra_field(field: &ExtraField) -> Result