Skip to main content

kanade_shared/wire/
agent_meta.rs

1//! Per-PC operator metadata stored in the `agent_meta` KV bucket, keyed
2//! by `pc_id`.
3//!
4//! Free-form key/value annotations an operator attaches to a machine —
5//! the primary user's name / email / department, or an ad-hoc note. Keys
6//! are not fixed; the operator invents them. Distinct from:
7//!
8//!   * the `agents` heartbeat projection — that table is *volatile*
9//!     (overwritten every heartbeat, removed by dead-agent prune), so it
10//!     is the wrong home for durable operator-entered data;
11//!   * `agent_groups` (per-PC membership) — same "per-PC operator-managed
12//!     KV" pattern, but tags, not key/value.
13//!
14//! A wrapper struct (rather than a bare map) leaves room for future
15//! per-PC metadata without a wire break, and a `Vec<MetaEntry>` (not a
16//! map) preserves the operator's field order.
17
18use serde::{Deserialize, Serialize};
19
20#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq)]
21pub struct AgentMeta {
22    /// Operator-entered key/value rows, in display order. Producers
23    /// should go through [`AgentMeta::new`] / [`AgentMeta::upsert`] /
24    /// [`AgentMeta::remove`] so the invariants below hold; consumers can
25    /// then rely on them: unique non-empty keys, each trimmed.
26    pub entries: Vec<MetaEntry>,
27}
28
29// Deserialize routes through [`AgentMeta::new`] so the invariants
30// (trimmed, unique non-empty keys) hold no matter where the JSON came
31// from — a raw KV write, an older binary, or a hand-edited value. Without
32// this a duplicate/blank/untrimmed key on the wire would slip straight
33// past `upsert`/`remove` (which assume unique keys) into the process.
34impl<'de> Deserialize<'de> for AgentMeta {
35    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
36    where
37        D: serde::Deserializer<'de>,
38    {
39        #[derive(Deserialize)]
40        struct Raw {
41            #[serde(default)]
42            entries: Vec<MetaEntry>,
43        }
44        Ok(AgentMeta::new(Raw::deserialize(deserializer)?.entries))
45    }
46}
47
48#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
49pub struct MetaEntry {
50    pub key: String,
51    pub value: String,
52}
53
54/// Response of the backend's single-key upsert / remove routes: the PC's
55/// attribute set as it stands afterwards, and whether the call actually
56/// wrote. An already-satisfied request answers `changed: false` without
57/// touching the KV row, so the revision does not move.
58#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
59pub struct MetaUpdate {
60    pub meta: AgentMeta,
61    pub changed: bool,
62}
63
64impl MetaEntry {
65    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
66        Self {
67            key: key.into(),
68            value: value.into(),
69        }
70    }
71}
72
73impl AgentMeta {
74    /// Construct from any iterator of entries, normalising: trim key and
75    /// value, drop rows whose key is empty, and de-dup by key keeping the
76    /// **last** occurrence's value while preserving each key's first-seen
77    /// position. So two operators who enter the same logical set (any
78    /// trailing whitespace / duplicate key / re-ordered) converge on the
79    /// same stored JSON for a given field order.
80    pub fn new<I: IntoIterator<Item = MetaEntry>>(entries: I) -> Self {
81        let mut order: Vec<String> = Vec::new();
82        let mut values: std::collections::HashMap<String, String> =
83            std::collections::HashMap::new();
84        for e in entries {
85            let key = e.key.trim().to_string();
86            if key.is_empty() {
87                continue;
88            }
89            let value = e.value.trim().to_string();
90            if !values.contains_key(&key) {
91                order.push(key.clone());
92            }
93            values.insert(key, value); // last wins
94        }
95        let entries = order
96            .into_iter()
97            .map(|key| {
98                let value = values.remove(&key).unwrap_or_default();
99                MetaEntry { key, value }
100            })
101            .collect();
102        Self { entries }
103    }
104
105    /// Set a key to a value (insert or overwrite). Trims both. Returns
106    /// `true` if the stored set changed (new key, or a different value for
107    /// an existing key), `false` on a no-op. An empty (post-trim) key is
108    /// rejected as a no-op — use [`AgentMeta::remove`] to drop a key.
109    pub fn upsert(&mut self, key: impl Into<String>, value: impl Into<String>) -> bool {
110        let key = key.into().trim().to_string();
111        if key.is_empty() {
112            return false;
113        }
114        let value = value.into().trim().to_string();
115        match self.entries.iter_mut().find(|e| e.key == key) {
116            Some(e) if e.value == value => false,
117            Some(e) => {
118                e.value = value;
119                true
120            }
121            None => {
122                self.entries.push(MetaEntry { key, value });
123                true
124            }
125        }
126    }
127
128    /// Remove a key. Returns `true` if it was present.
129    pub fn remove(&mut self, key: &str) -> bool {
130        let key = key.trim();
131        let before = self.entries.len();
132        self.entries.retain(|e| e.key != key);
133        self.entries.len() != before
134    }
135
136    pub fn get(&self, key: &str) -> Option<&str> {
137        let key = key.trim();
138        self.entries
139            .iter()
140            .find(|e| e.key == key)
141            .map(|e| e.value.as_str())
142    }
143
144    pub fn is_empty(&self) -> bool {
145        self.entries.is_empty()
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn e(key: &str, value: &str) -> MetaEntry {
154        MetaEntry::new(key, value)
155    }
156
157    #[test]
158    fn new_trims_drops_empty_keys_and_dedups_last_wins() {
159        let m = AgentMeta::new([
160            e("  Name ", "  Alice  "),
161            e("Email", "alice@example.com"),
162            e("   ", "orphan value"), // empty key -> dropped
163            e("Name", "Alice Smith"), // dup key -> last wins, keeps position
164        ]);
165        assert_eq!(
166            m.entries,
167            vec![e("Name", "Alice Smith"), e("Email", "alice@example.com"),]
168        );
169    }
170
171    #[test]
172    fn round_trips_through_json() {
173        let m = AgentMeta::new([e("Dept", "Finance")]);
174        let json = serde_json::to_string(&m).unwrap();
175        assert_eq!(json, r#"{"entries":[{"key":"Dept","value":"Finance"}]}"#);
176        let back: AgentMeta = serde_json::from_str(&json).unwrap();
177        assert_eq!(back, m);
178    }
179
180    #[test]
181    fn empty_round_trips() {
182        let m = AgentMeta::default();
183        assert_eq!(serde_json::to_string(&m).unwrap(), r#"{"entries":[]}"#);
184        assert!(m.is_empty());
185    }
186
187    #[test]
188    fn upsert_inserts_overwrites_and_noops() {
189        let mut m = AgentMeta::default();
190        assert!(m.upsert("Name", "Alice")); // insert
191        assert!(!m.upsert("Name", "Alice")); // no-op
192        assert!(m.upsert("Name", "Bob")); // overwrite
193        assert!(!m.upsert("  ", "x")); // empty key rejected
194        assert!(m.upsert("  Email ", " a@b.com ")); // trims key + value
195        assert_eq!(m.get("Name"), Some("Bob"));
196        assert_eq!(m.get("Email"), Some("a@b.com"));
197        assert_eq!(m.entries.len(), 2);
198    }
199
200    #[test]
201    fn remove_reports_change() {
202        let mut m = AgentMeta::new([e("Name", "Alice"), e("Dept", "IT")]);
203        assert!(m.remove("Name"));
204        assert!(!m.remove("Name"));
205        assert_eq!(m.entries, vec![e("Dept", "IT")]);
206    }
207
208    #[test]
209    fn accepts_unknown_fields_for_forward_compat() {
210        // Future versions may add per-PC metadata (set_by / set_at)
211        // alongside `entries`; old clients must not break on them.
212        let json = r#"{"entries":[{"key":"Name","value":"Alice"}],"set_by":"admin"}"#;
213        let m: AgentMeta = serde_json::from_str(json).unwrap();
214        assert_eq!(m.entries, vec![e("Name", "Alice")]);
215    }
216
217    #[test]
218    fn deserialize_normalises_untrusted_input() {
219        // A raw KV write / older binary could store un-normalised rows
220        // (untrimmed, blank key, duplicate key). Deserialize must clean
221        // them so upsert/remove — which assume unique keys — stay correct.
222        let json = r#"{"entries":[
223            {"key":"  Name ","value":" Alice "},
224            {"key":"","value":"orphan"},
225            {"key":"Name","value":"Bob"}
226        ]}"#;
227        let m: AgentMeta = serde_json::from_str(json).unwrap();
228        assert_eq!(m.entries, vec![e("Name", "Bob")]);
229    }
230
231    #[test]
232    fn deserialize_defaults_missing_entries() {
233        let m: AgentMeta = serde_json::from_str("{}").unwrap();
234        assert!(m.is_empty());
235    }
236}