use domain::{ errors::DomainError, events::DomainEvent, models::thought::{Thought, Visibility}, ports::{EventPublisher, TagRepository, ThoughtRepository, UserRepository}, value_objects::{Content, ThoughtId, UserId}, }; fn extract_hashtags(content: &str) -> Vec { let mut tags = Vec::new(); let mut chars = content.char_indices().peekable(); while let Some((_, c)) = chars.next() { if c == '#' && chars .peek() .map(|(_, nc)| nc.is_alphanumeric()) .unwrap_or(false) { let tag: String = chars .by_ref() .take_while(|(_, nc)| nc.is_alphanumeric() || *nc == '_') .map(|(_, nc)| nc) .collect(); if !tag.is_empty() { tags.push(tag.to_lowercase()); } } } tags.dedup(); tags } fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> { if thought.user_id != *user_id { return Err(DomainError::NotFound); } Ok(()) } pub struct CreateThoughtInput { pub user_id: UserId, pub content: String, pub in_reply_to_id: Option, pub visibility: Option, pub content_warning: Option, pub sensitive: bool, } pub struct CreateThoughtOutput { pub thought: Thought, } pub async fn create_thought( thoughts: &dyn ThoughtRepository, _users: &dyn UserRepository, tags: &dyn TagRepository, events: &dyn EventPublisher, input: CreateThoughtInput, ) -> Result { let content = Content::new_local(input.content)?; let visibility = input .visibility .as_deref() .map(Visibility::from_db_str) .unwrap_or(Visibility::Public); let thought = Thought::new_local( ThoughtId::new(), input.user_id, content.clone(), input.in_reply_to_id.clone(), visibility, input.content_warning, input.sensitive, ); thoughts.save(&thought).await?; // Extract and attach hashtags from content. for tag_name in extract_hashtags(content.as_str()) { if let Ok(tag) = tags.find_or_create(&tag_name).await { let _ = tags.attach_to_thought(&thought.id, tag.id).await; } } events .publish(&DomainEvent::ThoughtCreated { thought_id: thought.id.clone(), user_id: thought.user_id.clone(), in_reply_to_id: input.in_reply_to_id, }) .await?; Ok(CreateThoughtOutput { thought }) } pub async fn delete_thought( thoughts: &dyn ThoughtRepository, events: &dyn EventPublisher, id: &ThoughtId, user_id: &UserId, ) -> Result<(), DomainError> { let thought = thoughts .find_by_id(id) .await? .ok_or(DomainError::NotFound)?; require_owner(&thought, user_id)?; thoughts.delete(id, user_id).await?; events .publish(&DomainEvent::ThoughtDeleted { thought_id: id.clone(), user_id: user_id.clone(), }) .await?; Ok(()) } pub async fn edit_thought( thoughts: &dyn ThoughtRepository, events: &dyn EventPublisher, id: &ThoughtId, user_id: &UserId, new_content: String, ) -> Result<(), DomainError> { let thought = thoughts .find_by_id(id) .await? .ok_or(DomainError::NotFound)?; require_owner(&thought, user_id)?; let content = Content::new_local(new_content)?; thoughts.update_content(id, &content).await?; events .publish(&DomainEvent::ThoughtUpdated { thought_id: id.clone(), user_id: user_id.clone(), }) .await?; Ok(()) } pub async fn get_thought( thoughts: &dyn ThoughtRepository, id: &ThoughtId, ) -> Result { thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound) } pub async fn get_thread( thoughts: &dyn ThoughtRepository, id: &ThoughtId, ) -> Result, DomainError> { thoughts.get_thread(id).await } #[cfg(test)] mod tests { use super::*; use domain::{ models::user::User, testing::{NoOpEventPublisher, TestStore}, value_objects::*, }; fn user() -> User { User::new_local( UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()), ) } fn input(uid: UserId) -> CreateThoughtInput { CreateThoughtInput { user_id: uid, content: "hello".into(), in_reply_to_id: None, visibility: None, content_warning: None, sensitive: false, } } #[tokio::test] async fn create_thought_saves_and_emits_event() { let store = TestStore::default(); let u = user(); store.users.lock().unwrap().push(u.clone()); let out = create_thought(&store, &store, &store, &store, input(u.id.clone())) .await .unwrap(); assert_eq!(out.thought.content.as_str(), "hello"); assert_eq!(store.events.lock().unwrap().len(), 1); } #[tokio::test] async fn delete_own_thought_succeeds() { let store = TestStore::default(); let u = user(); store.users.lock().unwrap().push(u.clone()); let out = create_thought( &store, &store, &store, &NoOpEventPublisher, input(u.id.clone()), ) .await .unwrap(); delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &u.id) .await .unwrap(); assert!(store.thoughts.lock().unwrap().is_empty()); } #[tokio::test] async fn delete_other_thought_returns_not_found() { let store = TestStore::default(); let alice = user(); let bob = User::new_local( UserId::new(), Username::new("bob").unwrap(), Email::new("bob@ex.com").unwrap(), PasswordHash("h".into()), ); store .users .lock() .unwrap() .extend([alice.clone(), bob.clone()]); let out = create_thought( &store, &store, &store, &NoOpEventPublisher, input(alice.id.clone()), ) .await .unwrap(); let err = delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &bob.id) .await .unwrap_err(); assert!(matches!(err, DomainError::NotFound)); } #[tokio::test] async fn edit_thought_changes_content_and_emits_event() { let store = TestStore::default(); let alice = user(); store.users.lock().unwrap().push(alice.clone()); let out = create_thought(&store, &store, &store, &store, input(alice.id.clone())) .await .unwrap(); let tid = out.thought.id.clone(); edit_thought(&store, &store, &tid, &alice.id, "updated".to_string()) .await .unwrap(); let saved = store .thoughts .lock() .unwrap() .iter() .find(|t| t.id == tid) .unwrap() .clone(); assert_eq!(saved.content.as_str(), "updated"); let events = store.events.lock().unwrap(); assert!(events.iter().any( |e| matches!(e, DomainEvent::ThoughtUpdated { thought_id, .. } if thought_id == &tid) )); } #[tokio::test] async fn create_reply_sets_in_reply_to_id() { let store = TestStore::default(); let alice = user(); store.users.lock().unwrap().push(alice.clone()); let original = create_thought( &store, &store, &store, &NoOpEventPublisher, input(alice.id.clone()), ) .await .unwrap() .thought; create_thought( &store, &store, &store, &NoOpEventPublisher, CreateThoughtInput { user_id: alice.id.clone(), content: "reply".into(), in_reply_to_id: Some(original.id.clone()), visibility: None, content_warning: None, sensitive: false, }, ) .await .unwrap(); let thoughts = store.thoughts.lock().unwrap(); let reply = thoughts .iter() .find(|t| t.content.as_str() == "reply") .unwrap(); assert_eq!(reply.in_reply_to_id, Some(original.id.clone())); } }