gwm/labels.rs
1//! GitHub label declarative management (issue #81).
2//!
3//! `[[labels]]` in `.gwm.toml` declares the desired label set; this
4//! module resolves declared entries into concrete `LabelSpec` values
5//! (filling in deterministic-pastel colours when omitted), computes
6//! the diff against the upstream remote, and exposes the structs that
7//! the CLI (`gwm labels list / push`) renders.
8//!
9//! The `gh`-backed I/O (fetch / create / delete) lives in `github.rs`;
10//! this module is intentionally I/O-free so unit tests don't need a
11//! network or a `gh` binary.
12
13use crate::config::LabelConfig;
14use crate::error::{GwmError, Result};
15use std::collections::{HashMap, HashSet};
16
17/// A fully-resolved label declared by the user: same shape as
18/// `LabelConfig` but with `color` materialised (deterministic pastel,
19/// user-declared, or `--random-colors`) and validated as 6-hex lower.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct LabelSpec {
22 pub name: String,
23 pub description: Option<String>,
24 pub color: String,
25}
26
27/// One label as returned by `gh label list --json …` for the upstream
28/// remote. Colour is normalised to lowercase 6-hex on parse so the
29/// diff doesn't surface a spurious "update" when GitHub renders it
30/// uppercase.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct RemoteLabel {
33 pub name: String,
34 pub description: Option<String>,
35 pub color: String,
36}
37
38/// Kind of mutation a `LabelUpdate` carries. The variant exists to
39/// leave room for future "delete" entries when `--prune` is wired in
40/// without reshuffling the `LabelDiff` shape.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum LabelAction {
43 Create,
44 Update,
45}
46
47/// One row in `LabelDiff::to_update`. Carries the previous remote
48/// colour / description (when they differed) so `gwm labels list` can
49/// render `~ good first issue (color #008672 → #7057ff)`.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct LabelUpdate {
52 pub action: LabelAction,
53 pub spec: LabelSpec,
54 pub previous_color: Option<String>,
55 pub previous_description: Option<String>,
56}
57
58/// Result of diffing the declared label set against the remote. Each
59/// bucket is rendered separately by `gwm labels list` and consumed by
60/// `gwm labels push`.
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct LabelDiff {
63 pub to_create: Vec<LabelSpec>,
64 pub to_update: Vec<LabelUpdate>,
65 pub matching: Vec<LabelSpec>,
66 pub extra_on_remote: Vec<RemoteLabel>,
67}
68
69impl LabelDiff {
70 /// `(create, update, match, extra_on_remote)` — the four numbers
71 /// surfaced in the one-line push summary.
72 pub fn counts(&self) -> (usize, usize, usize, usize) {
73 (
74 self.to_create.len(),
75 self.to_update.len(),
76 self.matching.len(),
77 self.extra_on_remote.len(),
78 )
79 }
80}
81
82// --- Diff summary helpers (issue #106) ----------------------------------
83//
84// `gwm labels push` and `gwm milestones push` both end on a
85// structurally identical one-line summary (`summary: N create · M
86// update · K match · X extra-on-remote`) and a dry-run preview line
87// (`would create N, update M, leave K untouched, prune P, ignore Q
88// extra-on-remote`). The helpers below give both call sites a single
89// formatter so the two surfaces stay in lock-step when the contract
90// evolves.
91
92/// Render the canonical `summary: N create · M update · K match · X
93/// extra-on-remote` line. Used by both `print_labels_diff` and
94/// `print_milestones_diff` in `cli.rs`.
95pub fn diff_summary_line(create: usize, update: usize, matching: usize, extra: usize) -> String {
96 format!(
97 "summary: {} create · {} update · {} match · {} extra-on-remote",
98 create, update, matching, extra
99 )
100}
101
102/// Render the canonical dry-run preview line for `labels push --dry-run`
103/// / `milestones push --dry-run`. `pruned` is `extra` when the caller
104/// passed `--prune`, otherwise `0` — keeping the arithmetic at the call
105/// site means the helper itself stays stateless.
106pub fn diff_dry_run_line(create: usize, update: usize, matching: usize, extra: usize, pruned: usize) -> String {
107 format!(
108 "would create {}, update {}, leave {} untouched, prune {}, ignore {} extra-on-remote",
109 create,
110 update,
111 matching,
112 pruned,
113 extra.saturating_sub(pruned),
114 )
115}
116
117// --- Colour helpers ------------------------------------------------------
118
119/// Shape check: `s` is 6 ASCII hex chars (either case, no leading
120/// `#`). Returns `Err` on length / non-hex inputs; the OK variant is
121/// the input verbatim — case is NOT enforced here. Call
122/// `normalize_color` if you also want the canonical lowercase form
123/// (`#D73A4A` → `d73a4a`).
124pub fn validate_color(s: &str) -> Result<&str> {
125 if s.len() != 6 {
126 return Err(GwmError::Config(format!(
127 "invalid color '{}': expected 6 hex chars, got {}",
128 s,
129 s.len()
130 )));
131 }
132 if !s.chars().all(|c| c.is_ascii_hexdigit()) {
133 return Err(GwmError::Config(format!("invalid color '{}': not a hex string", s)));
134 }
135 Ok(s)
136}
137
138/// Trim a leading `#`, lowercase the hex, then validate. Returns the
139/// canonical 6-hex form. Users naturally type `#D73A4A`; we accept it
140/// rather than reject the config and lecture them about the spec.
141pub fn normalize_color(s: &str) -> Result<String> {
142 let trimmed = s.trim().trim_start_matches('#');
143 let lower = trimmed.to_ascii_lowercase();
144 validate_color(&lower)?;
145 Ok(lower)
146}
147
148/// Deterministic pastel colour derived from the label name. FNV-1a
149/// 64-bit hash → take low 3 bytes as RGB → average with white to push
150/// the output into the pastel band (`#7f…` to `#ff…`). The choice of
151/// FNV-1a (rather than `DefaultHasher`) is portability: same colour
152/// across platforms, compilers, and Rust versions.
153pub fn deterministic_color(name: &str) -> String {
154 let h = fnv1a_64(name.as_bytes());
155 let bytes = h.to_le_bytes();
156 // Pastel transform: average each channel with 255 (white).
157 let r = ((bytes[0] as u16 + 255) / 2) as u8;
158 let g = ((bytes[1] as u16 + 255) / 2) as u8;
159 let b = ((bytes[2] as u16 + 255) / 2) as u8;
160 format!("{:02x}{:02x}{:02x}", r, g, b)
161}
162
163/// Pseudo-random 6-hex colour. Not cryptographic — used only when the
164/// user passes `--random-colors`. Source of entropy is monotonic
165/// nanoseconds XOR'd with a per-call counter so back-to-back calls
166/// inside the same nanosecond don't collide.
167pub fn random_color() -> String {
168 use std::sync::atomic::{AtomicU64, Ordering};
169 static COUNTER: AtomicU64 = AtomicU64::new(0);
170 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
171 let t = std::time::SystemTime::now()
172 .duration_since(std::time::UNIX_EPOCH)
173 .unwrap_or_default()
174 .as_nanos() as u64;
175 // Mix with the 64-bit golden ratio constant — same trick splitmix64
176 // uses to spread sequential inputs across the output space.
177 let mixed = (t ^ n).wrapping_mul(0x9E3779B97F4A7C15);
178 let bytes = mixed.to_le_bytes();
179 format!("{:02x}{:02x}{:02x}", bytes[0], bytes[1], bytes[2])
180}
181
182fn fnv1a_64(bytes: &[u8]) -> u64 {
183 let mut hash: u64 = 0xcbf29ce484222325;
184 for &b in bytes {
185 hash ^= b as u64;
186 hash = hash.wrapping_mul(0x100000001b3);
187 }
188 hash
189}
190
191// --- Resolve LabelConfig → LabelSpec -------------------------------------
192
193/// Reject label names that would either pass through to `gh label
194/// create` as a flag or violate GitHub's own naming rules (issue #100).
195///
196/// The argv path matters most here: `gh label create <name>` takes the
197/// name positionally, so a value like `"--repo"` or `"-h"` is parsed by
198/// gh's flag splitter before the create call ever materialises. With
199/// `-h` the create call silently no-ops (gh prints its help banner and
200/// exits 0); with `--repo other/repo` the call retargets to a
201/// different repository entirely. Validation at the source closes the
202/// vector without depending on whether gh's parser ever grows a `--`
203/// separator.
204///
205/// Empty strings, leading `-`, embedded `,` (GitHub uses `,` as the
206/// list separator in label query strings), and ASCII control characters
207/// (newline / tab / etc., which break the `gh label list` JSON round-trip)
208/// are rejected. Spaces and unicode are explicitly allowed — GitHub
209/// permits them and they are common in real-world label sets.
210///
211/// The returned error message is unscoped (no `"labels:"` prefix);
212/// callers compose their own context — `Config::validate_labels`
213/// prepends `labels[<i>]:`, `resolve_labels` prepends `labels:`,
214/// the prune path prepends `labels (remote):`. Keeping the scope
215/// at the call site avoids the double-prefix the Copilot review on
216/// PR #121 flagged ("config error: labels[0]: config error: labels:
217/// …").
218pub fn validate_label_name(name: &str) -> Result<()> {
219 if name.is_empty() {
220 return Err(GwmError::Config(
221 "entry has empty `name` — GitHub label names must be non-empty".into(),
222 ));
223 }
224 if name.starts_with('-') {
225 return Err(GwmError::Config(format!(
226 "name {:?} starts with '-' — would be parsed as a flag by `gh label create`; \
227 rename or remove the leading dash (issue #100)",
228 name
229 )));
230 }
231 if name.contains(',') {
232 return Err(GwmError::Config(format!(
233 "name {:?} contains ',' — GitHub uses comma as a label-list separator; rename without commas",
234 name
235 )));
236 }
237 if let Some(bad) = name.chars().find(|c| c.is_ascii_control()) {
238 return Err(GwmError::Config(format!(
239 "name {:?} contains ASCII control character {:?} — rename without control characters",
240 name, bad
241 )));
242 }
243 Ok(())
244}
245
246/// Materialise a list of declared `[[labels]]` entries into concrete
247/// `LabelSpec` values. Declared colours win; missing colours fall
248/// back to `deterministic_color(name)` unless `random` is set, in
249/// which case `random_color()` is used.
250///
251/// Validates each `name` through [`validate_label_name`] as the
252/// in-module defence-in-depth on top of `Config::validate_labels` at
253/// load time — keeps the contract enforced even when a `LabelConfig`
254/// is constructed in tests or via a future programmatic API that
255/// bypasses the loader.
256pub fn resolve_labels(declared: &[LabelConfig], random: bool) -> Result<Vec<LabelSpec>> {
257 declared
258 .iter()
259 .map(|l| {
260 validate_label_name(&l.name).map_err(|e| {
261 let inner = match e {
262 GwmError::Config(msg) => msg,
263 other => other.to_string(),
264 };
265 GwmError::Config(format!("labels: {}", inner))
266 })?;
267 let color = match &l.color {
268 Some(c) => {
269 normalize_color(c).map_err(|e| GwmError::Config(format!("label '{}' has invalid color: {}", l.name, e)))?
270 }
271 None => {
272 if random {
273 random_color()
274 } else {
275 deterministic_color(&l.name)
276 }
277 }
278 };
279 Ok(LabelSpec {
280 name: l.name.clone(),
281 description: l.description.clone().filter(|s| !s.is_empty()),
282 color,
283 })
284 })
285 .collect()
286}
287
288// --- Diff declared vs remote --------------------------------------------
289
290/// Compute the diff between the user's declared label set and the
291/// labels currently on the upstream remote. Pure function — no I/O,
292/// no observable side effects (allocates a transient `HashMap` and
293/// `HashSet` to index by name, plus the returned `LabelDiff`).
294///
295/// Comparison rules:
296/// - **Name** is the unique key. Matching is byte-exact (including
297/// whitespace), since GitHub treats `bug` and `Bug` as different.
298/// - **Color** is lowercased on both sides before comparing — defence
299/// in depth on top of [`crate::github::parse_labels_json`], which
300/// already lowercases remote colours, so a manually-constructed
301/// `RemoteLabel` (e.g. from a test fixture) with uppercase hex
302/// doesn't slip through as a spurious "update".
303/// - **Description**: `None` and `Some("")` are equivalent (GitHub
304/// stores them interchangeably).
305pub fn diff_labels(declared: &[LabelSpec], remote: &[RemoteLabel]) -> LabelDiff {
306 let remote_by_name: HashMap<&str, &RemoteLabel> = remote.iter().map(|r| (r.name.as_str(), r)).collect();
307 let declared_names: HashSet<&str> = declared.iter().map(|s| s.name.as_str()).collect();
308
309 let mut to_create = Vec::new();
310 let mut to_update = Vec::new();
311 let mut matching = Vec::new();
312
313 for spec in declared {
314 match remote_by_name.get(spec.name.as_str()) {
315 None => to_create.push(spec.clone()),
316 Some(r) => {
317 let remote_color = r.color.to_ascii_lowercase();
318 let spec_color = spec.color.to_ascii_lowercase();
319 let color_match = remote_color == spec_color;
320 let desc_match = norm_desc(&spec.description) == norm_desc(&r.description);
321 if color_match && desc_match {
322 matching.push(spec.clone());
323 } else {
324 to_update.push(LabelUpdate {
325 action: LabelAction::Update,
326 spec: spec.clone(),
327 previous_color: if color_match { None } else { Some(r.color.clone()) },
328 previous_description: if desc_match { None } else { r.description.clone() },
329 });
330 }
331 }
332 }
333 }
334
335 let extra_on_remote: Vec<RemoteLabel> = remote
336 .iter()
337 .filter(|r| !declared_names.contains(r.name.as_str()))
338 .cloned()
339 .collect();
340
341 LabelDiff {
342 to_create,
343 to_update,
344 matching,
345 extra_on_remote,
346 }
347}
348
349fn norm_desc(d: &Option<String>) -> Option<String> {
350 d.as_ref().filter(|s| !s.is_empty()).cloned()
351}