Skip to main content

kanade_shared/wire/
agent_groups.rs

1use serde::{Deserialize, Serialize};
2
3/// Value stored in the `agent_groups` KV bucket, keyed by `pc_id`. The
4/// wrapper struct (instead of a bare `Vec<String>`) leaves room for
5/// future per-PC metadata (membership timestamps, who-set-it audit, …)
6/// without breaking the wire format.
7#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
8pub struct AgentGroups {
9    /// Sorted, de-duplicated group names. Producers should call
10    /// [`AgentGroups::new`] / [`AgentGroups::insert`] / [`AgentGroups::remove`]
11    /// to maintain those invariants; consumers can rely on them when
12    /// diffing two snapshots for "what changed since last KV update".
13    pub groups: Vec<String>,
14}
15
16impl AgentGroups {
17    /// Construct from any iterator. Sorts + dedups so two callers that
18    /// produce the same logical set get bit-identical JSON.
19    pub fn new<I, S>(groups: I) -> Self
20    where
21        I: IntoIterator<Item = S>,
22        S: Into<String>,
23    {
24        let mut v: Vec<String> = groups.into_iter().map(Into::into).collect();
25        v.sort();
26        v.dedup();
27        Self { groups: v }
28    }
29
30    /// Insert a group. Returns `true` if the membership actually
31    /// changed (i.e. the group wasn't already present). Keeps the
32    /// inner Vec sorted.
33    pub fn insert(&mut self, group: impl Into<String>) -> bool {
34        let group = group.into();
35        match self.groups.binary_search(&group) {
36            Ok(_) => false,
37            Err(idx) => {
38                self.groups.insert(idx, group);
39                true
40            }
41        }
42    }
43
44    /// Remove a group. Returns `true` if the membership actually
45    /// changed (i.e. the group was present).
46    pub fn remove(&mut self, group: &str) -> bool {
47        match self.groups.binary_search_by(|g| g.as_str().cmp(group)) {
48            Ok(idx) => {
49                self.groups.remove(idx);
50                true
51            }
52            Err(_) => false,
53        }
54    }
55
56    pub fn contains(&self, group: &str) -> bool {
57        self.groups
58            .binary_search_by(|g| g.as_str().cmp(group))
59            .is_ok()
60    }
61
62    pub fn is_empty(&self) -> bool {
63        self.groups.is_empty()
64    }
65
66    pub fn iter(&self) -> std::slice::Iter<'_, String> {
67        self.groups.iter()
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn new_sorts_and_dedups() {
77        let g = AgentGroups::new(["wave2", "canary", "wave1", "canary"]);
78        assert_eq!(g.groups, vec!["canary", "wave1", "wave2"]);
79    }
80
81    #[test]
82    fn round_trips_through_json() {
83        let g = AgentGroups::new(["wave1", "dept-eng"]);
84        let json = serde_json::to_string(&g).unwrap();
85        assert_eq!(json, r#"{"groups":["dept-eng","wave1"]}"#);
86        let back: AgentGroups = serde_json::from_str(&json).unwrap();
87        assert_eq!(back, g);
88    }
89
90    #[test]
91    fn empty_round_trips() {
92        let g = AgentGroups::default();
93        let json = serde_json::to_string(&g).unwrap();
94        assert_eq!(json, r#"{"groups":[]}"#);
95        let back: AgentGroups = serde_json::from_str(&json).unwrap();
96        assert!(back.is_empty());
97    }
98
99    #[test]
100    fn insert_returns_true_on_change_false_on_noop() {
101        let mut g = AgentGroups::new(["wave1"]);
102        assert!(g.insert("canary"));
103        assert!(!g.insert("canary")); // already present
104        assert_eq!(g.groups, vec!["canary", "wave1"]);
105    }
106
107    #[test]
108    fn remove_returns_true_on_change_false_on_noop() {
109        let mut g = AgentGroups::new(["wave1", "canary"]);
110        assert!(g.remove("wave1"));
111        assert!(!g.remove("wave1"));
112        assert_eq!(g.groups, vec!["canary"]);
113    }
114
115    #[test]
116    fn contains_matches_after_mutations() {
117        let mut g = AgentGroups::new(["wave1"]);
118        assert!(g.contains("wave1"));
119        assert!(!g.contains("canary"));
120        g.insert("canary");
121        assert!(g.contains("canary"));
122        g.remove("wave1");
123        assert!(!g.contains("wave1"));
124    }
125
126    #[test]
127    fn accepts_unknown_fields_for_forward_compat() {
128        // Future versions may add per-PC metadata next to `groups`.
129        // Old clients should not break on the new fields — serde's
130        // default is to ignore unknowns, but lock that property down.
131        let json = r#"{"groups":["canary"],"set_by":"alice","set_at":"2026-05-16T01:00:00Z"}"#;
132        let g: AgentGroups = serde_json::from_str(json).unwrap();
133        assert_eq!(g.groups, vec!["canary"]);
134    }
135}