Skip to main content

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}