use mdcs_core::lattice::Lattice;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct UserId(pub String);
impl UserId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
impl std::fmt::Display for UserId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cursor {
pub position: usize,
pub anchor: Option<usize>,
}
impl Cursor {
pub fn at(position: usize) -> Self {
Self {
position,
anchor: None,
}
}
pub fn with_selection(anchor: usize, position: usize) -> Self {
Self {
position,
anchor: Some(anchor),
}
}
pub fn has_selection(&self) -> bool {
self.anchor.is_some() && self.anchor != Some(self.position)
}
pub fn selection_range(&self) -> Option<(usize, usize)> {
self.anchor.map(|anchor| {
if anchor < self.position {
(anchor, self.position)
} else {
(self.position, anchor)
}
})
}
pub fn selection_length(&self) -> usize {
self.selection_range()
.map(|(start, end)| end - start)
.unwrap_or(0)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum UserStatus {
#[default]
Online,
Idle,
Typing,
Away,
Offline,
Custom(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserInfo {
pub name: String,
pub color: String,
pub avatar: Option<String>,
}
impl UserInfo {
pub fn new(name: impl Into<String>, color: impl Into<String>) -> Self {
Self {
name: name.into(),
color: color.into(),
avatar: None,
}
}
pub fn with_avatar(mut self, avatar: impl Into<String>) -> Self {
self.avatar = Some(avatar.into());
self
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UserPresence {
pub user_id: UserId,
pub info: UserInfo,
pub status: UserStatus,
pub cursors: HashMap<String, Cursor>,
pub state: HashMap<String, String>,
pub last_updated: u64,
pub timestamp: u64,
}
impl UserPresence {
pub fn new(user_id: UserId, info: UserInfo) -> Self {
Self {
user_id,
info,
status: UserStatus::Online,
cursors: HashMap::new(),
state: HashMap::new(),
last_updated: now_millis(),
timestamp: 0,
}
}
pub fn set_cursor(&mut self, document_id: impl Into<String>, cursor: Cursor) {
self.cursors.insert(document_id.into(), cursor);
self.touch();
}
pub fn remove_cursor(&mut self, document_id: &str) {
self.cursors.remove(document_id);
self.touch();
}
pub fn get_cursor(&self, document_id: &str) -> Option<&Cursor> {
self.cursors.get(document_id)
}
pub fn set_status(&mut self, status: UserStatus) {
self.status = status;
self.touch();
}
pub fn set_state(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.state.insert(key.into(), value.into());
self.touch();
}
pub fn get_state(&self, key: &str) -> Option<&String> {
self.state.get(key)
}
fn touch(&mut self) {
self.last_updated = now_millis();
self.timestamp += 1;
}
pub fn is_stale(&self, timeout_ms: u64) -> bool {
let now = now_millis();
now.saturating_sub(self.last_updated) > timeout_ms
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PresenceDelta {
pub updates: Vec<UserPresence>,
pub removals: Vec<UserId>,
}
impl PresenceDelta {
pub fn new() -> Self {
Self {
updates: Vec::new(),
removals: Vec::new(),
}
}
pub fn is_empty(&self) -> bool {
self.updates.is_empty() && self.removals.is_empty()
}
}
impl Default for PresenceDelta {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct PresenceTracker {
local_user: UserId,
users: HashMap<UserId, UserPresence>,
stale_timeout: u64,
pending_delta: Option<PresenceDelta>,
}
impl PresenceTracker {
pub fn new(local_user: UserId, info: UserInfo) -> Self {
let mut tracker = Self {
local_user: local_user.clone(),
users: HashMap::new(),
stale_timeout: 30_000, pending_delta: None,
};
let presence = UserPresence::new(local_user, info);
tracker.users.insert(presence.user_id.clone(), presence);
tracker
}
pub fn local_user(&self) -> &UserId {
&self.local_user
}
pub fn set_stale_timeout(&mut self, timeout_ms: u64) {
self.stale_timeout = timeout_ms;
}
pub fn local_presence(&self) -> Option<&UserPresence> {
self.users.get(&self.local_user)
}
pub fn set_cursor(&mut self, document_id: impl Into<String>, cursor: Cursor) {
let doc_id = document_id.into();
let local_user = self.local_user.clone();
if let Some(presence) = self.users.get_mut(&local_user) {
presence.set_cursor(&doc_id, cursor);
let presence_clone = presence.clone();
let delta = self.pending_delta.get_or_insert_with(PresenceDelta::new);
delta.updates.push(presence_clone);
}
}
pub fn remove_cursor(&mut self, document_id: &str) {
let local_user = self.local_user.clone();
if let Some(presence) = self.users.get_mut(&local_user) {
presence.remove_cursor(document_id);
let presence_clone = presence.clone();
let delta = self.pending_delta.get_or_insert_with(PresenceDelta::new);
delta.updates.push(presence_clone);
}
}
pub fn set_status(&mut self, status: UserStatus) {
let local_user = self.local_user.clone();
if let Some(presence) = self.users.get_mut(&local_user) {
presence.set_status(status);
let presence_clone = presence.clone();
let delta = self.pending_delta.get_or_insert_with(PresenceDelta::new);
delta.updates.push(presence_clone);
}
}
pub fn set_state(&mut self, key: impl Into<String>, value: impl Into<String>) {
let local_user = self.local_user.clone();
if let Some(presence) = self.users.get_mut(&local_user) {
presence.set_state(key, value);
let presence_clone = presence.clone();
let delta = self.pending_delta.get_or_insert_with(PresenceDelta::new);
delta.updates.push(presence_clone);
}
}
pub fn heartbeat(&mut self) {
let local_user = self.local_user.clone();
if let Some(presence) = self.users.get_mut(&local_user) {
presence.touch();
let presence_clone = presence.clone();
let delta = self.pending_delta.get_or_insert_with(PresenceDelta::new);
delta.updates.push(presence_clone);
}
}
pub fn get_user(&self, user_id: &UserId) -> Option<&UserPresence> {
self.users.get(user_id)
}
pub fn all_users(&self) -> impl Iterator<Item = &UserPresence> + '_ {
self.users.values()
}
pub fn online_users(&self) -> impl Iterator<Item = &UserPresence> + '_ {
self.users
.values()
.filter(|p| !p.is_stale(self.stale_timeout) && !matches!(p.status, UserStatus::Offline))
}
pub fn users_in_document(&self, document_id: &str) -> Vec<&UserPresence> {
self.online_users()
.filter(|p| p.cursors.contains_key(document_id))
.collect()
}
pub fn cursors_in_document(&self, document_id: &str) -> Vec<(&UserPresence, &Cursor)> {
self.online_users()
.filter(|p| p.user_id != self.local_user)
.filter_map(|p| p.get_cursor(document_id).map(|c| (p, c)))
.collect()
}
pub fn online_count(&self) -> usize {
self.online_users().count()
}
pub fn take_delta(&mut self) -> Option<PresenceDelta> {
self.pending_delta.take()
}
pub fn apply_delta(&mut self, delta: &PresenceDelta) {
for presence in &delta.updates {
if let Some(existing) = self.users.get(&presence.user_id) {
if presence.timestamp <= existing.timestamp {
continue;
}
}
self.users
.insert(presence.user_id.clone(), presence.clone());
}
for user_id in &delta.removals {
if *user_id != self.local_user {
self.users.remove(user_id);
}
}
}
pub fn cleanup_stale(&mut self) -> Vec<UserId> {
let stale: Vec<_> = self
.users
.iter()
.filter(|(id, p)| *id != &self.local_user && p.is_stale(self.stale_timeout))
.map(|(id, _)| id.clone())
.collect();
for id in &stale {
self.users.remove(id);
}
if !stale.is_empty() {
let delta = self.pending_delta.get_or_insert_with(PresenceDelta::new);
delta.removals.extend(stale.clone());
}
stale
}
pub fn leave(&mut self) {
let delta = self.pending_delta.get_or_insert_with(PresenceDelta::new);
delta.removals.push(self.local_user.clone());
}
}
impl Lattice for PresenceTracker {
fn bottom() -> Self {
Self {
local_user: UserId::new(""),
users: HashMap::new(),
stale_timeout: 30_000,
pending_delta: None,
}
}
fn join(&self, other: &Self) -> Self {
let mut result = self.clone();
for (user_id, other_presence) in &other.users {
result
.users
.entry(user_id.clone())
.and_modify(|p| {
if other_presence.timestamp > p.timestamp {
*p = other_presence.clone();
}
})
.or_insert_with(|| other_presence.clone());
}
result
}
}
fn now_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
pub struct CursorBuilder {
document_id: String,
}
impl CursorBuilder {
pub fn for_document(id: impl Into<String>) -> Self {
Self {
document_id: id.into(),
}
}
pub fn at(self, position: usize) -> (String, Cursor) {
(self.document_id, Cursor::at(position))
}
pub fn selection(self, anchor: usize, head: usize) -> (String, Cursor) {
(self.document_id, Cursor::with_selection(anchor, head))
}
}
pub struct CursorColors;
impl CursorColors {
pub const COLORS: [&'static str; 12] = [
"#E91E63", "#9C27B0", "#3F51B5", "#2196F3", "#00BCD4", "#009688", "#4CAF50", "#8BC34A", "#CDDC39", "#FF9800", "#FF5722", "#795548", ];
pub fn color_for_user(user_id: &UserId) -> &'static str {
let hash: usize = user_id.0.bytes().map(|b| b as usize).sum();
Self::COLORS[hash % Self::COLORS.len()]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cursor_creation() {
let cursor = Cursor::at(10);
assert_eq!(cursor.position, 10);
assert!(!cursor.has_selection());
let selection = Cursor::with_selection(5, 15);
assert!(selection.has_selection());
assert_eq!(selection.selection_range(), Some((5, 15)));
assert_eq!(selection.selection_length(), 10);
}
#[test]
fn test_cursor_selection_backwards() {
let selection = Cursor::with_selection(15, 5);
assert_eq!(selection.selection_range(), Some((5, 15)));
assert_eq!(selection.selection_length(), 10);
}
#[test]
fn test_presence_tracker() {
let user_id = UserId::new("user1");
let info = UserInfo::new("Alice", "#E91E63");
let tracker = PresenceTracker::new(user_id.clone(), info);
assert_eq!(tracker.local_user(), &user_id);
assert!(tracker.local_presence().is_some());
}
#[test]
fn test_cursor_tracking() {
let user_id = UserId::new("user1");
let info = UserInfo::new("Alice", "#E91E63");
let mut tracker = PresenceTracker::new(user_id, info);
tracker.set_cursor("doc1", Cursor::at(42));
let presence = tracker.local_presence().unwrap();
let cursor = presence.get_cursor("doc1").unwrap();
assert_eq!(cursor.position, 42);
}
#[test]
fn test_status_changes() {
let user_id = UserId::new("user1");
let info = UserInfo::new("Alice", "#E91E63");
let mut tracker = PresenceTracker::new(user_id, info);
tracker.set_status(UserStatus::Typing);
let presence = tracker.local_presence().unwrap();
assert_eq!(presence.status, UserStatus::Typing);
}
#[test]
fn test_presence_sync() {
let user1 = UserId::new("user1");
let user2 = UserId::new("user2");
let mut tracker1 = PresenceTracker::new(user1.clone(), UserInfo::new("Alice", "#E91E63"));
let mut tracker2 = PresenceTracker::new(user2.clone(), UserInfo::new("Bob", "#2196F3"));
tracker1.set_cursor("doc1", Cursor::at(10));
let delta = tracker1.take_delta().unwrap();
tracker2.apply_delta(&delta);
let users = tracker2.users_in_document("doc1");
assert_eq!(users.len(), 1);
assert_eq!(users[0].user_id, user1);
}
#[test]
fn test_multiple_users() {
let user1 = UserId::new("user1");
let info1 = UserInfo::new("Alice", "#E91E63");
let mut tracker = PresenceTracker::new(user1.clone(), info1);
let user2 = UserId::new("user2");
let presence2 = UserPresence::new(user2.clone(), UserInfo::new("Bob", "#2196F3"));
tracker.users.insert(user2.clone(), presence2);
let user3 = UserId::new("user3");
let presence3 = UserPresence::new(user3.clone(), UserInfo::new("Charlie", "#4CAF50"));
tracker.users.insert(user3.clone(), presence3);
assert_eq!(tracker.online_count(), 3);
}
#[test]
fn test_cursors_in_document() {
let user1 = UserId::new("user1");
let info1 = UserInfo::new("Alice", "#E91E63");
let mut tracker = PresenceTracker::new(user1, info1);
let user2 = UserId::new("user2");
let mut presence2 = UserPresence::new(user2.clone(), UserInfo::new("Bob", "#2196F3"));
presence2.set_cursor("doc1", Cursor::at(50));
tracker.users.insert(user2, presence2);
let cursors = tracker.cursors_in_document("doc1");
assert_eq!(cursors.len(), 1);
assert_eq!(cursors[0].1.position, 50);
}
#[test]
fn test_color_assignment() {
let user1 = UserId::new("alice");
let user2 = UserId::new("bob");
let color1 = CursorColors::color_for_user(&user1);
let color2 = CursorColors::color_for_user(&user2);
assert!(CursorColors::COLORS.contains(&color1));
assert!(CursorColors::COLORS.contains(&color2));
assert_eq!(color1, CursorColors::color_for_user(&user1));
}
#[test]
fn test_custom_state() {
let user_id = UserId::new("user1");
let info = UserInfo::new("Alice", "#E91E63");
let mut tracker = PresenceTracker::new(user_id, info);
tracker.set_state("view", "editor");
tracker.set_state("zoom", "100%");
let presence = tracker.local_presence().unwrap();
assert_eq!(presence.get_state("view"), Some(&"editor".to_string()));
assert_eq!(presence.get_state("zoom"), Some(&"100%".to_string()));
}
#[test]
fn test_cursor_builder() {
let (doc, cursor) = CursorBuilder::for_document("doc1").at(42);
assert_eq!(doc, "doc1");
assert_eq!(cursor.position, 42);
let (doc, cursor) = CursorBuilder::for_document("doc2").selection(10, 20);
assert_eq!(doc, "doc2");
assert_eq!(cursor.selection_range(), Some((10, 20)));
}
}