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
54impl MetaEntry {
55    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
56        Self {
57            key: key.into(),
58            value: value.into(),
59        }
60    }
61}
62
63impl AgentMeta {
64    /// Construct from any iterator of entries, normalising: trim key and
65    /// value, drop rows whose key is empty, and de-dup by key keeping the
66    /// **last** occurrence's value while preserving each key's first-seen
67    /// position. So two operators who enter the same logical set (any
68    /// trailing whitespace / duplicate key / re-ordered) converge on the
69    /// same stored JSON for a given field order.
70    pub fn new<I: IntoIterator<Item = MetaEntry>>(entries: I) -> Self {
71        let mut order: Vec<String> = Vec::new();
72        let mut values: std::collections::HashMap<String, String> =
73            std::collections::HashMap::new();
74        for e in entries {
75            let key = e.key.trim().to_string();
76            if key.is_empty() {
77                continue;
78            }
79            let value = e.value.trim().to_string();
80            if !values.contains_key(&key) {
81                order.push(key.clone());
82            }
83            values.insert(key, value); // last wins
84        }
85        let entries = order
86            .into_iter()
87            .map(|key| {
88                let value = values.remove(&key).unwrap_or_default();
89                MetaEntry { key, value }
90            })
91            .collect();
92        Self { entries }
93    }
94
95    /// Set a key to a value (insert or overwrite). Trims both. Returns
96    /// `true` if the stored set changed (new key, or a different value for
97    /// an existing key), `false` on a no-op. An empty (post-trim) key is
98    /// rejected as a no-op — use [`AgentMeta::remove`] to drop a key.
99    pub fn upsert(&mut self, key: impl Into<String>, value: impl Into<String>) -> bool {
100        let key = key.into().trim().to_string();
101        if key.is_empty() {
102            return false;
103        }
104        let value = value.into().trim().to_string();
105        match self.entries.iter_mut().find(|e| e.key == key) {
106            Some(e) if e.value == value => false,
107            Some(e) => {
108                e.value = value;
109                true
110            }
111            None => {
112                self.entries.push(MetaEntry { key, value });
113                true
114            }
115        }
116    }
117
118    /// Remove a key. Returns `true` if it was present.
119    pub fn remove(&mut self, key: &str) -> bool {
120        let key = key.trim();
121        let before = self.entries.len();
122        self.entries.retain(|e| e.key != key);
123        self.entries.len() != before
124    }
125
126    pub fn get(&self, key: &str) -> Option<&str> {
127        let key = key.trim();
128        self.entries
129            .iter()
130            .find(|e| e.key == key)
131            .map(|e| e.value.as_str())
132    }
133
134    pub fn is_empty(&self) -> bool {
135        self.entries.is_empty()
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn e(key: &str, value: &str) -> MetaEntry {
144        MetaEntry::new(key, value)
145    }
146
147    #[test]
148    fn new_trims_drops_empty_keys_and_dedups_last_wins() {
149        let m = AgentMeta::new([
150            e("  Name ", "  Alice  "),
151            e("Email", "alice@example.com"),
152            e("   ", "orphan value"), // empty key -> dropped
153            e("Name", "Alice Smith"), // dup key -> last wins, keeps position
154        ]);
155        assert_eq!(
156            m.entries,
157            vec![e("Name", "Alice Smith"), e("Email", "alice@example.com"),]
158        );
159    }
160
161    #[test]
162    fn round_trips_through_json() {
163        let m = AgentMeta::new([e("Dept", "Finance")]);
164        let json = serde_json::to_string(&m).unwrap();
165        assert_eq!(json, r#"{"entries":[{"key":"Dept","value":"Finance"}]}"#);
166        let back: AgentMeta = serde_json::from_str(&json).unwrap();
167        assert_eq!(back, m);
168    }
169
170    #[test]
171    fn empty_round_trips() {
172        let m = AgentMeta::default();
173        assert_eq!(serde_json::to_string(&m).unwrap(), r#"{"entries":[]}"#);
174        assert!(m.is_empty());
175    }
176
177    #[test]
178    fn upsert_inserts_overwrites_and_noops() {
179        let mut m = AgentMeta::default();
180        assert!(m.upsert("Name", "Alice")); // insert
181        assert!(!m.upsert("Name", "Alice")); // no-op
182        assert!(m.upsert("Name", "Bob")); // overwrite
183        assert!(!m.upsert("  ", "x")); // empty key rejected
184        assert!(m.upsert("  Email ", " a@b.com ")); // trims key + value
185        assert_eq!(m.get("Name"), Some("Bob"));
186        assert_eq!(m.get("Email"), Some("a@b.com"));
187        assert_eq!(m.entries.len(), 2);
188    }
189
190    #[test]
191    fn remove_reports_change() {
192        let mut m = AgentMeta::new([e("Name", "Alice"), e("Dept", "IT")]);
193        assert!(m.remove("Name"));
194        assert!(!m.remove("Name"));
195        assert_eq!(m.entries, vec![e("Dept", "IT")]);
196    }
197
198    #[test]
199    fn accepts_unknown_fields_for_forward_compat() {
200        // Future versions may add per-PC metadata (set_by / set_at)
201        // alongside `entries`; old clients must not break on them.
202        let json = r#"{"entries":[{"key":"Name","value":"Alice"}],"set_by":"admin"}"#;
203        let m: AgentMeta = serde_json::from_str(json).unwrap();
204        assert_eq!(m.entries, vec![e("Name", "Alice")]);
205    }
206
207    #[test]
208    fn deserialize_normalises_untrusted_input() {
209        // A raw KV write / older binary could store un-normalised rows
210        // (untrimmed, blank key, duplicate key). Deserialize must clean
211        // them so upsert/remove — which assume unique keys — stay correct.
212        let json = r#"{"entries":[
213            {"key":"  Name ","value":" Alice "},
214            {"key":"","value":"orphan"},
215            {"key":"Name","value":"Bob"}
216        ]}"#;
217        let m: AgentMeta = serde_json::from_str(json).unwrap();
218        assert_eq!(m.entries, vec![e("Name", "Bob")]);
219    }
220
221    #[test]
222    fn deserialize_defaults_missing_entries() {
223        let m: AgentMeta = serde_json::from_str("{}").unwrap();
224        assert!(m.is_empty());
225    }
226}