use crate::config::LabelConfig;
use crate::error::{GwmError, Result};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LabelSpec {
pub name: String,
pub description: Option<String>,
pub color: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteLabel {
pub name: String,
pub description: Option<String>,
pub color: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LabelAction {
Create,
Update,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LabelUpdate {
pub action: LabelAction,
pub spec: LabelSpec,
pub previous_color: Option<String>,
pub previous_description: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LabelDiff {
pub to_create: Vec<LabelSpec>,
pub to_update: Vec<LabelUpdate>,
pub matching: Vec<LabelSpec>,
pub extra_on_remote: Vec<RemoteLabel>,
}
impl LabelDiff {
pub fn counts(&self) -> (usize, usize, usize, usize) {
(
self.to_create.len(),
self.to_update.len(),
self.matching.len(),
self.extra_on_remote.len(),
)
}
}
pub fn diff_summary_line(create: usize, update: usize, matching: usize, extra: usize) -> String {
format!(
"summary: {} create · {} update · {} match · {} extra-on-remote",
create, update, matching, extra
)
}
pub fn diff_dry_run_line(create: usize, update: usize, matching: usize, extra: usize, pruned: usize) -> String {
format!(
"would create {}, update {}, leave {} untouched, prune {}, ignore {} extra-on-remote",
create,
update,
matching,
pruned,
extra.saturating_sub(pruned),
)
}
pub fn validate_color(s: &str) -> Result<&str> {
if s.len() != 6 {
return Err(GwmError::Config(format!(
"invalid color '{}': expected 6 hex chars, got {}",
s,
s.len()
)));
}
if !s.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(GwmError::Config(format!("invalid color '{}': not a hex string", s)));
}
Ok(s)
}
pub fn normalize_color(s: &str) -> Result<String> {
let trimmed = s.trim().trim_start_matches('#');
let lower = trimmed.to_ascii_lowercase();
validate_color(&lower)?;
Ok(lower)
}
pub fn deterministic_color(name: &str) -> String {
let h = fnv1a_64(name.as_bytes());
let bytes = h.to_le_bytes();
let r = ((bytes[0] as u16 + 255) / 2) as u8;
let g = ((bytes[1] as u16 + 255) / 2) as u8;
let b = ((bytes[2] as u16 + 255) / 2) as u8;
format!("{:02x}{:02x}{:02x}", r, g, b)
}
pub fn random_color() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let mixed = (t ^ n).wrapping_mul(0x9E3779B97F4A7C15);
let bytes = mixed.to_le_bytes();
format!("{:02x}{:02x}{:02x}", bytes[0], bytes[1], bytes[2])
}
fn fnv1a_64(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf29ce484222325;
for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
pub fn validate_label_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(GwmError::Config(
"entry has empty `name` — GitHub label names must be non-empty".into(),
));
}
if name.starts_with('-') {
return Err(GwmError::Config(format!(
"name {:?} starts with '-' — would be parsed as a flag by `gh label create`; \
rename or remove the leading dash (issue #100)",
name
)));
}
if name.contains(',') {
return Err(GwmError::Config(format!(
"name {:?} contains ',' — GitHub uses comma as a label-list separator; rename without commas",
name
)));
}
if let Some(bad) = name.chars().find(|c| c.is_ascii_control()) {
return Err(GwmError::Config(format!(
"name {:?} contains ASCII control character {:?} — rename without control characters",
name, bad
)));
}
Ok(())
}
pub fn resolve_labels(declared: &[LabelConfig], random: bool) -> Result<Vec<LabelSpec>> {
declared
.iter()
.map(|l| {
validate_label_name(&l.name).map_err(|e| {
let inner = match e {
GwmError::Config(msg) => msg,
other => other.to_string(),
};
GwmError::Config(format!("labels: {}", inner))
})?;
let color = match &l.color {
Some(c) => {
normalize_color(c).map_err(|e| GwmError::Config(format!("label '{}' has invalid color: {}", l.name, e)))?
}
None => {
if random {
random_color()
} else {
deterministic_color(&l.name)
}
}
};
Ok(LabelSpec {
name: l.name.clone(),
description: l.description.clone().filter(|s| !s.is_empty()),
color,
})
})
.collect()
}
pub fn diff_labels(declared: &[LabelSpec], remote: &[RemoteLabel]) -> LabelDiff {
let remote_by_name: HashMap<&str, &RemoteLabel> = remote.iter().map(|r| (r.name.as_str(), r)).collect();
let declared_names: HashSet<&str> = declared.iter().map(|s| s.name.as_str()).collect();
let mut to_create = Vec::new();
let mut to_update = Vec::new();
let mut matching = Vec::new();
for spec in declared {
match remote_by_name.get(spec.name.as_str()) {
None => to_create.push(spec.clone()),
Some(r) => {
let remote_color = r.color.to_ascii_lowercase();
let spec_color = spec.color.to_ascii_lowercase();
let color_match = remote_color == spec_color;
let desc_match = norm_desc(&spec.description) == norm_desc(&r.description);
if color_match && desc_match {
matching.push(spec.clone());
} else {
to_update.push(LabelUpdate {
action: LabelAction::Update,
spec: spec.clone(),
previous_color: if color_match { None } else { Some(r.color.clone()) },
previous_description: if desc_match { None } else { r.description.clone() },
});
}
}
}
}
let extra_on_remote: Vec<RemoteLabel> = remote
.iter()
.filter(|r| !declared_names.contains(r.name.as_str()))
.cloned()
.collect();
LabelDiff {
to_create,
to_update,
matching,
extra_on_remote,
}
}
fn norm_desc(d: &Option<String>) -> Option<String> {
d.as_ref().filter(|s| !s.is_empty()).cloned()
}