use crate::config::{self, Scope};
pub const KEY_GITMOJI: &str = "amont.commit.gitmoji";
pub const KEY_SUBJECT_MAX: &str = "amont.commit.subjectMax";
pub const KEY_DESCRIPTION_MAX: &str = "amont.commit.descriptionMax";
pub const KEY_BODY_WRAP: &str = "amont.commit.bodyWrap";
const PREFIX: &str = "amont.commit.";
pub const DEFAULT_GITMOJI: Gitmoji = Gitmoji::None;
pub const DEFAULT_SUBJECT_MAX: usize = 72;
pub const DEFAULT_DESCRIPTION_MAX: usize = 50;
pub const DEFAULT_BODY_WRAP: usize = 72;
const LIMIT_RANGE: std::ops::RangeInclusive<i64> = 1..=1000;
const WRAP_RANGE: std::ops::RangeInclusive<i64> = 0..=1000;
const SHORTEST_PREFIX: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Gitmoji {
None,
Prefix,
Suffix,
Replace,
}
impl Gitmoji {
pub const ALL: [Gitmoji; 4] = [
Gitmoji::None,
Gitmoji::Prefix,
Gitmoji::Suffix,
Gitmoji::Replace,
];
pub fn as_str(self) -> &'static str {
match self {
Gitmoji::None => "none",
Gitmoji::Prefix => "prefix",
Gitmoji::Suffix => "suffix",
Gitmoji::Replace => "replace",
}
}
pub fn parse(s: &str) -> Option<Gitmoji> {
Gitmoji::ALL.into_iter().find(|g| g.as_str() == s)
}
pub fn explain(self) -> &'static str {
match self {
Gitmoji::None => "leave the subject as written",
Gitmoji::Prefix => "before the type",
Gitmoji::Suffix => "after the description — tooling still reads the type",
Gitmoji::Replace => "instead of the type — conventional-commit tools stop reading it",
}
}
pub fn example(self) -> String {
render_subject(self, "feat", "", "", "add a cart")
}
}
pub fn render_subject(
placement: Gitmoji,
prefix: &str,
scope: &str,
breaking: &str,
description: &str,
) -> String {
let emoji = crate::vocabulary::emoji_for(prefix);
let conventional = format!("{prefix}{scope}{breaking}: {description}");
match placement {
Gitmoji::None => conventional,
Gitmoji::Prefix => format!("{emoji} {conventional}"),
Gitmoji::Suffix => format!("{conventional} {emoji}"),
Gitmoji::Replace => {
let rest = format!("{scope}{breaking}");
if rest.is_empty() {
format!("{emoji} {description}")
} else {
format!("{emoji} {rest}: {description}")
}
}
}
}
const GITMOJI_WORDS: [&str; 4] = ["none", "prefix", "suffix", "replace"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Style {
pub gitmoji: Gitmoji,
pub subject_max: usize,
pub description_max: usize,
pub body_wrap: usize,
}
impl Default for Style {
fn default() -> Self {
Style {
gitmoji: DEFAULT_GITMOJI,
subject_max: DEFAULT_SUBJECT_MAX,
description_max: DEFAULT_DESCRIPTION_MAX,
body_wrap: DEFAULT_BODY_WRAP,
}
}
}
impl Style {
pub fn resolve() -> Style {
let names = config::present(PREFIX);
if names.is_empty() {
return Style::default();
}
let d = Style::default();
Style {
gitmoji: if config::is_present(&names, KEY_GITMOJI) {
Gitmoji::parse(config::enumerated_or(
KEY_GITMOJI,
&GITMOJI_WORDS,
d.gitmoji.as_str(),
))
.unwrap_or(d.gitmoji)
} else {
d.gitmoji
},
subject_max: read_limit(&names, KEY_SUBJECT_MAX, d.subject_max, LIMIT_RANGE),
description_max: read_limit(
&names,
KEY_DESCRIPTION_MAX,
d.description_max,
LIMIT_RANGE,
),
body_wrap: read_limit(&names, KEY_BODY_WRAP, d.body_wrap, WRAP_RANGE),
}
}
pub fn warnings(&self) -> Vec<String> {
let mut out = Vec::new();
if self.description_max + SHORTEST_PREFIX > self.subject_max {
out.push(format!(
"{KEY_DESCRIPTION_MAX} ({}) can never bind — the subject limit is {} and the \
shortest prefix is {SHORTEST_PREFIX} characters",
self.description_max, self.subject_max
));
}
out
}
}
fn read_limit(
names: &std::collections::BTreeSet<String>,
key: &str,
default: usize,
range: std::ops::RangeInclusive<i64>,
) -> usize {
if !config::is_present(names, key) {
return default;
}
config::integer_or(key, default as i64, range).max(0) as usize
}
pub struct Setting {
pub key: &'static str,
pub label: &'static str,
pub value: String,
pub default: String,
pub overridden: bool,
pub set_here: bool,
pub scope: Scope,
}
pub fn describe() -> (Style, Vec<Setting>) {
let style = Style::resolve();
let d = Style::default();
let rows = vec![
row(
KEY_GITMOJI,
"gitmoji",
style.gitmoji.as_str().to_string(),
d.gitmoji.as_str().to_string(),
),
row(
KEY_SUBJECT_MAX,
"subject max",
style.subject_max.to_string(),
d.subject_max.to_string(),
),
row(
KEY_DESCRIPTION_MAX,
"description max",
style.description_max.to_string(),
d.description_max.to_string(),
),
row(
KEY_BODY_WRAP,
"body wrap",
wrap_word(style.body_wrap),
wrap_word(d.body_wrap),
),
];
(style, rows)
}
fn wrap_word(n: usize) -> String {
if n == 0 {
"off".to_string()
} else {
n.to_string()
}
}
fn row(key: &'static str, label: &'static str, value: String, default: String) -> Setting {
let scope = config::scope_of(key);
Setting {
key,
label,
overridden: value != default,
set_here: scope != Scope::Default,
value,
default,
scope,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shipped_defaults_are_what_the_docs_promise() {
let d = Style::default();
assert_eq!(d.gitmoji, Gitmoji::None);
assert_eq!(d.subject_max, 72);
assert_eq!(d.description_max, 50);
assert_eq!(d.body_wrap, 72);
}
#[test]
fn every_placement_parses_from_its_own_name() {
for g in Gitmoji::ALL {
assert_eq!(Gitmoji::parse(g.as_str()), Some(g), "{}", g.as_str());
assert!(!g.explain().is_empty());
}
assert_eq!(Gitmoji::parse("sideways"), None);
assert_eq!(GITMOJI_WORDS.len(), Gitmoji::ALL.len());
}
#[test]
fn the_accepted_words_are_exactly_the_placements() {
for word in GITMOJI_WORDS {
assert!(Gitmoji::parse(word).is_some(), "{word} has no variant");
}
}
#[test]
fn the_defaults_are_coherent() {
assert!(Style::default().warnings().is_empty());
}
#[test]
fn a_description_budget_that_can_never_bind_is_reported() {
let s = Style {
description_max: 50,
subject_max: 52,
..Style::default()
};
let w = s.warnings();
assert_eq!(w.len(), 1, "{w:?}");
assert!(w[0].contains(KEY_DESCRIPTION_MAX), "{w:?}");
let ok = Style {
description_max: 50,
subject_max: 55,
..Style::default()
};
assert!(ok.warnings().is_empty(), "{:?}", ok.warnings());
}
#[test]
fn a_zero_wrap_column_reads_as_off() {
assert_eq!(wrap_word(0), "off");
assert_eq!(wrap_word(72), "72");
}
}