Skip to main content

mur_common/
labels.rs

1//! Fleet labels — a central, many-to-many taxonomy over fleets.
2//!
3//! Labels live in one registry (`~/.mur/labels.yaml`), never in each
4//! `fleet.yaml`: renaming a label must not rewrite N fleet files, and the
5//! registry is also where label order (hence chip order) is kept.
6//!
7//! A fleet's **primary label is simply the first entry of its ordered
8//! assignment list** — there is deliberately no separate `primary:` field, so
9//! the two can never disagree.
10
11use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15/// A label id must be a filesystem-safe lowercase slug: it is used as a map key
16/// in YAML and shown as a chip. Same character class as `valid_fleet_name`, so
17/// a hand-edited `../evil` can never enter the registry.
18pub 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    /// Human-facing name; falls back to `id` when empty.
30    #[serde(default)]
31    pub display: String,
32    /// Optional chip tint, e.g. `#4a9eff`.
33    #[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/// The whole taxonomy: an ordered list of labels plus fleet → label-ids.
57///
58/// `assignments` order is meaningful (index 0 is the primary label); the map
59/// itself is a `BTreeMap` so the serialized file is stable across saves.
60#[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    /// Labels assigned to a fleet, primary first. Empty when unassigned.
78    pub fn labels_of(&self, fleet: &str) -> &[String] {
79        self.assignments.get(fleet).map(|v| &v[..]).unwrap_or(&[])
80    }
81
82    /// The group a fleet belongs to: its first label, or `None` for Ungrouped.
83    pub fn primary_of(&self, fleet: &str) -> Option<&str> {
84        self.labels_of(fleet).first().map(|s| s.as_str())
85    }
86
87    /// How many fleets carry a label (in any position).
88    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    /// Self-heal a registry that may have been hand-edited: drop invalid or
96    /// unknown label ids, de-duplicate while keeping first-wins order (so the
97    /// primary survives), and drop fleets left with nothing.
98    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    /// Replace a fleet's labels (order = priority; first is primary).
125    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    /// Remove a label everywhere: from the list and from every assignment.
135    /// Fleets fall back to their next label, or become Ungrouped.
136    pub fn delete_label(&mut self, id: &str) {
137        self.labels.retain(|l| l.id != id);
138        self.normalize();
139    }
140
141    /// Rename a label's display text (the id, being the key, is stable).
142    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    /// Forget assignments for fleets that no longer exist on disk.
153    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        // develop-web falls back to its next label; it does not vanish.
213        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}