The durable anchor of the friends-first model: a local JSON address book at
~/.config/peerspeak/friends.json keyed by stable EndpointId, with a locally
editable display name and a last-known address per friend.
src/friends.rs — FriendStore over Vec<Friend{id,name,last_addr}>. Pure ops:
add (explicit + idempotent; meeting someone in a room never auto-friends
them), remove, rename (local), and note_seen — the auto-heal hook that
refreshes a friend's saved address on connect, friends-only, never clobbering
a local name, and only reporting a change so callers can skip needless writes.
I/O behind a path-injectable seam (load_at/save_at, atomic tempfile+rename,
malformed = error not silent loss), JSON via serde_json (no toml dep).
7 unit tests (idempotent add, remove/contains, rename-existing-only, auto-heal
friends-only + name-preserving, save/load round-trip, missing=empty,
malformed=error). 245 lib tests green, clippy clean, release builds. Store
only — the friends-list UI + core wiring (add-from-room, note_seen on connect)
come with P5.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
259 lines
9.8 KiB
Rust
259 lines
9.8 KiB
Rust
//! Persistent friends list at `~/.config/peerspeak/friends.json` (W7 P2).
|
|
//!
|
|
//! In the friends-first contacts model (`docs/contacts-plan.md`) the friends
|
|
//! list is the durable anchor: rooms are ephemeral cosmetic labels, but a friend
|
|
//! is a stable [`EndpointId`] you keep across sessions. This module is the pure,
|
|
//! local store — no networking. Reachability (presence pings, the idle listener,
|
|
//! discovery) is built on top in later phases; here we just remember *who* your
|
|
//! friends are, a locally-editable display name, and their last-known address so
|
|
//! a future reconnect has somewhere to dial.
|
|
//!
|
|
//! Stored as JSON (peerspeak uses `serde_json` everywhere — no `toml` dep), in
|
|
//! its own file rather than a section of `config.json` so a config reset can't
|
|
//! drop the list, mirroring the separate `identity.key`.
|
|
//!
|
|
//! Design: friends are added **explicitly** (meeting someone in a room does NOT
|
|
//! auto-friend them). Once someone is a friend, [`FriendStore::note_seen`]
|
|
//! refreshes their saved address whenever you connect — the silent address
|
|
//! auto-heal — without ever clobbering a name you set locally.
|
|
|
|
use anyhow::{Context, Result};
|
|
use iroh::{EndpointAddr, EndpointId};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// One saved friend.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Friend {
|
|
/// Stable node id — the durable identity we key everything on.
|
|
pub id: EndpointId,
|
|
/// Display name — seeded from the name the peer reported, locally editable.
|
|
pub name: String,
|
|
/// Last address we successfully saw them at, refreshed on every connection
|
|
/// (the auto-heal). `None` until we've connected at least once since adding.
|
|
#[serde(default)]
|
|
pub last_addr: Option<EndpointAddr>,
|
|
}
|
|
|
|
/// The persisted friends list.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct FriendStore {
|
|
#[serde(default)]
|
|
pub friends: Vec<Friend>,
|
|
}
|
|
|
|
impl FriendStore {
|
|
/// Look up a friend by id.
|
|
pub fn get(&self, id: &EndpointId) -> Option<&Friend> {
|
|
self.friends.iter().find(|f| &f.id == id)
|
|
}
|
|
|
|
/// Whether `id` is a saved friend.
|
|
pub fn contains(&self, id: &EndpointId) -> bool {
|
|
self.friends.iter().any(|f| &f.id == id)
|
|
}
|
|
|
|
/// All friends, in stored order.
|
|
pub fn list(&self) -> &[Friend] {
|
|
&self.friends
|
|
}
|
|
|
|
/// Explicitly add a friend. Returns `false` if they were already saved (in
|
|
/// which case nothing changes — an existing local name/addr is preserved).
|
|
/// `addr` seeds `last_addr` if known at add time (e.g. you met them in a room).
|
|
pub fn add(&mut self, id: EndpointId, name: String, addr: Option<EndpointAddr>) -> bool {
|
|
if self.contains(&id) {
|
|
return false;
|
|
}
|
|
self.friends.push(Friend { id, name, last_addr: addr });
|
|
true
|
|
}
|
|
|
|
/// Remove a friend. Returns `true` if one was removed.
|
|
pub fn remove(&mut self, id: &EndpointId) -> bool {
|
|
let before = self.friends.len();
|
|
self.friends.retain(|f| &f.id != id);
|
|
self.friends.len() != before
|
|
}
|
|
|
|
/// Rename a friend locally. Returns `false` if `id` isn't a saved friend.
|
|
pub fn rename(&mut self, id: &EndpointId, new_name: String) -> bool {
|
|
match self.friends.iter_mut().find(|f| &f.id == id) {
|
|
Some(f) => {
|
|
f.name = new_name;
|
|
true
|
|
}
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// Auto-heal hook: if `id` is a saved friend, refresh their `last_addr` to the
|
|
/// address we just connected to. No-op (returns `false`) for non-friends — we
|
|
/// never auto-add. Never touches the locally-set name. Returns `true` only
|
|
/// when the stored address actually changed (so a caller can skip a needless
|
|
/// disk write on an unchanged reconnect).
|
|
pub fn note_seen(&mut self, id: &EndpointId, addr: EndpointAddr) -> bool {
|
|
match self.friends.iter_mut().find(|f| &f.id == id) {
|
|
Some(f) if f.last_addr.as_ref() != Some(&addr) => {
|
|
f.last_addr = Some(addr);
|
|
true
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns `~/.config/peerspeak/friends.json` (or the XDG equivalent). Shares the
|
|
/// config directory with [`crate::config`]; the parent is created on save.
|
|
pub fn friends_path() -> Option<PathBuf> {
|
|
dirs::config_dir().map(|mut p| {
|
|
p.push("peerspeak");
|
|
p.push("friends.json");
|
|
p
|
|
})
|
|
}
|
|
|
|
/// Load the store, or a default (empty) one if the file doesn't exist yet. A
|
|
/// *parse* error bubbles up so a hand-edit being debugged isn't silently
|
|
/// overwritten with an empty list.
|
|
pub fn load() -> Result<FriendStore> {
|
|
let path = friends_path().context("could not determine a config directory for the friends list")?;
|
|
load_at(&path)
|
|
}
|
|
|
|
/// Save the store. Atomic via tempfile-in-same-dir + rename.
|
|
pub fn save(store: &FriendStore) -> Result<()> {
|
|
let path = friends_path().context("could not determine a config directory for the friends list")?;
|
|
save_at(&path, store)
|
|
}
|
|
|
|
/// Path-injectable core of [`load`], so the round-trip is testable in a temp dir.
|
|
fn load_at(path: &Path) -> Result<FriendStore> {
|
|
match fs::read_to_string(path) {
|
|
Ok(s) => serde_json::from_str(&s)
|
|
.with_context(|| format!("failed to parse {}", path.display())),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()),
|
|
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
|
}
|
|
}
|
|
|
|
/// Path-injectable core of [`save`]. Atomic write: tempfile-in-same-dir, then
|
|
/// rename, so a crash mid-write can't leave a truncated list.
|
|
fn save_at(path: &Path, store: &FriendStore) -> Result<()> {
|
|
let parent = path.parent().context("friends path has no parent directory")?;
|
|
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
|
|
let json = serde_json::to_string_pretty(store).context("failed to encode the friends list")?;
|
|
let tmp = parent.join(format!(".friends.json.tmp.{}", std::process::id()));
|
|
fs::write(&tmp, json.as_bytes()).with_context(|| format!("failed to write {}", tmp.display()))?;
|
|
fs::rename(&tmp, path)
|
|
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use iroh::SecretKey;
|
|
|
|
fn id() -> EndpointId {
|
|
SecretKey::generate().public()
|
|
}
|
|
|
|
fn addr_for(id: EndpointId) -> EndpointAddr {
|
|
EndpointAddr::from(id)
|
|
}
|
|
|
|
#[test]
|
|
fn add_is_idempotent_and_preserves_existing() {
|
|
let mut s = FriendStore::default();
|
|
let a = id();
|
|
assert!(s.add(a, "Alice".into(), None));
|
|
// Second add returns false and does NOT overwrite the existing entry.
|
|
assert!(!s.add(a, "DIFFERENT".into(), Some(addr_for(a))));
|
|
assert_eq!(s.get(&a).unwrap().name, "Alice");
|
|
assert_eq!(s.get(&a).unwrap().last_addr, None);
|
|
assert_eq!(s.list().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_and_contains() {
|
|
let mut s = FriendStore::default();
|
|
let a = id();
|
|
s.add(a, "Alice".into(), None);
|
|
assert!(s.contains(&a));
|
|
assert!(s.remove(&a));
|
|
assert!(!s.contains(&a));
|
|
assert!(!s.remove(&a)); // already gone
|
|
}
|
|
|
|
#[test]
|
|
fn rename_only_existing() {
|
|
let mut s = FriendStore::default();
|
|
let a = id();
|
|
assert!(!s.rename(&a, "Nope".into())); // not a friend yet
|
|
s.add(a, "Alice".into(), None);
|
|
assert!(s.rename(&a, "Al".into()));
|
|
assert_eq!(s.get(&a).unwrap().name, "Al");
|
|
}
|
|
|
|
#[test]
|
|
fn note_seen_heals_addr_for_friends_only_and_keeps_name() {
|
|
let mut s = FriendStore::default();
|
|
let a = id();
|
|
let stranger = id();
|
|
s.add(a, "Alice".into(), None);
|
|
// First sighting sets the address and reports a change.
|
|
let addr1 = addr_for(a);
|
|
assert!(s.note_seen(&a, addr1.clone()));
|
|
assert_eq!(s.get(&a).unwrap().last_addr, Some(addr1.clone()));
|
|
// Local rename, then a repeat sighting at the same addr: no change, name kept.
|
|
s.rename(&a, "BestFriend".into());
|
|
assert!(!s.note_seen(&a, addr1));
|
|
assert_eq!(s.get(&a).unwrap().name, "BestFriend");
|
|
// A stranger is never auto-added.
|
|
assert!(!s.note_seen(&stranger, addr_for(stranger)));
|
|
assert!(!s.contains(&stranger));
|
|
}
|
|
|
|
/// A unique temp path; `save_at` creates the nested dir (exercises create_dir_all).
|
|
fn temp_path(tag: &str) -> PathBuf {
|
|
let mut p = std::env::temp_dir();
|
|
p.push(format!("peerspeak-friendstest-{}-{}", std::process::id(), tag));
|
|
p.push("friends.json");
|
|
p
|
|
}
|
|
|
|
#[test]
|
|
fn save_then_load_round_trips() {
|
|
let path = temp_path("roundtrip");
|
|
let _ = fs::remove_dir_all(path.parent().unwrap());
|
|
let mut s = FriendStore::default();
|
|
let a = id();
|
|
s.add(a, "Alice".into(), Some(addr_for(a)));
|
|
s.add(id(), "Bob".into(), None);
|
|
save_at(&path, &s).unwrap();
|
|
let loaded = load_at(&path).unwrap();
|
|
assert_eq!(loaded, s);
|
|
let _ = fs::remove_dir_all(path.parent().unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn missing_file_loads_empty() {
|
|
let path = temp_path("missing");
|
|
let _ = fs::remove_dir_all(path.parent().unwrap());
|
|
let loaded = load_at(&path).unwrap();
|
|
assert!(loaded.friends.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_file_is_an_error_not_silent_loss() {
|
|
let path = temp_path("malformed");
|
|
let _ = fs::remove_dir_all(path.parent().unwrap());
|
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
fs::write(&path, "{ not valid json").unwrap();
|
|
assert!(load_at(&path).is_err());
|
|
let _ = fs::remove_dir_all(path.parent().unwrap());
|
|
}
|
|
}
|