Skip to main content

amont_runtime/
vocabulary.rs

1//! The project's vocabulary: commit types and branch prefixes, in one place.
2//!
3//! These were two hand-maintained lists in two hooks, and they drifted badly —
4//! 8 of 12 commit types were REJECTED as branch prefixes, so `docs/…` and
5//! `refactor/…` could not be pushed even though `docs:` and `refactor:` were
6//! valid commit types. It cost two branch renames before anyone looked.
7//!
8//! Syncing them once would not hold: the next type added drifts again. So the
9//! lists live here, each derived view reads from them, and the reconciliation
10//! test below FAILS unless every name is either shared or explicitly declared
11//! as an exception WITH A REASON. Adding a type now forces the decision rather
12//! than deferring it to whoever next hits a rejected push.
13
14/// A conventional-commit type and the gitmoji `commit-msg` prepends.
15pub struct CommitType {
16    pub name: &'static str,
17    pub emoji: &'static str,
18}
19
20/// Order is user-visible: `commit-msg` lists these when it rejects a message.
21pub const COMMIT_TYPES: &[CommitType] = &[
22    CommitType {
23        name: "build",
24        emoji: "👷",
25    },
26    CommitType {
27        name: "chore",
28        emoji: "🔧",
29    },
30    CommitType {
31        name: "docs",
32        emoji: "📝️",
33    },
34    CommitType {
35        name: "feat",
36        emoji: "✨",
37    },
38    CommitType {
39        name: "fix",
40        emoji: "🐛",
41    },
42    CommitType {
43        name: "perf",
44        emoji: "⚡️",
45    },
46    CommitType {
47        name: "refactor",
48        emoji: "♻️",
49    },
50    CommitType {
51        name: "revert",
52        emoji: "⏪️",
53    },
54    CommitType {
55        name: "style",
56        emoji: "🎨",
57    },
58    CommitType {
59        name: "test",
60        emoji: "🚨",
61    },
62    CommitType {
63        name: "add",
64        emoji: "➕",
65    },
66    CommitType {
67        name: "remove",
68        emoji: "➖",
69    },
70];
71
72/// A branch prefix, and whether dots are allowed after it.
73pub struct BranchPrefix {
74    pub name: &'static str,
75    /// Dots suit version-bump branches (`chore/duro-1.50.50`) and would only be
76    /// noise elsewhere. Git already rejects the dangerous forms (`..`, trailing
77    /// `.lock`).
78    pub dots: bool,
79}
80
81pub const BRANCH_PREFIXES: &[BranchPrefix] = &[
82    BranchPrefix {
83        name: "add",
84        dots: false,
85    },
86    BranchPrefix {
87        name: "automation",
88        dots: false,
89    },
90    BranchPrefix {
91        name: "build",
92        dots: false,
93    },
94    BranchPrefix {
95        name: "chore",
96        dots: true,
97    },
98    BranchPrefix {
99        name: "docs",
100        dots: false,
101    },
102    BranchPrefix {
103        name: "feat",
104        dots: false,
105    },
106    BranchPrefix {
107        name: "fix",
108        dots: false,
109    },
110    BranchPrefix {
111        name: "hotfix",
112        dots: false,
113    },
114    BranchPrefix {
115        name: "perf",
116        dots: false,
117    },
118    BranchPrefix {
119        name: "refactor",
120        dots: false,
121    },
122    BranchPrefix {
123        name: "remove",
124        dots: false,
125    },
126    BranchPrefix {
127        name: "revert",
128        dots: false,
129    },
130    BranchPrefix {
131        name: "style",
132        dots: false,
133    },
134    BranchPrefix {
135        name: "test",
136        dots: false,
137    },
138];
139
140/// Consumed only by the reconciliation test — that IS their job: they are the
141/// record of a decision, and the test is what forces one to exist.
142#[allow(dead_code)]
143/// Commit types deliberately NOT usable as a branch prefix, and why.
144/// Empty today — every type is a legitimate thing to open a branch for.
145pub const COMMIT_ONLY: &[(&str, &str)] = &[];
146
147#[allow(dead_code)]
148/// Branch prefixes deliberately not commit types, and why.
149pub const BRANCH_ONLY: &[(&str, &str)] = &[
150    (
151        "hotfix",
152        "an urgency, not a kind of change — the commits inside are still fix:",
153    ),
154    (
155        "automation",
156        "bot-authored branches; their commits carry their own types",
157    ),
158];
159
160pub fn branch_prefix(name: &str) -> Option<&'static BranchPrefix> {
161    BRANCH_PREFIXES.iter().find(|p| p.name == name)
162}
163
164pub fn emoji_for(commit_type: &str) -> &'static str {
165    COMMIT_TYPES
166        .iter()
167        .find(|t| t.name == commit_type)
168        .map(|t| t.emoji)
169        .unwrap_or("")
170}
171
172/// The type an emoji stands for — [`emoji_for`] read backwards.
173///
174/// This is what makes the `replace` gitmoji placement survive an amend: the
175/// stored subject `✨  add a cart` carries its type only in the emoji, so
176/// re-validating it means recovering `feat` from `✨`. Well defined because
177/// `each_type_has_its_own_emoji` holds the emojis distinct.
178pub fn type_for_emoji(emoji: &str) -> Option<&'static str> {
179    COMMIT_TYPES
180        .iter()
181        .find(|t| t.emoji == emoji)
182        .map(|t| t.name)
183}
184
185/// The branch contract, rendered for the rejection message so what a user is
186/// told always matches what is enforced.
187pub fn branch_contract() -> String {
188    let plain: Vec<&str> = BRANCH_PREFIXES
189        .iter()
190        .filter(|p| !p.dots)
191        .map(|p| p.name)
192        .collect();
193    let dotted: Vec<&str> = BRANCH_PREFIXES
194        .iter()
195        .filter(|p| p.dots)
196        .map(|p| p.name)
197        .collect();
198    format!(
199        "^(({})/[[:alnum:]_-]+|({})/[[:alnum:]_.-]+)$",
200        plain.join("|"),
201        dotted.join("|")
202    )
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use std::collections::BTreeSet;
209
210    fn names<T>(v: &[T], f: impl Fn(&T) -> &'static str) -> BTreeSet<&'static str> {
211        v.iter().map(f).collect()
212    }
213
214    /// The point of this module. Every name must be shared, or declared as an
215    /// exception with a reason — so adding a commit type forces a decision
216    /// about whether it is also a branch prefix, instead of silently producing
217    /// a rejected push months later.
218    #[test]
219    fn the_two_vocabularies_are_reconciled() {
220        let types = names(COMMIT_TYPES, |t| t.name);
221        let prefixes = names(BRANCH_PREFIXES, |p| p.name);
222        let commit_only: BTreeSet<&str> = COMMIT_ONLY.iter().map(|(n, _)| *n).collect();
223        let branch_only: BTreeSet<&str> = BRANCH_ONLY.iter().map(|(n, _)| *n).collect();
224
225        let unexplained: Vec<_> = types
226            .difference(&prefixes)
227            .filter(|n| !commit_only.contains(*n))
228            .collect();
229        assert!(
230            unexplained.is_empty(),
231            "commit types that are not branch prefixes and not listed in COMMIT_ONLY: {unexplained:?}"
232        );
233
234        let unexplained: Vec<_> = prefixes
235            .difference(&types)
236            .filter(|n| !branch_only.contains(*n))
237            .collect();
238        assert!(
239            unexplained.is_empty(),
240            "branch prefixes that are not commit types and not listed in BRANCH_ONLY: {unexplained:?}"
241        );
242
243        // An exception must be a real exception, not a stale note.
244        for n in &commit_only {
245            assert!(
246                !prefixes.contains(n),
247                "{n} is listed COMMIT_ONLY but IS a branch prefix"
248            );
249        }
250        for n in &branch_only {
251            assert!(
252                !types.contains(n),
253                "{n} is listed BRANCH_ONLY but IS a commit type"
254            );
255        }
256    }
257
258    #[test]
259    fn no_duplicates() {
260        assert_eq!(names(COMMIT_TYPES, |t| t.name).len(), COMMIT_TYPES.len());
261        assert_eq!(
262            names(BRANCH_PREFIXES, |p| p.name).len(),
263            BRANCH_PREFIXES.len()
264        );
265    }
266
267    /// Two types sharing an emoji would make [`type_for_emoji`] a coin toss,
268    /// and the `replace` gitmoji placement stores the emoji INSTEAD of the type
269    /// — so a duplicate would silently rewrite one type into another on the
270    /// next amend.
271    #[test]
272    fn each_type_has_its_own_emoji() {
273        let emojis = names(COMMIT_TYPES, |t| t.emoji);
274        assert_eq!(
275            emojis.len(),
276            COMMIT_TYPES.len(),
277            "two commit types share an emoji: {emojis:?}"
278        );
279    }
280
281    /// Every type recovers from its own emoji, and nothing else does.
282    #[test]
283    fn an_emoji_names_the_type_it_was_written_for() {
284        for t in COMMIT_TYPES {
285            assert_eq!(type_for_emoji(t.emoji), Some(t.name), "{}", t.name);
286            assert_eq!(emoji_for(t.name), t.emoji);
287        }
288        assert_eq!(type_for_emoji("🚀"), None);
289        assert_eq!(type_for_emoji(""), None);
290    }
291
292    /// Dots are a chore-only affordance for version bumps.
293    #[test]
294    fn only_chore_allows_dots() {
295        let dotted: Vec<&str> = BRANCH_PREFIXES
296            .iter()
297            .filter(|p| p.dots)
298            .map(|p| p.name)
299            .collect();
300        assert_eq!(dotted, vec!["chore"]);
301    }
302
303    /// The message a user is shown must describe what is actually enforced.
304    #[test]
305    fn the_contract_string_lists_every_prefix() {
306        let c = branch_contract();
307        for p in BRANCH_PREFIXES {
308            assert!(c.contains(p.name), "{} missing from the contract", p.name);
309        }
310    }
311}