use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, uniffi::Record)]
pub struct RosterEntry {
pub node_id: String,
pub group_id: String,
pub name: String,
pub addresses: Vec<String>,
pub relay_url: Option<String>,
pub last_seen_ms: u64,
}
pub(crate) struct RosterStore {
entries: Mutex<HashMap<String, RosterEntry>>,
path: PathBuf,
}
impl RosterStore {
pub(crate) fn load(dir: &Path) -> Self {
let path = dir.join("roster.json");
let entries = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice::<Vec<RosterEntry>>(&b).ok())
.map(|v| {
v.into_iter()
.map(|e| (e.node_id.clone(), e))
.collect::<HashMap<_, _>>()
})
.unwrap_or_default();
RosterStore {
entries: Mutex::new(entries),
path,
}
}
fn persist(&self, entries: &HashMap<String, RosterEntry>) {
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut list: Vec<&RosterEntry> = entries.values().collect();
list.sort_by(|a, b| a.node_id.cmp(&b.node_id)); if let Ok(json) = serde_json::to_vec_pretty(&list) {
let tmp = self.path.with_extension("json.tmp");
if std::fs::write(&tmp, &json).is_ok() {
let _ = std::fs::rename(&tmp, &self.path);
}
}
}
pub(crate) fn upsert(&self, mut entry: RosterEntry) {
let mut map = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if let Some(existing) = map.get(&entry.node_id) {
if entry.last_seen_ms < existing.last_seen_ms {
entry.last_seen_ms = existing.last_seen_ms;
}
}
map.insert(entry.node_id.clone(), entry);
self.persist(&map);
}
pub(crate) fn remove(&self, node_id: &str) -> bool {
let mut map = self.entries.lock().unwrap_or_else(|e| e.into_inner());
let removed = map.remove(node_id).is_some();
if removed {
self.persist(&map);
}
removed
}
pub(crate) fn get(&self, node_id: &str) -> Option<RosterEntry> {
let map = self.entries.lock().unwrap_or_else(|e| e.into_inner());
map.get(node_id).cloned()
}
pub(crate) fn list(&self) -> Vec<RosterEntry> {
let map = self.entries.lock().unwrap_or_else(|e| e.into_inner());
let mut v: Vec<RosterEntry> = map.values().cloned().collect();
v.sort_by(|a, b| a.node_id.cmp(&b.node_id));
v
}
pub(crate) fn list_by_group(&self, group_id: &str) -> Vec<RosterEntry> {
self.list()
.into_iter()
.filter(|e| e.group_id == group_id)
.collect()
}
pub(crate) fn mark_seen(&self, node_id: &str, now_ms: u64) {
let mut map = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if let Some(e) = map.get_mut(node_id) {
e.last_seen_ms = now_ms;
self.persist(&map);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmpdir(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"peat-roster-test-{name}-{:?}",
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
fn entry(node_id: &str, group: &str) -> RosterEntry {
RosterEntry {
node_id: node_id.to_string(),
group_id: group.to_string(),
name: format!("peer-{node_id}"),
addresses: vec!["192.168.1.5:19001".to_string()],
relay_url: Some("https://relay.example".to_string()),
last_seen_ms: 0,
}
}
#[test]
fn upsert_get_remove_roundtrip() {
let store = RosterStore::load(&tmpdir("crud"));
store.upsert(entry("aa", "g1"));
assert_eq!(store.get("aa").unwrap().group_id, "g1");
assert!(store.remove("aa"));
assert!(store.get("aa").is_none());
assert!(!store.remove("aa"), "second remove reports absent");
}
#[test]
fn upsert_updates_in_place_no_duplicates() {
let store = RosterStore::load(&tmpdir("update"));
store.upsert(entry("aa", "g1"));
let mut updated = entry("aa", "g1");
updated.addresses = vec!["10.0.0.9:5000".to_string()];
store.upsert(updated);
assert_eq!(store.list().len(), 1, "same node_id updates, not appends");
assert_eq!(store.get("aa").unwrap().addresses, vec!["10.0.0.9:5000"]);
}
#[test]
fn upsert_never_moves_last_seen_backwards() {
let store = RosterStore::load(&tmpdir("lastseen"));
let mut e = entry("aa", "g1");
e.last_seen_ms = 1000;
store.upsert(e);
store.upsert(entry("aa", "g1"));
assert_eq!(store.get("aa").unwrap().last_seen_ms, 1000);
store.mark_seen("aa", 2000);
assert_eq!(store.get("aa").unwrap().last_seen_ms, 2000);
}
#[test]
fn list_by_group_filters() {
let store = RosterStore::load(&tmpdir("group"));
store.upsert(entry("aa", "g1"));
store.upsert(entry("bb", "g1"));
store.upsert(entry("cc", "g2"));
assert_eq!(store.list_by_group("g1").len(), 2);
assert_eq!(store.list_by_group("g2").len(), 1);
assert_eq!(store.list_by_group("none").len(), 0);
}
#[test]
fn persists_across_reload() {
let dir = tmpdir("reload");
{
let store = RosterStore::load(&dir);
store.upsert(entry("aa", "g1"));
store.mark_seen("aa", 4242);
}
let reopened = RosterStore::load(&dir);
let e = reopened.get("aa").expect("survived reload");
assert_eq!(e.last_seen_ms, 4242);
}
#[test]
fn corrupt_file_yields_empty_roster_not_error() {
let dir = tmpdir("corrupt");
std::fs::write(dir.join("roster.json"), b"{ this is not json").unwrap();
let store = RosterStore::load(&dir);
assert_eq!(store.list().len(), 0);
store.upsert(entry("aa", "g1"));
assert_eq!(RosterStore::load(&dir).list().len(), 1);
}
}