#![expect(
clippy::expect_used,
clippy::missing_panics_doc,
reason = "Updaters make a heavy usage of `expect` instead of proper error \
handling. This is because `ConfigUpdater::load` already validates the \
configuration by parsing it to a `Config`. Any error occurring here is \
a bug, hence should lead to a panic."
)]
use indoc::indoc;
use regex::Regex;
use toml_edit::{DocumentMut, Item, Table};
use super::{super::split_type_and_doc, AskForTicket, common};
const OLD_TYPES_DOC: &str = indoc! {"
# The available types of commits.
#
# This is a list of types (1 word) and their description, separated by one or
# more spaces.
"};
const OLD_SCOPES_DOC: &str = indoc! {"
# The list of valid scopes.
"};
pub const OLD_TICKET_PREFIXES_DOC: &str = indoc! {"
# The list of valid ticket prefixes.
"};
pub const OLD_TEMPLATES_COMMIT_DOC: &str = indoc! {"
# The commit message template, written with the Tera [1] templating engine.
# [1] https://tera.netlify.app/
"};
pub fn update(
toml_config: &mut DocumentMut,
switch_scopes_to_any: bool,
ask_for_ticket: AskForTicket,
empty_prefix_to_hash: bool,
) {
common::update_version(toml_config);
update_types(toml_config);
update_scopes(toml_config, switch_scopes_to_any);
match ask_for_ticket {
AskForTicket::Ask { require } => {
update_ticket(toml_config, require, empty_prefix_to_hash);
}
AskForTicket::DontAsk => remove_ticket(toml_config),
}
update_templates(toml_config, empty_prefix_to_hash);
}
fn update_types(toml_config: &mut DocumentMut) {
let (key, value) =
toml_config.get_key_value("types").expect("No `types` key");
let doc = key
.leaf_decor()
.prefix()
.expect("No prefix decorator for key `types`")
.as_str()
.expect("Improper string in the prefix decorator of the `types` key");
let mut types: Table = value
.as_array()
.expect("The `types` key is not an array")
.iter()
.map(|ty| {
ty.as_str()
.expect("Values of the `types` array are not strings")
})
.map(split_type_and_doc)
.collect();
types
.decor_mut()
.set_prefix(doc.replace(OLD_TYPES_DOC, common::TYPES_DOC));
toml_config.insert("types", Item::Table(types));
}
fn update_scopes(toml_config: &mut DocumentMut, switch_scopes_to_any: bool) {
let (key, value) = toml_config
.get_key_value("scopes")
.expect("No `scopes` key");
let doc = key
.leaf_decor()
.prefix()
.expect("No prefix decorator for key `scopes`")
.as_str()
.expect("Improper string in the prefix decorator of the `scopes` key");
let mut scopes = Table::new();
if switch_scopes_to_any {
scopes.insert("accept", Item::Value("any".into()));
} else {
scopes.insert("accept", Item::Value("list".into()));
scopes.insert("list", value.clone());
}
scopes
.decor_mut()
.set_prefix(doc.replace(OLD_SCOPES_DOC, common::SCOPES_DOC));
scopes
.key_mut("accept")
.expect("No `scopes.accept` key")
.leaf_decor_mut()
.set_prefix(common::SCOPES_ACCEPT_DOC);
toml_config.insert("scopes", Item::Table(scopes));
}
fn update_ticket(
toml_config: &mut DocumentMut,
required: bool,
empty_prefix_to_hash: bool,
) {
let (key, value) = toml_config
.get_key_value("ticket_prefixes")
.expect("No `ticket_prefixes` key");
let doc = key
.leaf_decor()
.prefix()
.expect("No prefix decorator for key `ticket_prefixes`")
.as_str()
.expect("Improper string in the prefix decorator of the `ticket_prefixes` key");
let mut prefixes = value.clone();
if empty_prefix_to_hash {
replace_empty_prefix_with_hash(&mut prefixes);
}
let mut ticket = Table::new();
ticket.insert("required", Item::Value(required.into()));
ticket.insert("prefixes", prefixes);
ticket.decor_mut().set_prefix(common::TICKET_DOC);
ticket
.key_mut("required")
.expect("No `ticket.required` key")
.leaf_decor_mut()
.set_prefix(common::TICKET_REQUIRED_DOC);
ticket
.key_mut("prefixes")
.expect("No `ticket.prefixes` key")
.leaf_decor_mut()
.set_prefix(
doc.trim_start()
.replace(OLD_TICKET_PREFIXES_DOC, common::TICKET_PREFIXES_DOC),
);
toml_config.remove("ticket_prefixes");
toml_config.insert("ticket", Item::Table(ticket));
}
fn replace_empty_prefix_with_hash(prefixes: &mut Item) {
let empty_prefix = prefixes
.as_array_mut()
.expect("The `ticket.prefixes` key is not an array")
.iter_mut()
.find(|item| {
item.as_str()
.expect("Items in `ticket.prefixes are not strings")
.is_empty()
});
if let Some(value) = empty_prefix {
*value = "#".into();
}
}
fn remove_ticket(toml_config: &mut DocumentMut) {
toml_config.remove("ticket_prefixes");
}
fn update_templates(toml_config: &mut DocumentMut, remove_hash_prefix: bool) {
let (key, value) = toml_config
.get_key_value("template")
.expect("No `template` key");
let doc = key
.leaf_decor()
.prefix()
.expect("No prefix decorator for key `template`")
.as_str()
.expect(
"Improper string in the prefix decorator of the `template` key",
);
let template = value.as_str().expect("The `template` key is not a string");
let template = add_ticket_condition_to_commit_template(template);
let template = if remove_hash_prefix {
remove_hash_ticket_prefix_from_commit_template(&template)
} else {
template
};
let mut templates = Table::new();
templates.insert("commit", Item::Value(template.into()));
templates.decor_mut().set_prefix(common::TEMPLATES_DOC);
templates
.key_mut("commit")
.expect("No `commit` key")
.leaf_decor_mut()
.set_prefix(
doc.trim_start().replace(
OLD_TEMPLATES_COMMIT_DOC,
common::TEMPLATES_COMMIT_DOC,
),
);
toml_config.remove("template");
toml_config.insert("templates", Item::Table(templates));
}
fn add_ticket_condition_to_commit_template(template: &str) -> String {
#[expect(clippy::unwrap_used, reason = "This regex is known to be valid.")]
let re = Regex::new(r"(.*\{\{ ticket \}\}.*)").unwrap();
re.replace(template, "{% if ticket %}$1{% endif %}")
.to_string()
}
fn remove_hash_ticket_prefix_from_commit_template(template: &str) -> String {
template.replace("#{{ ticket }}", "{{ ticket }}")
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::restriction)]
use super::*;
const V0_1_STANDARD: &str =
include_str!("../../../tests/res/config/v0_1_standard.toml");
const V0_1_USER_COMMENTS: &str =
include_str!("../../../tests/res/config/v0_1_user-comments.toml");
const V0_1_DOC_AND_USER_COMMENTS: &str = include_str!(
"../../../tests/res/config/v0_1_doc-and-user-comments.toml"
);
const V0_2_STANDARD: &str =
include_str!("../../../tests/res/config/v0_2_standard.toml");
const V0_2_SCOPES_ANY: &str =
include_str!("../../../tests/res/config/v0_2_scopes-any.toml");
const V0_2_TICKET_NOT_REQUIRED: &str =
include_str!("../../../tests/res/config/v0_2_ticket-not-required.toml");
const V0_2_TICKET_NOT_ASKED_FOR: &str = include_str!(
"../../../tests/res/config/v0_2_ticket-not-asked-for.toml"
);
const V0_2_KEEP_EMPTY_PREFIX: &str =
include_str!("../../../tests/res/config/v0_2_keep-empty-prefix.toml");
const V0_2_USER_COMMENTS: &str =
include_str!("../../../tests/res/config/v0_2_user-comments.toml");
const V0_2_DOC_AND_USER_COMMENTS: &str = include_str!(
"../../../tests/res/config/v0_2_doc-and-user-comments.toml"
);
#[test]
fn update_works_with_standard_config() {
let source = V0_1_STANDARD;
let expected = V0_2_STANDARD;
let mut document = source.parse().unwrap();
update(
&mut document,
false,
AskForTicket::Ask { require: true },
true,
);
let actual = document.to_string();
assert_eq!(actual, expected);
}
#[test]
fn update_can_switch_scopes_to_any() {
let source = V0_1_STANDARD;
let expected = V0_2_SCOPES_ANY;
let mut document = source.parse().unwrap();
update(
&mut document,
true,
AskForTicket::Ask { require: true },
true,
);
let actual = document.to_string();
assert_eq!(actual, expected);
}
#[test]
fn update_can_ask_for_ticket_without_requiring_it() {
let source = V0_1_STANDARD;
let expected = V0_2_TICKET_NOT_REQUIRED;
let mut document = source.parse().unwrap();
update(
&mut document,
false,
AskForTicket::Ask { require: false },
true,
);
let actual = document.to_string();
assert_eq!(actual, expected);
}
#[test]
fn update_can_omit_to_ask_for_a_ticket() {
let source = V0_1_STANDARD;
let expected = V0_2_TICKET_NOT_ASKED_FOR;
let mut document = source.parse().unwrap();
update(&mut document, false, AskForTicket::DontAsk, true);
let actual = document.to_string();
assert_eq!(actual, expected);
}
#[test]
fn update_can_skip_updating_an_empty_ticket_prefix_to_hash() {
let source = V0_1_STANDARD;
let expected = V0_2_KEEP_EMPTY_PREFIX;
let mut document = source.parse().unwrap();
update(
&mut document,
false,
AskForTicket::Ask { require: true },
false,
);
let actual = document.to_string();
assert_eq!(actual, expected);
}
#[test]
fn update_preserves_user_comments() {
let source = V0_1_USER_COMMENTS;
let expected = V0_2_USER_COMMENTS;
let mut document = source.parse().unwrap();
update(
&mut document,
false,
AskForTicket::Ask { require: true },
true,
);
let actual = document.to_string();
assert_eq!(actual, expected);
}
#[test]
fn update_updates_default_doc_when_mixed_with_user_comments() {
let source = V0_1_DOC_AND_USER_COMMENTS;
let expected = V0_2_DOC_AND_USER_COMMENTS;
let mut document = source.parse().unwrap();
update(
&mut document,
false,
AskForTicket::Ask { require: true },
true,
);
let actual = document.to_string();
assert_eq!(actual, expected);
}
#[test]
fn add_ticket_condition_makes_reference_conditional_on_refs_footer() {
let source = indoc! {"
{{ type }}{% if scope %}({{ scope }}){% endif %}{% if breaking_change %}!{% endif %}: {{ description }}
# Feel free to enter a longer description here.
Refs: {{ ticket }}
{% if breaking_change %}BREAKING CHANGE: {{ breaking_change }}{% endif %}
"};
let expected = indoc! {"
{{ type }}{% if scope %}({{ scope }}){% endif %}{% if breaking_change %}!{% endif %}: {{ description }}
# Feel free to enter a longer description here.
{% if ticket %}Refs: {{ ticket }}{% endif %}
{% if breaking_change %}BREAKING CHANGE: {{ breaking_change }}{% endif %}
"};
let actual = add_ticket_condition_to_commit_template(source);
assert_eq!(actual, expected);
}
}