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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -544,7 +546,6 @@ $ Select scope
> api
auth
(none)
Other (enter manually)

$ Ticket reference (PROJ-42)
> PROJ-42
Expand Down Expand Up @@ -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]]
Expand Down
37 changes: 35 additions & 2 deletions src/commands/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down Expand Up @@ -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");
Expand Down
53 changes: 27 additions & 26 deletions src/extra_fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

use std::collections::{HashMap, HashSet};

use dialoguer::{FuzzySelect, Input};
use dialoguer::Input;
use regex::Regex;
use serde::{Deserialize, Serialize};

Expand All @@ -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,
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -202,7 +199,7 @@ pub fn prompt_extra_field(field: &ExtraField) -> Result<Option<String>> {
));

if use_select {
prompt_as_select(field, prompt_text, candidates, validator_regex)
prompt_as_select(field, prompt_text, &candidates, validator_regex)
} else {
// Branch prefetch: the single extracted value becomes the text default
let default_owned = candidates.into_iter().next();
Expand All @@ -215,31 +212,35 @@ pub fn prompt_extra_field(field: &ExtraField) -> Result<Option<String>> {
}
}

/// Offers the prefetched candidates without closing the door on a value they do not hold: the
/// picker filters the list as the user types and turns unmatched text into a new value, so adding
/// a scope costs no more keystrokes than reusing one.
fn prompt_as_select(
field: &ExtraField,
prompt_text: &str,
candidates: Vec<String>,
candidates: &[String],
validator_regex: Option<Regex>,
) -> Result<Option<String>> {
let mut options = candidates;
if !field.required {
options.push(NONE_OPTION.to_string());
}
options.push(OTHER_OPTION.to_string());

let index = FuzzySelect::with_theme(&crate::theme::prompt_theme())
.with_prompt(prompt_text)
.items(&options)
.default(0)
.interact_opt()
.map_err(|_| RonaError::UserCancelled)?
.ok_or(RonaError::UserCancelled)?;

match options[index].as_str() {
s if s == NONE_OPTION => Ok(None),
s if s == OTHER_OPTION => prompt_as_text(field, prompt_text, None::<&str>, validator_regex),
value => Ok(Some(value.to_string())),
}
let required = field.required;
let pattern = field.validation.clone();

let validate = move |value: &str| -> std::result::Result<(), String> {
if required && value.trim().is_empty() {
return Err("This field is required.".to_string());
}
if let Some(ref re) = validator_regex
&& !value.is_empty()
&& !re.is_match(value)
{
return Err(format!(
"Must match pattern: {}",
pattern.as_deref().unwrap_or("")
));
}
Ok(())
};

crate::prompt::select_or_create(prompt_text, candidates, !required, &validate)
}

fn prompt_as_text(
Expand Down
6 changes: 5 additions & 1 deletion src/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@
//!
//! The prompts for config-declared extra fields live in [`crate::extra_fields`], next to the
//! declarations and the prefetch that feed them; this module drives them in the configured
//! order via [`fields`].
//! order via [`fields`], and lends them the [`select_or_create`] picker for their candidate lists.

mod picker;

use std::{collections::HashMap, fmt::Display};

use dialoguer::{Confirm, FuzzySelect, Input, MultiSelect};
use regex::Regex;

pub(crate) use picker::select_or_create;

use crate::{
errors::{Result, RonaError},
extra_fields::{BuiltInFieldConfig, ExtraField, prompt_extra_field},
Expand Down
Loading