//! 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, } /// The persisted friends list. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct FriendStore { #[serde(default)] pub friends: Vec, } 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) -> 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 { 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 { 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 { 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()); } }