Skip to main content

communitas_core/
linking_service.rs

1// Copyright (c) 2025 Saorsa Labs Limited
2//
3// This file is part of the Communitas P2P collaboration platform.
4//
5// Licensed under the GPL-3.0 license
6
7//! Linking Service Module
8//!
9//! Provides functionality for linking local-only entities and contacts
10//! to network identities via four-word addresses.
11//!
12//! ## Features
13//!
14//! - Four-word address validation using dictionary
15//! - Entity linking to network identities
16//! - Contact linking to network identities
17//! - Sync status tracking
18
19use crate::entity_service::{Entity, EntityService, EntityServiceError};
20use crate::gossip::contact_storage::{
21    ContactRecord, ContactResult, ContactStorageError, ContactStore,
22};
23use crate::identity::validate_id_words;
24use crate::security::input_validation::InputValidator;
25use std::sync::Arc;
26use thiserror::Error;
27use tokio::sync::RwLock;
28
29/// Errors related to linking operations
30#[derive(Debug, Error)]
31pub enum LinkingError {
32    #[error("Invalid four-word address: {0}")]
33    InvalidFourWords(String),
34
35    #[error("Entity not found: {0}")]
36    EntityNotFound(String),
37
38    #[error("Contact not found: {0}")]
39    ContactNotFound(String),
40
41    #[error("Entity service error: {0}")]
42    EntityServiceError(#[from] EntityServiceError),
43
44    #[error("Contact storage error: {0}")]
45    ContactStorageError(#[from] ContactStorageError),
46
47    #[error("Already linked: {0}")]
48    AlreadyLinked(String),
49
50    #[error("Validation error: {0}")]
51    ValidationError(String),
52}
53
54pub type LinkingResult<T> = Result<T, LinkingError>;
55
56/// Result of a sync operation
57#[derive(Debug, Clone)]
58pub struct SyncResult {
59    /// Whether sync was successful
60    pub success: bool,
61    /// ID of the entity/contact that was synced
62    pub id: String,
63    /// Number of changes pushed to remote
64    pub changes_pushed: usize,
65    /// Number of changes pulled from remote
66    pub changes_pulled: usize,
67    /// Error message if sync failed
68    pub error: Option<String>,
69}
70
71impl SyncResult {
72    /// Create a successful sync result
73    pub fn success(id: String, pushed: usize, pulled: usize) -> Self {
74        Self {
75            success: true,
76            id,
77            changes_pushed: pushed,
78            changes_pulled: pulled,
79            error: None,
80        }
81    }
82
83    /// Create a failed sync result
84    pub fn failure(id: String, error: String) -> Self {
85        Self {
86            success: false,
87            id,
88            changes_pushed: 0,
89            changes_pulled: 0,
90            error: Some(error),
91        }
92    }
93}
94
95/// Service for linking local-only items to network identities
96pub struct LinkingService {
97    entity_service: Arc<RwLock<EntityService>>,
98    contact_store: Arc<ContactStore>,
99    validator: InputValidator,
100}
101
102impl LinkingService {
103    /// Create a new linking service
104    pub fn new(
105        entity_service: Arc<RwLock<EntityService>>,
106        contact_store: Arc<ContactStore>,
107    ) -> Self {
108        Self {
109            entity_service,
110            contact_store,
111            validator: InputValidator::new(),
112        }
113    }
114
115    /// Validate a four-word address format and dictionary membership
116    ///
117    /// # Arguments
118    /// * `four_words` - The four-word address to validate
119    ///
120    /// # Returns
121    /// Ok(normalized_address) if valid, Err(LinkingError) if invalid
122    pub fn validate_four_words(&self, four_words: &str) -> LinkingResult<String> {
123        // First validate format and sanitize
124        let normalized = self
125            .validator
126            .validate_four_words(four_words)
127            .map_err(|e| LinkingError::ValidationError(e.to_string()))?;
128
129        // Then validate dictionary membership
130        if !validate_id_words(&normalized) {
131            return Err(LinkingError::InvalidFourWords(format!(
132                "'{}' contains words not in dictionary",
133                normalized
134            )));
135        }
136
137        Ok(normalized)
138    }
139
140    /// Check if a four-word address is valid
141    pub fn is_valid_four_words(&self, four_words: &str) -> bool {
142        self.validate_four_words(four_words).is_ok()
143    }
144
145    /// Link an entity to a network identity
146    ///
147    /// # Arguments
148    /// * `entity_id` - The local entity ID
149    /// * `four_words` - The four-word network identity to link to
150    ///
151    /// # Returns
152    /// The updated entity with network identity linked
153    pub async fn link_entity(&self, entity_id: &str, four_words: &str) -> LinkingResult<Entity> {
154        // Validate the four-word address
155        let normalized = self.validate_four_words(four_words)?;
156
157        // Get the entity service and link the entity
158        let entity_service = self.entity_service.write().await;
159        let entity = entity_service
160            .link_entity_to_network(entity_id, &normalized)
161            .await?;
162
163        Ok(entity)
164    }
165
166    /// Link a contact to a network identity
167    ///
168    /// # Arguments
169    /// * `contact_id` - The local contact ID
170    /// * `four_words` - The four-word network identity to link to
171    ///
172    /// # Returns
173    /// The updated contact with network identity linked
174    pub async fn link_contact(
175        &self,
176        contact_id: &str,
177        four_words: &str,
178    ) -> LinkingResult<ContactRecord> {
179        // Validate the four-word address
180        let normalized = self.validate_four_words(four_words)?;
181
182        // Link the contact
183        let contact = self
184            .contact_store
185            .link_contact(contact_id, &normalized)
186            .await?;
187
188        Ok(contact)
189    }
190
191    /// Get all local-only entities
192    pub async fn get_local_only_entities(&self) -> LinkingResult<Vec<Entity>> {
193        let entity_service = self.entity_service.read().await;
194        let all_entities = entity_service.list_entities().await?;
195
196        Ok(all_entities
197            .into_iter()
198            .filter(|e| e.is_local_only)
199            .collect())
200    }
201
202    /// Get all network-linked entities
203    pub async fn get_linked_entities(&self) -> LinkingResult<Vec<Entity>> {
204        let entity_service = self.entity_service.read().await;
205        let all_entities = entity_service.list_entities().await?;
206
207        Ok(all_entities.into_iter().filter(|e| e.is_linked()).collect())
208    }
209
210    /// Get all local-only contacts
211    pub async fn get_local_only_contacts(&self) -> Vec<ContactRecord> {
212        self.contact_store.local_only().await
213    }
214
215    /// Get all network-linked contacts
216    pub async fn get_linked_contacts(&self) -> Vec<ContactRecord> {
217        self.contact_store.network_linked().await
218    }
219
220    /// Mark an entity as synced
221    pub async fn mark_entity_synced(&self, entity_id: &str) -> LinkingResult<Entity> {
222        let entity_service = self.entity_service.write().await;
223        let entity = entity_service.mark_entity_synced(entity_id).await?;
224        Ok(entity)
225    }
226
227    /// Mark a contact as synced
228    pub async fn mark_contact_synced(&self, contact_id: &str) -> LinkingResult<ContactRecord> {
229        let contact = self
230            .contact_store
231            .get_by_id(contact_id)
232            .await
233            .ok_or_else(|| LinkingError::ContactNotFound(contact_id.to_string()))?;
234
235        // Update the contact with sync timestamp
236        let mut updated = contact;
237        updated.mark_synced();
238
239        // Re-add to store (replaces existing)
240        // Note: This is a bit awkward - ideally we'd have an update method
241        // For now, we just update the record in place
242        let contacts = self.contact_store.all().await;
243        self.contact_store.clear().await;
244        for c in contacts {
245            if c.id == contact_id {
246                let _ = self.contact_store.add(updated.clone()).await;
247            } else {
248                let _ = self.contact_store.add(c).await;
249            }
250        }
251
252        Ok(updated)
253    }
254
255    /// Create a local-only entity
256    pub async fn create_local_entity(
257        &self,
258        name: String,
259        entity_type: crate::legacy_crdt::EntityType,
260        description: Option<String>,
261        created_by: String,
262    ) -> LinkingResult<Entity> {
263        let entity_service = self.entity_service.write().await;
264        let entity = entity_service
265            .create_local_entity(name, entity_type, description, created_by)
266            .await?;
267        Ok(entity)
268    }
269
270    /// Create a local-only contact
271    pub async fn create_local_contact(&self, display_name: String) -> ContactResult<ContactRecord> {
272        let contact = ContactRecord::new_local(display_name);
273        self.contact_store.add(contact.clone()).await?;
274        Ok(contact)
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::crdt_manager::CrdtManager;
282    use crate::legacy_crdt::EntityType;
283    use tempfile::TempDir;
284
285    async fn create_test_service() -> (LinkingService, TempDir) {
286        let temp_dir = TempDir::new().unwrap();
287        let data_dir = temp_dir.path().to_path_buf();
288
289        let crdt_manager = Arc::new(CrdtManager::new(data_dir.clone()).await.unwrap());
290        let entity_service = Arc::new(RwLock::new(EntityService::new(crdt_manager)));
291        let contact_store = Arc::new(ContactStore::new());
292
293        let service = LinkingService::new(entity_service, contact_store);
294        (service, temp_dir)
295    }
296
297    async fn create_simple_test_service() -> LinkingService {
298        let temp_dir = TempDir::new().unwrap();
299        let data_dir = temp_dir.path().to_path_buf();
300
301        let crdt_manager = Arc::new(CrdtManager::new(data_dir).await.unwrap());
302        let entity_service = Arc::new(RwLock::new(EntityService::new(crdt_manager)));
303        let contact_store = Arc::new(ContactStore::new());
304
305        LinkingService::new(entity_service, contact_store)
306    }
307
308    #[tokio::test]
309    async fn test_validate_four_words_format() {
310        let service = create_simple_test_service().await;
311
312        // Valid format but may not be in dictionary
313        let _result = service.validate_four_words("hello-world-test-network");
314        // This depends on dictionary - may or may not be valid
315        // For format testing, we just check the sanitization works
316        assert!(
317            service
318                .validator
319                .validate_four_words("hello-world-test-network")
320                .is_ok()
321        );
322
323        // Invalid formats
324        assert!(service.validate_four_words("only-three-words").is_err());
325        assert!(service.validate_four_words("").is_err());
326        assert!(
327            service
328                .validate_four_words("too-many-words-here-now")
329                .is_err()
330        );
331    }
332
333    #[tokio::test]
334    async fn test_create_local_contact() {
335        let (service, _temp_dir) = create_test_service().await;
336
337        let contact = service
338            .create_local_contact("Alice".to_string())
339            .await
340            .unwrap();
341
342        assert!(contact.is_local_only);
343        assert!(contact.four_words.is_none());
344        assert_eq!(contact.display_name, Some("Alice".to_string()));
345        assert_eq!(contact.effective_name(), "Alice");
346    }
347
348    #[tokio::test]
349    async fn test_get_local_only_contacts() {
350        let (service, _temp_dir) = create_test_service().await;
351
352        // Create some contacts
353        service
354            .create_local_contact("Alice".to_string())
355            .await
356            .unwrap();
357        service
358            .create_local_contact("Bob".to_string())
359            .await
360            .unwrap();
361
362        // Add a network-linked contact directly
363        let linked = ContactRecord::new("ocean-forest-moon-star".to_string());
364        service.contact_store.add(linked).await.unwrap();
365
366        // Get local-only contacts
367        let local_only = service.get_local_only_contacts().await;
368        assert_eq!(local_only.len(), 2);
369
370        // Get linked contacts
371        let linked = service.get_linked_contacts().await;
372        assert_eq!(linked.len(), 1);
373    }
374
375    #[tokio::test]
376    async fn test_create_local_entity() {
377        let (service, _temp_dir) = create_test_service().await;
378
379        let entity = service
380            .create_local_entity(
381                "Test Org".to_string(),
382                EntityType::Organisation,
383                Some("A test organisation".to_string()),
384                "creator-id".to_string(),
385            )
386            .await
387            .unwrap();
388
389        assert!(entity.is_local_only);
390        assert!(entity.network_four_words.is_none());
391        assert_eq!(entity.name, "Test Org");
392        assert!(!entity.is_linked());
393    }
394}