use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub fn valid_label_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 32
&& id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Label {
pub id: String,
#[serde(default)]
pub display: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
impl Label {
pub fn new(id: impl Into<String>) -> Self {
let id = id.into();
Label {
display: id.clone(),
id,
color: None,
}
}
pub fn display_or_id(&self) -> &str {
if self.display.is_empty() {
&self.id
} else {
&self.display
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LabelRegistry {
#[serde(default)]
pub labels: Vec<Label>,
#[serde(default)]
pub assignments: BTreeMap<String, Vec<String>>,
}
impl LabelRegistry {
pub fn contains(&self, id: &str) -> bool {
self.labels.iter().any(|l| l.id == id)
}
pub fn get(&self, id: &str) -> Option<&Label> {
self.labels.iter().find(|l| l.id == id)
}
pub fn labels_of(&self, fleet: &str) -> &[String] {
self.assignments.get(fleet).map(|v| &v[..]).unwrap_or(&[])
}
pub fn primary_of(&self, fleet: &str) -> Option<&str> {
self.labels_of(fleet).first().map(|s| s.as_str())
}
pub fn fleet_count(&self, id: &str) -> usize {
self.assignments
.values()
.filter(|ids| ids.iter().any(|i| i == id))
.count()
}
pub fn normalize(&mut self) {
self.labels.retain(|l| valid_label_id(&l.id));
let mut seen_labels = Vec::new();
self.labels.retain(|l| {
if seen_labels.contains(&l.id) {
false
} else {
seen_labels.push(l.id.clone());
true
}
});
let known = seen_labels;
for ids in self.assignments.values_mut() {
let mut seen = Vec::new();
ids.retain(|id| {
if !known.contains(id) || seen.contains(id) {
false
} else {
seen.push(id.clone());
true
}
});
}
self.assignments.retain(|_, ids| !ids.is_empty());
}
pub fn set_labels(&mut self, fleet: &str, ids: Vec<String>) {
if ids.is_empty() {
self.assignments.remove(fleet);
} else {
self.assignments.insert(fleet.to_string(), ids);
}
self.normalize();
}
pub fn delete_label(&mut self, id: &str) {
self.labels.retain(|l| l.id != id);
self.normalize();
}
pub fn rename_label(&mut self, id: &str, display: &str) -> bool {
match self.labels.iter_mut().find(|l| l.id == id) {
Some(l) => {
l.display = display.to_string();
true
}
None => false,
}
}
pub fn prune(&mut self, existing_fleets: &[String]) {
self.assignments
.retain(|fleet, _| existing_fleets.iter().any(|f| f == fleet));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn label_id_refuses_traversal_and_junk() {
assert!(valid_label_id("web"));
assert!(valid_label_id("rust-2024_x"));
assert!(!valid_label_id(""));
assert!(!valid_label_id("../evil"));
assert!(!valid_label_id("Web"));
assert!(!valid_label_id("has space"));
assert!(!valid_label_id(&"x".repeat(33)));
}
fn reg() -> LabelRegistry {
let mut r = LabelRegistry {
labels: vec![Label::new("web"), Label::new("rust")],
assignments: BTreeMap::new(),
};
r.set_labels("develop-web", vec!["web".into(), "rust".into()]);
r.set_labels("rust-solo", vec!["rust".into()]);
r
}
#[test]
fn primary_is_the_first_label_so_a_fleet_groups_once() {
let r = reg();
assert_eq!(r.primary_of("develop-web"), Some("web"));
assert_eq!(r.primary_of("rust-solo"), Some("rust"));
assert_eq!(r.primary_of("unknown-fleet"), None);
assert_eq!(r.fleet_count("rust"), 2);
assert_eq!(r.fleet_count("web"), 1);
}
#[test]
fn normalize_drops_unknown_and_duplicate_ids() {
let mut r = reg();
r.assignments.insert(
"ghost".into(),
vec!["nope".into(), "web".into(), "web".into()],
);
r.assignments.insert("empty".into(), vec!["nope".into()]);
r.normalize();
assert_eq!(r.labels_of("ghost"), ["web"]);
assert!(!r.assignments.contains_key("empty"));
}
#[test]
fn delete_label_scrubs_assignments_and_repoints_primary() {
let mut r = reg();
r.delete_label("web");
assert!(!r.contains("web"));
assert_eq!(r.primary_of("develop-web"), Some("rust"));
}
#[test]
fn delete_last_label_makes_fleet_ungrouped() {
let mut r = reg();
r.delete_label("rust");
assert_eq!(r.primary_of("rust-solo"), None);
}
#[test]
fn prune_forgets_dead_fleets() {
let mut r = reg();
r.prune(&["develop-web".to_string()]);
assert!(r.assignments.contains_key("develop-web"));
assert!(!r.assignments.contains_key("rust-solo"));
}
#[test]
fn rename_changes_display_not_id() {
let mut r = reg();
assert!(r.rename_label("web", "Web Stuff"));
assert_eq!(r.get("web").unwrap().display_or_id(), "Web Stuff");
assert!(!r.rename_label("missing", "x"));
}
}