1use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15pub fn valid_label_id(id: &str) -> bool {
19 !id.is_empty()
20 && id.len() <= 32
21 && id
22 .chars()
23 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Label {
28 pub id: String,
29 #[serde(default)]
31 pub display: String,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub color: Option<String>,
35}
36
37impl Label {
38 pub fn new(id: impl Into<String>) -> Self {
39 let id = id.into();
40 Label {
41 display: id.clone(),
42 id,
43 color: None,
44 }
45 }
46
47 pub fn display_or_id(&self) -> &str {
48 if self.display.is_empty() {
49 &self.id
50 } else {
51 &self.display
52 }
53 }
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
61pub struct LabelRegistry {
62 #[serde(default)]
63 pub labels: Vec<Label>,
64 #[serde(default)]
65 pub assignments: BTreeMap<String, Vec<String>>,
66}
67
68impl LabelRegistry {
69 pub fn contains(&self, id: &str) -> bool {
70 self.labels.iter().any(|l| l.id == id)
71 }
72
73 pub fn get(&self, id: &str) -> Option<&Label> {
74 self.labels.iter().find(|l| l.id == id)
75 }
76
77 pub fn labels_of(&self, fleet: &str) -> &[String] {
79 self.assignments.get(fleet).map(|v| &v[..]).unwrap_or(&[])
80 }
81
82 pub fn primary_of(&self, fleet: &str) -> Option<&str> {
84 self.labels_of(fleet).first().map(|s| s.as_str())
85 }
86
87 pub fn fleet_count(&self, id: &str) -> usize {
89 self.assignments
90 .values()
91 .filter(|ids| ids.iter().any(|i| i == id))
92 .count()
93 }
94
95 pub fn normalize(&mut self) {
99 self.labels.retain(|l| valid_label_id(&l.id));
100 let mut seen_labels = Vec::new();
101 self.labels.retain(|l| {
102 if seen_labels.contains(&l.id) {
103 false
104 } else {
105 seen_labels.push(l.id.clone());
106 true
107 }
108 });
109 let known = seen_labels;
110 for ids in self.assignments.values_mut() {
111 let mut seen = Vec::new();
112 ids.retain(|id| {
113 if !known.contains(id) || seen.contains(id) {
114 false
115 } else {
116 seen.push(id.clone());
117 true
118 }
119 });
120 }
121 self.assignments.retain(|_, ids| !ids.is_empty());
122 }
123
124 pub fn set_labels(&mut self, fleet: &str, ids: Vec<String>) {
126 if ids.is_empty() {
127 self.assignments.remove(fleet);
128 } else {
129 self.assignments.insert(fleet.to_string(), ids);
130 }
131 self.normalize();
132 }
133
134 pub fn delete_label(&mut self, id: &str) {
137 self.labels.retain(|l| l.id != id);
138 self.normalize();
139 }
140
141 pub fn rename_label(&mut self, id: &str, display: &str) -> bool {
143 match self.labels.iter_mut().find(|l| l.id == id) {
144 Some(l) => {
145 l.display = display.to_string();
146 true
147 }
148 None => false,
149 }
150 }
151
152 pub fn prune(&mut self, existing_fleets: &[String]) {
154 self.assignments
155 .retain(|fleet, _| existing_fleets.iter().any(|f| f == fleet));
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn label_id_refuses_traversal_and_junk() {
165 assert!(valid_label_id("web"));
166 assert!(valid_label_id("rust-2024_x"));
167 assert!(!valid_label_id(""));
168 assert!(!valid_label_id("../evil"));
169 assert!(!valid_label_id("Web"));
170 assert!(!valid_label_id("has space"));
171 assert!(!valid_label_id(&"x".repeat(33)));
172 }
173
174 fn reg() -> LabelRegistry {
175 let mut r = LabelRegistry {
176 labels: vec![Label::new("web"), Label::new("rust")],
177 assignments: BTreeMap::new(),
178 };
179 r.set_labels("develop-web", vec!["web".into(), "rust".into()]);
180 r.set_labels("rust-solo", vec!["rust".into()]);
181 r
182 }
183
184 #[test]
185 fn primary_is_the_first_label_so_a_fleet_groups_once() {
186 let r = reg();
187 assert_eq!(r.primary_of("develop-web"), Some("web"));
188 assert_eq!(r.primary_of("rust-solo"), Some("rust"));
189 assert_eq!(r.primary_of("unknown-fleet"), None);
190 assert_eq!(r.fleet_count("rust"), 2);
191 assert_eq!(r.fleet_count("web"), 1);
192 }
193
194 #[test]
195 fn normalize_drops_unknown_and_duplicate_ids() {
196 let mut r = reg();
197 r.assignments.insert(
198 "ghost".into(),
199 vec!["nope".into(), "web".into(), "web".into()],
200 );
201 r.assignments.insert("empty".into(), vec!["nope".into()]);
202 r.normalize();
203 assert_eq!(r.labels_of("ghost"), ["web"]);
204 assert!(!r.assignments.contains_key("empty"));
205 }
206
207 #[test]
208 fn delete_label_scrubs_assignments_and_repoints_primary() {
209 let mut r = reg();
210 r.delete_label("web");
211 assert!(!r.contains("web"));
212 assert_eq!(r.primary_of("develop-web"), Some("rust"));
214 }
215
216 #[test]
217 fn delete_last_label_makes_fleet_ungrouped() {
218 let mut r = reg();
219 r.delete_label("rust");
220 assert_eq!(r.primary_of("rust-solo"), None);
221 }
222
223 #[test]
224 fn prune_forgets_dead_fleets() {
225 let mut r = reg();
226 r.prune(&["develop-web".to_string()]);
227 assert!(r.assignments.contains_key("develop-web"));
228 assert!(!r.assignments.contains_key("rust-solo"));
229 }
230
231 #[test]
232 fn rename_changes_display_not_id() {
233 let mut r = reg();
234 assert!(r.rename_label("web", "Web Stuff"));
235 assert_eq!(r.get("web").unwrap().display_or_id(), "Web Stuff");
236 assert!(!r.rename_label("missing", "x"));
237 }
238}