Files
peerspeak/src/friends.rs
T
molluskandClaude Opus 4.8 a7a4da3cd4 feat(friends): pure persistent friends store (W7 P2)
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>
2026-06-15 04:12:42 -04:00

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());
}
}