diff --git a/src/friends.rs b/src/friends.rs new file mode 100644 index 0000000..6de6100 --- /dev/null +++ b/src/friends.rs @@ -0,0 +1,258 @@ +//! 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()); + } +} diff --git a/src/lib.rs b/src/lib.rs index c8e2ec8..0c9d30f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod core; pub mod app; pub mod config; pub mod identity; +pub mod friends; pub mod theme; pub mod notify; pub mod screenshare;