Skip to main content

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}