use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
pub const CONFIG_KEY_PREFIX: &str = "config:";
pub const PENDING_ACTIONS_KEY: &str = "pending_actions";
pub const META_NONCE_KEY: &str = "__meta_nonce__";
pub const META_UPDATED_AT_KEY: &str = "__meta_updated_at__";
pub type DataMap = HashMap<String, String>;
pub fn config_key(widget_id: &str) -> String {
format!("{CONFIG_KEY_PREFIX}{widget_id}")
}
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn map_nonce(map: &DataMap) -> u64 {
map.get(META_NONCE_KEY)
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
pub fn map_updated_at(map: &DataMap) -> u64 {
map.get(META_UPDATED_AT_KEY)
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
pub fn touch_meta(map: &mut DataMap) {
let next = map_nonce(map).saturating_add(1);
map.insert(META_NONCE_KEY.into(), next.to_string());
map.insert(META_UPDATED_AT_KEY.into(), now_ms().to_string());
}
pub fn touch_meta_above(map: &mut DataMap, floor: u64) {
let next = map_nonce(map)
.saturating_add(1)
.max(floor.saturating_add(1));
map.insert(META_NONCE_KEY.into(), next.to_string());
map.insert(META_UPDATED_AT_KEY.into(), now_ms().to_string());
}
pub fn map_has_config(map: &DataMap) -> bool {
map.keys().any(|k| k.starts_with(CONFIG_KEY_PREFIX))
}
pub fn pick_freshest(maps: impl IntoIterator<Item = DataMap>) -> DataMap {
let mut best: Option<DataMap> = None;
let mut best_has_config = false;
let mut best_nonce = 0u64;
let mut best_ts = 0u64;
for map in maps {
let has_config = map_has_config(&map);
let n = map_nonce(&map);
let t = map_updated_at(&map);
let better = match &best {
None => true,
Some(_) if has_config && !best_has_config => true,
Some(_) if !has_config && best_has_config => false,
Some(_) => n > best_nonce || (n == best_nonce && t > best_ts),
};
if better {
best_has_config = has_config;
best_nonce = n;
best_ts = t;
best = Some(map);
}
}
best.unwrap_or_default()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WidgetActionEnvelope {
pub action: String,
#[serde(default)]
pub payload: Option<String>,
pub ts: u64,
pub widget_id: String,
pub group: String,
}
impl WidgetActionEnvelope {
pub fn new(
action: impl Into<String>,
payload: Option<String>,
widget_id: impl Into<String>,
group: impl Into<String>,
) -> Self {
Self {
action: action.into(),
payload,
ts: now_ms(),
widget_id: widget_id.into(),
group: group.into(),
}
}
}
pub fn parse_pending_actions(raw: Option<&str>) -> Vec<WidgetActionEnvelope> {
let Some(s) = raw.filter(|s| !s.is_empty()) else {
return Vec::new();
};
serde_json::from_str(s).unwrap_or_default()
}
pub fn encode_pending_actions(actions: &[WidgetActionEnvelope]) -> crate::Result<String> {
Ok(serde_json::to_string(actions)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn touch_meta_above_beats_stale_sibling_nonce() {
let mut map = DataMap::new();
map.insert("config:example".into(), "{}".into());
map.insert(META_NONCE_KEY.into(), "9".into());
touch_meta_above(&mut map, 65);
assert_eq!(map_nonce(&map), 66);
}
#[test]
fn pick_freshest_prefers_config_over_higher_nonce_probe() {
let mut probe = DataMap::new();
probe.insert("__probe__".into(), "1".into());
probe.insert(META_NONCE_KEY.into(), "99".into());
probe.insert(META_UPDATED_AT_KEY.into(), "999".into());
let mut live = DataMap::new();
live.insert("config:example".into(), r#"{"version":1}"#.into());
live.insert(META_NONCE_KEY.into(), "4".into());
live.insert(META_UPDATED_AT_KEY.into(), "100".into());
let picked = pick_freshest([probe, live]);
assert!(
picked.contains_key("config:example"),
"probe-only sibling must not wipe live config"
);
assert_eq!(map_nonce(&picked), 4);
}
}