gwm/gitmoji.rs
1//! Gitmoji mapping (issue #85).
2//!
3//! This repo standardises commits as `<emoji> <type>(#<issue>): <subject>`
4//! (Gitmoji + Conventional Commits โ see CONTRIBUTING.md). The mapping
5//! `branch_type โ emoji shortcode` is universal across the project, so
6//! we bake a default table into the binary and let `.gwm.toml` override
7//! individual entries via a `[gitmoji]` block:
8//!
9//! ```toml
10//! [gitmoji]
11//! feat = ":rocket:" # team uses ๐ for new features instead of โจ
12//! ```
13//!
14//! Three surfaces consume this module:
15//! 1. `gwm commit-prefix` โ prints `:sparkles: feat(#41):` for the current
16//! or named branch (with `--unicode` to emit โจ instead).
17//! 2. `gwm types --gitmoji` โ extends the branch-type list with the
18//! unicode + shortcode columns.
19//! 3. `gwm hooks install commit-msg` โ installs a `.git/hooks/commit-msg`
20//! that shells out to `gwm commit-prefix --unicode` and auto-prepends
21//! the prefix when missing.
22//!
23//! The shortcode โ unicode table is intentionally kept small (the ten
24//! built-in branch types + `:question:` as the unknown-type fallback)
25//! to avoid pulling in a heavy `gh-emoji`-style dependency for a
26//! handful of entries.
27
28use crate::config::CONFIG_FILE;
29use crate::error::Result;
30use crate::naming::BranchSpec;
31use serde::Deserialize;
32use std::collections::BTreeMap;
33use std::path::Path;
34
35/// Built-in `branch_type โ shortcode` table. Lifted to a `&[(&str,
36/// &str)]` const so the static table stays compile-time and zero-alloc
37/// at the storage level; the runtime view materialises on demand via
38/// [`default_map`]. The list mirrors the ten built-in branch types
39/// declared in `naming::BRANCH_TYPES`.
40pub const DEFAULT_GITMOJI: &[(&str, &str)] = &[
41 ("feat", ":sparkles:"),
42 ("fix", ":bug:"),
43 ("hotfix", ":ambulance:"),
44 ("docs", ":memo:"),
45 ("test", ":white_check_mark:"),
46 ("refactor", ":recycle:"),
47 ("chore", ":wrench:"),
48 ("perf", ":zap:"),
49 ("ci", ":construction_worker:"),
50 ("build", ":package:"),
51];
52
53/// Fallback shortcode used by [`resolve_prefix`] when neither the user's
54/// `[gitmoji]` block nor the built-in defaults claim a given branch
55/// type. Picked so the surface stays syntactically valid (a prefix is
56/// always emitted) while flagging "this type has no emoji yet" visually.
57const UNKNOWN_SHORTCODE: &str = ":question:";
58
59/// Resolved `branch_type โ shortcode` table. The `BTreeMap` choice is
60/// load-bearing: it gives deterministic iteration order (alphabetical
61/// by branch type), which `gwm types --gitmoji` relies on so a CI diff
62/// against the previous run is byte-stable.
63#[derive(Debug, Clone, Default)]
64pub struct GitmojiMap {
65 entries: BTreeMap<String, String>,
66}
67
68impl GitmojiMap {
69 /// Look up the shortcode for a branch type. Returns `None` for types
70 /// not in the table โ callers (notably [`resolve_prefix`]) decide
71 /// the fallback policy.
72 pub fn get(&self, branch_type: &str) -> Option<&str> {
73 self.entries.get(branch_type).map(String::as_str)
74 }
75
76 /// Iterate over `(branch_type, shortcode)` pairs in deterministic
77 /// (alphabetical) order. Used by `gwm types --gitmoji` and the
78 /// "every default has a unicode" test sweep.
79 pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
80 self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str()))
81 }
82
83 /// Merge another `(branch_type, shortcode)` pair into the table.
84 /// Overrides the existing entry if any. Public so external callers
85 /// (e.g. tests, future programmatic surfaces) can build a custom
86 /// map without round-tripping through TOML.
87 pub fn insert(&mut self, branch_type: impl Into<String>, shortcode: impl Into<String>) {
88 self.entries.insert(branch_type.into(), shortcode.into());
89 }
90}
91
92/// Materialise the built-in table as a [`GitmojiMap`]. The runtime cost
93/// is one allocation per built-in entry โ measured at ~1ยตs total in
94/// release builds, dominated by the `BTreeMap` insertions, so we don't
95/// cache.
96pub fn default_map() -> GitmojiMap {
97 let mut map = GitmojiMap::default();
98 for (ty, shortcode) in DEFAULT_GITMOJI {
99 map.insert(*ty, *shortcode);
100 }
101 map
102}
103
104/// Load `.gwm.toml`'s `[gitmoji]` block from the given repo root and
105/// merge it on top of the built-in defaults. Returns the built-in
106/// defaults verbatim when the file is missing or the block is absent.
107///
108/// `repo_root` is `Option` so callers without a workdir handle (e.g.
109/// the `gwm commit-prefix --branch <name>` path, which doesn't need
110/// to open a repo) can still get the defaults.
111pub fn load(repo_root: Option<&Path>) -> Result<GitmojiMap> {
112 let mut map = default_map();
113 let Some(root) = repo_root else {
114 return Ok(map);
115 };
116 let path = root.join(CONFIG_FILE);
117 if !path.exists() {
118 return Ok(map);
119 }
120 let raw = std::fs::read_to_string(&path)?;
121 // Deserialise only the `[gitmoji]` block โ we don't want to fail
122 // here on unrelated config errors (a malformed `[[bootstrap.copy]]`
123 // shouldn't break `gwm commit-prefix`). The dedicated struct keeps
124 // the parse local to this module's contract.
125 let parsed: GitmojiOnlyConfig = toml::from_str(&raw)?;
126 for (ty, shortcode) in parsed.gitmoji {
127 map.insert(ty, shortcode);
128 }
129 Ok(map)
130}
131
132/// Render the canonical commit prefix for a branch: `<emoji>
133/// <type>(#<issue>):`. Use `unicode = true` to substitute the
134/// shortcode for its real emoji character (e.g. `:sparkles:` โ โจ).
135///
136/// When `branch.type_` has no entry in the map, falls back to
137/// `:question:` / โ rather than panicking โ the surface must be
138/// usable on any branch, including non-gwm-style ones that bypass
139/// `BranchSpec::validate`.
140pub fn resolve_prefix(map: &GitmojiMap, branch: &BranchSpec, unicode: bool) -> String {
141 let shortcode = map.get(&branch.type_).unwrap_or(UNKNOWN_SHORTCODE);
142 let emoji = if unicode {
143 shortcode_to_unicode(shortcode)
144 } else {
145 shortcode
146 };
147 format!("{} {}(#{}):", emoji, branch.type_, branch.issue)
148}
149
150/// Map a `:shortcode:` to its unicode character. Covers the ten
151/// built-in defaults plus a curated set of the most commonly-used
152/// Gitmoji shortcodes (the ones a team `[gitmoji]` override is
153/// statistically likely to swap to โ `:rocket:`, `:fire:`, `:lock:`,
154/// `:art:`, `:lipstick:`, โฆ). Anything outside the table round-trips
155/// verbatim: rendering an arbitrary user string under `--unicode`
156/// would require the full 3000-entry Gitmoji set (a heavy dep) and
157/// shortcodes remain valid commit-message decoration anyway. The
158/// `:question:` fallback covers the unknown-branch-type path inside
159/// [`resolve_prefix`].
160pub fn shortcode_to_unicode(shortcode: &str) -> &str {
161 match shortcode {
162 // Built-in default mappings (mirror DEFAULT_GITMOJI).
163 ":sparkles:" => "โจ",
164 ":bug:" => "๐",
165 ":ambulance:" => "๐",
166 ":memo:" => "๐",
167 ":white_check_mark:" => "โ
",
168 ":recycle:" => "โป",
169 ":wrench:" => "๐ง",
170 ":zap:" => "โก",
171 ":construction_worker:" => "๐ท",
172 ":package:" => "๐ฆ",
173 ":question:" => "โ",
174 // Curated extension โ common Gitmoji shortcodes teams swap in
175 // via `[gitmoji]` overrides. The list is intentionally not the
176 // full Gitmoji set (~3000 entries); it covers the ones witnessed
177 // in OSS `.gwm.toml`/`.commitlintrc` configs.
178 ":rocket:" => "๐",
179 ":tada:" => "๐",
180 ":boom:" => "๐ฅ",
181 ":fire:" => "๐ฅ",
182 ":lock:" => "๐",
183 ":closed_lock_with_key:" => "๐",
184 ":key:" => "๐",
185 ":art:" => "๐จ",
186 ":lipstick:" => "๐",
187 ":hammer:" => "๐จ",
188 ":wastebasket:" => "๐",
189 ":truck:" => "๐",
190 ":bookmark:" => "๐",
191 ":pencil2:" => "โ",
192 ":pushpin:" => "๐",
193 ":green_heart:" => "๐",
194 ":rotating_light:" => "๐จ",
195 ":construction:" => "๐ง",
196 ":heavy_plus_sign:" => "โ",
197 ":heavy_minus_sign:" => "โ",
198 ":arrow_up:" => "โฌ",
199 ":arrow_down:" => "โฌ",
200 ":lock_with_ink_pen:" => "๐",
201 ":mag:" => "๐",
202 ":bulb:" => "๐ก",
203 ":poop:" => "๐ฉ",
204 ":rewind:" => "โช",
205 ":twisted_rightwards_arrows:" => "๐",
206 ":alien:" => "๐ฝ",
207 ":seedling:" => "๐ฑ",
208 ":triangular_flag_on_post:" => "๐ฉ",
209 ":bento:" => "๐ฑ",
210 ":busts_in_silhouette:" => "๐ฅ",
211 ":children_crossing:" => "๐ธ",
212 ":building_construction:" => "๐",
213 ":iphone:" => "๐ฑ",
214 ":clown_face:" => "๐คก",
215 ":egg:" => "๐ฅ",
216 ":see_no_evil:" => "๐",
217 ":camera_flash:" => "๐ธ",
218 ":coffin:" => "โฐ",
219 ":test_tube:" => "๐งช",
220 ":necktie:" => "๐",
221 ":stethoscope:" => "๐ฉบ",
222 ":bricks:" => "๐งฑ",
223 ":technologist:" => "๐งโ๐ป",
224 ":money_with_wings:" => "๐ธ",
225 ":thread:" => "๐งต",
226 ":safety_vest:" => "๐ฆบ",
227 other => other,
228 }
229}
230
231/// Local deserialisation envelope: we only care about the `[gitmoji]`
232/// block here, and accepting unknown fields lets the rest of
233/// `.gwm.toml` (bootstrap, worktree, labels, โฆ) coexist without a
234/// schema dependency in this module.
235#[derive(Debug, Default, Deserialize)]
236struct GitmojiOnlyConfig {
237 #[serde(default)]
238 gitmoji: BTreeMap<String, String>,
239}