From 19171806b95f2897add17f192b91b352b1ab2f77 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 13 May 2026 23:38:57 +0200 Subject: [PATCH] fmt --- .../activitypub-base/src/activities.rs | 12 +- .../adapters/activitypub-base/src/actors.rs | 18 +- .../adapters/activitypub-base/src/nodeinfo.rs | 4 +- .../adapters/activitypub-base/src/outbox.rs | 11 +- .../adapters/activitypub-base/src/service.rs | 98 ++++-- .../activitypub-base/src/tests/actors.rs | 10 +- .../activitypub-base/src/tests/nodeinfo.rs | 5 +- .../activitypub-base/src/tests/service.rs | 14 +- .../activitypub/src/composite_handler.rs | 4 +- .../adapters/activitypub/src/event_handler.rs | 34 +- crates/adapters/activitypub/src/lib.rs | 24 +- crates/adapters/activitypub/src/objects.rs | 6 +- .../activitypub/src/review_handler.rs | 12 +- .../adapters/activitypub/src/tests/objects.rs | 5 +- crates/adapters/activitypub/src/urls.rs | 7 +- .../adapters/activitypub/src/user_adapter.rs | 39 ++- crates/adapters/event-payload/src/lib.rs | 260 ++++++++------- .../adapters/event-payload/src/tests/lib.rs | 27 +- crates/adapters/event-publisher/src/lib.rs | 8 +- .../adapters/event-publisher/src/tests/lib.rs | 5 +- crates/adapters/export/src/tests/lib.rs | 4 +- .../adapters/image-converter/src/backfill.rs | 8 +- .../adapters/image-converter/src/handler.rs | 13 +- crates/adapters/image-converter/src/lib.rs | 4 +- .../image-converter/src/tests/backfill.rs | 22 +- .../image-converter/src/tests/config.rs | 12 +- .../image-converter/src/tests/handler.rs | 69 +++- crates/adapters/image-storage/src/lib.rs | 9 +- .../adapters/image-storage/src/tests/lib.rs | 10 +- crates/adapters/importer/src/lib.rs | 8 +- crates/adapters/importer/src/mapper.rs | 31 +- crates/adapters/importer/src/parsers/csv.rs | 13 +- crates/adapters/importer/src/parsers/json.rs | 21 +- crates/adapters/importer/src/parsers/xlsx.rs | 19 +- crates/adapters/importer/src/tests/mapper.rs | 76 ++++- crates/adapters/metadata/src/tmdb.rs | 20 +- crates/adapters/nats/src/config.rs | 12 +- crates/adapters/nats/src/publisher.rs | 12 +- crates/adapters/nats/src/subject.rs | 16 +- crates/adapters/nats/src/tests/config.rs | 7 +- crates/adapters/nats/src/tests/subject.rs | 4 +- crates/adapters/poster-fetcher/src/lib.rs | 4 +- crates/adapters/poster-sync/src/lib.rs | 60 +++- .../adapters/postgres-event-queue/src/lib.rs | 78 +++-- .../adapters/postgres-federation/src/lib.rs | 274 ++++++++++------ crates/adapters/postgres-search/src/lib.rs | 116 +++++-- crates/adapters/postgres/src/image_ref.rs | 30 +- .../adapters/postgres/src/import_profile.rs | 99 ++++-- .../adapters/postgres/src/import_session.rs | 120 +++++-- crates/adapters/postgres/src/lib.rs | 49 +-- crates/adapters/postgres/src/persons.rs | 38 ++- crates/adapters/postgres/src/profile.rs | 71 +++- .../adapters/postgres/src/profile_fields.rs | 4 +- crates/adapters/postgres/src/users.rs | 20 +- crates/adapters/postgres/src/watchlist.rs | 94 ++++-- crates/adapters/sqlite-event-queue/src/lib.rs | 92 +++--- crates/adapters/sqlite-federation/src/lib.rs | 129 +++++--- .../src/tests/actor_block_tests.rs | 24 +- .../src/tests/domain_block_tests.rs | 8 +- .../sqlite-federation/src/tests/lib.rs | 27 +- crates/adapters/sqlite-search/src/lib.rs | 133 +++++--- .../adapters/sqlite-search/src/tests/lib.rs | 158 ++++++--- crates/adapters/sqlite/src/image_ref.rs | 30 +- crates/adapters/sqlite/src/import_profile.rs | 75 +++-- crates/adapters/sqlite/src/import_session.rs | 125 +++++-- crates/adapters/sqlite/src/lib.rs | 59 ++-- crates/adapters/sqlite/src/models.rs | 5 +- crates/adapters/sqlite/src/persons.rs | 38 ++- crates/adapters/sqlite/src/profile.rs | 95 ++++-- crates/adapters/sqlite/src/profile_fields.rs | 18 +- crates/adapters/sqlite/src/tests/image_ref.rs | 29 +- crates/adapters/sqlite/src/tests/persons.rs | 21 +- crates/adapters/sqlite/src/tests/users.rs | 14 +- crates/adapters/sqlite/src/users.rs | 11 +- crates/adapters/sqlite/src/watchlist.rs | 51 ++- crates/adapters/template-askama/src/lib.rs | 18 +- crates/adapters/tmdb-enrichment/src/lib.rs | 158 ++++++--- crates/application/src/context.rs | 16 +- crates/application/src/jobs.rs | 6 +- crates/application/src/lib.rs | 8 +- .../src/movie_discovery_indexer.rs | 15 +- crates/application/src/movie_resolver.rs | 24 +- crates/application/src/ports.rs | 6 +- crates/application/src/search_cleanup.rs | 17 +- .../application/src/tests/movie_resolver.rs | 61 +++- .../src/use_cases/add_to_watchlist.rs | 30 +- .../src/use_cases/apply_import_mapping.rs | 38 ++- .../src/use_cases/apply_import_profile.rs | 17 +- .../cleanup_expired_import_sessions.rs | 2 +- .../src/use_cases/create_import_session.rs | 18 +- .../src/use_cases/delete_import_profile.rs | 8 +- .../src/use_cases/delete_review.rs | 8 +- .../application/src/use_cases/enrich_movie.rs | 4 +- .../src/use_cases/execute_import.rs | 51 +-- .../src/use_cases/get_movie_social_page.rs | 12 +- .../application/src/use_cases/get_movies.rs | 5 +- .../application/src/use_cases/get_person.rs | 5 +- .../src/use_cases/get_person_credits.rs | 5 +- .../src/use_cases/get_remote_watchlist.rs | 9 +- .../src/use_cases/get_watchlist.rs | 5 +- .../src/use_cases/list_import_profiles.rs | 7 +- .../application/src/use_cases/log_review.rs | 29 +- crates/application/src/use_cases/mod.rs | 18 +- .../src/use_cases/remove_from_watchlist.rs | 8 +- .../src/use_cases/save_import_profile.rs | 32 +- crates/application/src/use_cases/search.rs | 5 +- .../application/src/use_cases/sync_poster.rs | 17 +- .../src/use_cases/update_profile.rs | 38 ++- .../src/use_cases/update_profile_fields.rs | 18 +- crates/domain/src/models/import.rs | 5 +- crates/domain/src/models/import_profile.rs | 10 +- crates/domain/src/models/import_session.rs | 2 +- crates/domain/src/models/mod.rs | 24 +- crates/domain/src/models/person.rs | 8 +- crates/domain/src/models/tests.rs | 14 +- crates/domain/src/ports.rs | 59 ++-- crates/domain/src/value_objects.rs | 26 +- crates/presentation/src/forms.rs | 5 +- crates/presentation/src/handlers/api.rs | 296 +++++++++++------ crates/presentation/src/handlers/html.rs | 266 +++++++++------ crates/presentation/src/handlers/import.rs | 25 +- crates/presentation/src/handlers/mod.rs | 4 +- crates/presentation/src/lib.rs | 2 +- crates/presentation/src/main.rs | 162 ++++++--- crates/presentation/src/openapi/auth.rs | 7 +- crates/presentation/src/openapi/diary.rs | 6 +- crates/presentation/src/openapi/import.rs | 2 +- crates/presentation/src/openapi/social.rs | 2 +- crates/presentation/src/openapi/users.rs | 6 +- crates/presentation/src/openapi/watchlist.rs | 4 +- crates/presentation/src/routes.rs | 124 +++++-- crates/presentation/src/tests/api_handlers.rs | 39 ++- crates/presentation/src/tests/extractors.rs | 309 ++++++++++++++---- crates/presentation/tests/api_test.rs | 241 +++++++++++--- crates/tui/src/app.rs | 236 +++++++------ crates/tui/src/client.rs | 26 +- crates/tui/src/main.rs | 54 +-- crates/tui/src/tests/client.rs | 10 +- crates/worker/src/db.rs | 81 +++-- crates/worker/src/event_bus.rs | 12 +- crates/worker/src/follow_backfill_handler.rs | 7 +- crates/worker/src/main.rs | 181 ++++++---- 142 files changed, 4140 insertions(+), 2025 deletions(-) diff --git a/crates/adapters/activitypub-base/src/activities.rs b/crates/adapters/activitypub-base/src/activities.rs index 4ee061b..eb084ed 100644 --- a/crates/adapters/activitypub-base/src/activities.rs +++ b/crates/adapters/activitypub-base/src/activities.rs @@ -72,7 +72,8 @@ impl Activity for FollowActivity { let _follower = self.actor.dereference(data).await?; let local_actor = self.object.dereference(data).await?; - if data.federation_repo + if data + .federation_repo .is_actor_blocked(local_actor.user_id, self.actor.inner().as_str()) .await? { @@ -246,7 +247,11 @@ impl Activity for UndoActivity { return Ok(()); } - let obj_type = self.object.get("type").and_then(|t| t.as_str()).unwrap_or(""); + let obj_type = self + .object + .get("type") + .and_then(|t| t.as_str()) + .unwrap_or(""); match obj_type { "Follow" => { @@ -266,7 +271,8 @@ impl Activity for UndoActivity { tracing::info!(actor = %self.actor.inner(), "unfollowed"); } "Add" => { - let ap_id_str = self.object + let ap_id_str = self + .object .get("object") .and_then(|o| o.get("id")) .and_then(|id| id.as_str()) diff --git a/crates/adapters/activitypub-base/src/actors.rs b/crates/adapters/activitypub-base/src/actors.rs index befedc0..01cd40d 100644 --- a/crates/adapters/activitypub-base/src/actors.rs +++ b/crates/adapters/activitypub-base/src/actors.rs @@ -222,14 +222,18 @@ impl Object for DbActor { }); let profile_url = self.profile_url; let also_known_as: Vec = self.also_known_as.into_iter().collect(); - let attachment: Vec = self.attachment.into_iter().map(|f| ProfileFieldObject { - kind: "PropertyValue".to_string(), - name: f.name, - value: f.value, - }).collect(); + let attachment: Vec = self + .attachment + .into_iter() + .map(|f| ProfileFieldObject { + kind: "PropertyValue".to_string(), + name: f.name, + value: f.value, + }) + .collect(); - let shared_inbox = Url::parse(&format!("{}/inbox", data.base_url)) - .expect("base_url is always valid"); + let shared_inbox = + Url::parse(&format!("{}/inbox", data.base_url)).expect("base_url is always valid"); Ok(Person { kind: Default::default(), diff --git a/crates/adapters/activitypub-base/src/nodeinfo.rs b/crates/adapters/activitypub-base/src/nodeinfo.rs index 9c0f621..1b95ae8 100644 --- a/crates/adapters/activitypub-base/src/nodeinfo.rs +++ b/crates/adapters/activitypub-base/src/nodeinfo.rs @@ -56,9 +56,7 @@ pub async fn nodeinfo_well_known_handler( })) } -pub async fn nodeinfo_handler( - data: Data, -) -> Result, Error> { +pub async fn nodeinfo_handler(data: Data) -> Result, Error> { let user_count = data.user_repo.count_users().await.unwrap_or(0); let local_posts = data.object_handler.count_local_posts().await.unwrap_or(0); diff --git a/crates/adapters/activitypub-base/src/outbox.rs b/crates/adapters/activitypub-base/src/outbox.rs index 983b9b1..d9a209e 100644 --- a/crates/adapters/activitypub-base/src/outbox.rs +++ b/crates/adapters/activitypub-base/src/outbox.rs @@ -5,9 +5,7 @@ use serde::{Deserialize, Serialize}; use url::Url; use activitypub_federation::{ - config::Data, - fetch::object_id::ObjectId, - kinds::activity::CreateType, + config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType, protocol::context::WithContext, }; @@ -83,8 +81,7 @@ pub async fn outbox_handler( let ordered_items: Vec = items .into_iter() .map(|(ap_id, object, _)| { - let create_id = - Url::parse(&format!("{}/activity", ap_id)).expect("valid url"); + let create_id = Url::parse(&format!("{}/activity", ap_id)).expect("valid url"); serde_json::to_value(WithContext::new_default(CreateActivity { id: create_id, kind: CreateType::default(), @@ -105,9 +102,7 @@ pub async fn outbox_handler( let next = if has_more { oldest_ts.map(|ts| { // Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding - let ts_str = ts - .format("%Y-%m-%dT%H:%M:%S%.3fZ") - .to_string(); + let ts_str = ts.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); format!("{}?page=true&before={}", outbox_url, ts_str) }) } else { diff --git a/crates/adapters/activitypub-base/src/service.rs b/crates/adapters/activitypub-base/src/service.rs index f3e23b8..a536187 100644 --- a/crates/adapters/activitypub-base/src/service.rs +++ b/crates/adapters/activitypub-base/src/service.rs @@ -10,18 +10,23 @@ use axum::{Router, routing::get, routing::post}; use url::Url; use crate::{ - activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity, UpdateActivity}, + activities::{ + AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity, + UpdateActivity, + }, actors::{DbActor, get_local_actor}, content::ApObjectHandler, data::FederationData, federation::ApFederationConfig, followers_handler::{followers_handler, following_handler}, inbox::inbox_handler, + nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler}, outbox::outbox_handler, - repository::{BlockedDomain, FederationRepository, FollowerStatus, FollowingStatus, RemoteActor}, + repository::{ + BlockedDomain, FederationRepository, FollowerStatus, FollowingStatus, RemoteActor, + }, urls::activity_url, user::ApUserRepository, - nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler}, webfinger::webfinger_handler, }; @@ -35,9 +40,10 @@ fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec { .as_deref() .unwrap_or(&f.actor.inbox_url); if seen.insert(inbox_str.to_string()) - && let Ok(url) = Url::parse(inbox_str) { - inboxes.push(url); - } + && let Ok(url) = Url::parse(inbox_str) + { + inboxes.push(url); + } } inboxes } @@ -84,8 +90,13 @@ impl ActivityPubService { event_publisher: Option>, ) -> anyhow::Result { let data = FederationData::new( - repo, user_repo, object_handler, base_url.clone(), - allow_registration, software_name, event_publisher, + repo, + user_repo, + object_handler, + base_url.clone(), + allow_registration, + software_name, + event_publisher, ); let federation_config = ApFederationConfig::new(data, debug).await?; Ok(Self { @@ -550,8 +561,8 @@ impl ActivityPubService { return Ok(()); } - let delete_id = crate::urls::activity_url(&self.base_url) - .map_err(|e| anyhow::anyhow!("{e}"))?; + let delete_id = + crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; let delete = crate::activities::DeleteActivity { id: delete_id, kind: Default::default(), @@ -627,8 +638,7 @@ impl ActivityPubService { }; let add_with_ctx = WithContext::new_default(add); let inboxes = collect_inboxes(&accepted); - let sends = - SendActivityTask::prepare(&add_with_ctx, &local_actor, inboxes, &data).await?; + let sends = SendActivityTask::prepare(&add_with_ctx, &local_actor, inboxes, &data).await?; let failures = send_with_retry(sends, &data).await; if !failures.is_empty() { tracing::warn!(count = failures.len(), "some Add deliveries failed"); @@ -678,8 +688,8 @@ impl ActivityPubService { return Ok(()); } - let undo_id = crate::urls::activity_url(&self.base_url) - .map_err(|e| anyhow::anyhow!("{e}"))?; + let undo_id = + crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; let undo = crate::activities::UndoActivity { id: undo_id, kind: Default::default(), @@ -692,8 +702,7 @@ impl ActivityPubService { }; let undo_with_ctx = WithContext::new_default(undo); let inboxes = collect_inboxes(&accepted); - let sends = - SendActivityTask::prepare(&undo_with_ctx, &local_actor, inboxes, &data).await?; + let sends = SendActivityTask::prepare(&undo_with_ctx, &local_actor, inboxes, &data).await?; let failures = send_with_retry(sends, &data).await; if !failures.is_empty() { tracing::warn!(count = failures.len(), "some Undo(Add) deliveries failed"); @@ -778,7 +787,10 @@ impl ActivityPubService { .await .map_err(|e| anyhow::anyhow!("{e}"))?; - let person = local_actor.clone().into_json(&data).await + let person = local_actor + .clone() + .into_json(&data) + .await .map_err(|e| anyhow::anyhow!("{e}"))?; // Wrap with @context so Mastodon's JSON-LD processor can resolve field names. let person_json = serde_json::to_value(&WithContext::new_default(person))?; @@ -831,29 +843,43 @@ impl ActivityPubService { return Err(anyhow::anyhow!( "actor update delivery failed for {} inbox(es): {}", failures.len(), - failures.iter().map(|e| e.to_string()).collect::>().join("; ") + failures + .iter() + .map(|e| e.to_string()) + .collect::>() + .join("; ") )); } tracing::info!(user_id = %user_id, "actor update broadcast complete"); Ok(()) } - pub async fn block_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> { + pub async fn block_actor( + &self, + local_user_id: uuid::Uuid, + actor_url: &str, + ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); data.federation_repo .add_blocked_actor(local_user_id, actor_url) .await?; - let _ = data.federation_repo.remove_follower(local_user_id, actor_url).await; - let _ = data.federation_repo.remove_following(local_user_id, actor_url).await; + let _ = data + .federation_repo + .remove_follower(local_user_id, actor_url) + .await; + let _ = data + .federation_repo + .remove_following(local_user_id, actor_url) + .await; let local_actor = get_local_actor(local_user_id, &data) .await .map_err(|e| anyhow::anyhow!("{e}"))?; if let Ok(Some(remote_actor)) = data.federation_repo.get_remote_actor(actor_url).await { - let block_id = crate::urls::activity_url(&self.base_url) - .map_err(|e| anyhow::anyhow!("{e}"))?; + let block_id = + crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; let block = crate::activities::BlockActivity { id: block_id, kind: Default::default(), @@ -877,16 +903,26 @@ impl ActivityPubService { Ok(()) } - pub async fn unblock_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> { + pub async fn unblock_actor( + &self, + local_user_id: uuid::Uuid, + actor_url: &str, + ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); data.federation_repo .remove_blocked_actor(local_user_id, actor_url) .await } - pub async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> anyhow::Result> { + pub async fn get_blocked_actors( + &self, + local_user_id: uuid::Uuid, + ) -> anyhow::Result> { let data = self.federation_config.to_request_data(); - let actor_urls = data.federation_repo.get_blocked_actors(local_user_id).await?; + let actor_urls = data + .federation_repo + .get_blocked_actors(local_user_id) + .await?; let mut actors = Vec::new(); for url in actor_urls { let actor = match data.federation_repo.get_remote_actor(&url).await { @@ -906,9 +942,15 @@ impl ActivityPubService { Ok(actors) } - pub async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> { + pub async fn add_blocked_domain( + &self, + domain: &str, + reason: Option<&str>, + ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); - data.federation_repo.add_blocked_domain(domain, reason).await + data.federation_repo + .add_blocked_domain(domain, reason) + .await } pub async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> { diff --git a/crates/adapters/activitypub-base/src/tests/actors.rs b/crates/adapters/activitypub-base/src/tests/actors.rs index b5ceca4..7f510c4 100644 --- a/crates/adapters/activitypub-base/src/tests/actors.rs +++ b/crates/adapters/activitypub-base/src/tests/actors.rs @@ -4,7 +4,10 @@ use super::*; fn person_serializes_with_enriched_fields() { let person = Person { kind: Default::default(), - id: "https://example.com/users/1".parse::().unwrap().into(), + id: "https://example.com/users/1" + .parse::() + .unwrap() + .into(), preferred_username: "alice".to_string(), inbox: "https://example.com/users/1/inbox".parse().unwrap(), outbox: "https://example.com/users/1/outbox".parse().unwrap(), @@ -39,5 +42,8 @@ fn person_serializes_with_enriched_fields() { assert_eq!(json["manuallyApprovesFollowers"], true); assert!(json.get("updated").is_some()); assert!(json.get("endpoints").is_some()); - assert_eq!(json["endpoints"]["sharedInbox"], "https://example.com/inbox"); + assert_eq!( + json["endpoints"]["sharedInbox"], + "https://example.com/inbox" + ); } diff --git a/crates/adapters/activitypub-base/src/tests/nodeinfo.rs b/crates/adapters/activitypub-base/src/tests/nodeinfo.rs index 4bb5791..898e1bf 100644 --- a/crates/adapters/activitypub-base/src/tests/nodeinfo.rs +++ b/crates/adapters/activitypub-base/src/tests/nodeinfo.rs @@ -9,7 +9,10 @@ fn nodeinfo_well_known_serializes_correctly() { }], }; let json = serde_json::to_value(&doc).unwrap(); - assert_eq!(json["links"][0]["rel"], "http://nodeinfo.diaspora.software/ns/schema/2.0"); + assert_eq!( + json["links"][0]["rel"], + "http://nodeinfo.diaspora.software/ns/schema/2.0" + ); assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0"); } diff --git a/crates/adapters/activitypub-base/src/tests/service.rs b/crates/adapters/activitypub-base/src/tests/service.rs index 0ac7bcb..336f589 100644 --- a/crates/adapters/activitypub-base/src/tests/service.rs +++ b/crates/adapters/activitypub-base/src/tests/service.rs @@ -19,8 +19,14 @@ fn make_follower(inbox: &str, shared: Option<&str>) -> Follower { #[test] fn collect_inboxes_deduplicates_shared() { let followers = vec![ - make_follower("https://mastodon.social/users/a/inbox", Some("https://mastodon.social/inbox")), - make_follower("https://mastodon.social/users/b/inbox", Some("https://mastodon.social/inbox")), + make_follower( + "https://mastodon.social/users/a/inbox", + Some("https://mastodon.social/inbox"), + ), + make_follower( + "https://mastodon.social/users/b/inbox", + Some("https://mastodon.social/inbox"), + ), make_follower("https://other.instance/users/c/inbox", None), ]; let inboxes = collect_inboxes(&followers); @@ -32,9 +38,7 @@ fn collect_inboxes_deduplicates_shared() { #[test] fn collect_inboxes_falls_back_to_individual_inbox() { - let followers = vec![ - make_follower("https://example.com/users/x/inbox", None), - ]; + let followers = vec![make_follower("https://example.com/users/x/inbox", None)]; let inboxes = collect_inboxes(&followers); assert_eq!(inboxes.len(), 1); assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox"); diff --git a/crates/adapters/activitypub/src/composite_handler.rs b/crates/adapters/activitypub/src/composite_handler.rs index 23b6d44..06ca41e 100644 --- a/crates/adapters/activitypub/src/composite_handler.rs +++ b/crates/adapters/activitypub/src/composite_handler.rs @@ -27,7 +27,9 @@ impl ApObjectHandler for CompositeObjectHandler { before: Option>, limit: usize, ) -> anyhow::Result)>> { - self.review.get_local_objects_page(user_id, before, limit).await + self.review + .get_local_objects_page(user_id, before, limit) + .await } async fn on_create( diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index 9862495..eb437fd 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -40,11 +40,15 @@ impl ActivityPubEventHandler { impl EventHandler for ActivityPubEventHandler { async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { match event { - DomainEvent::ReviewLogged { review_id, user_id, .. } => self + DomainEvent::ReviewLogged { + review_id, user_id, .. + } => self .on_review_logged(user_id, review_id) .await .map_err(|e| DomainError::InfrastructureError(e.to_string())), - DomainEvent::ReviewUpdated { review_id, user_id, .. } => self + DomainEvent::ReviewUpdated { + review_id, user_id, .. + } => self .on_review_updated(user_id, review_id) .await .map_err(|e| DomainError::InfrastructureError(e.to_string())), @@ -65,7 +69,14 @@ impl EventHandler for ActivityPubEventHandler { external_metadata_id, added_at, } => self - .on_watchlist_added(user_id, movie_id, movie_title, *release_year, external_metadata_id, added_at) + .on_watchlist_added( + user_id, + movie_id, + movie_title, + *release_year, + external_metadata_id, + added_at, + ) .await .map_err(|e| DomainError::InfrastructureError(e.to_string())), DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => self @@ -124,7 +135,11 @@ impl ActivityPubEventHandler { Ok(()) } - async fn on_review_updated(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> { + async fn on_review_updated( + &self, + user_id: &UserId, + review_id: &ReviewId, + ) -> anyhow::Result<()> { let review = match self.review_repository.get_review_by_id(review_id).await? { Some(r) => r, None => return Ok(()), @@ -170,7 +185,11 @@ impl ActivityPubEventHandler { Ok(()) } - async fn on_review_deleted(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> { + async fn on_review_deleted( + &self, + user_id: &UserId, + review_id: &ReviewId, + ) -> anyhow::Result<()> { let ap_id = review_url(&self.base_url, review_id); self.ap_service .broadcast_delete_to_followers(user_id.value(), ap_id) @@ -197,7 +216,10 @@ impl ActivityPubEventHandler { .await .ok() .flatten() - .and_then(|m| m.poster_path().map(|p| format!("{}/images/{}", self.base_url, p.value()))); + .and_then(|m| { + m.poster_path() + .map(|p| format!("{}/images/{}", self.base_url, p.value())) + }); let added_at_utc = chrono::DateTime::::from_naive_utc_and_offset(*added_at, chrono::Utc); diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index a1854e1..d8583f4 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -4,9 +4,9 @@ pub mod objects; pub mod port; pub mod remote_review_repository; pub mod review_handler; -pub mod watchlist_handler; pub(crate) mod urls; pub mod user_adapter; +pub mod watchlist_handler; // Re-export the generic base types that callers need pub use activitypub_base::{ @@ -21,22 +21,22 @@ pub use review_handler::ReviewObjectHandler; pub use user_adapter::DomainUserRepoAdapter; pub struct ActivityPubWire { - pub service: std::sync::Arc, - pub router: axum::Router, + pub service: std::sync::Arc, + pub router: axum::Router, pub event_handler: std::sync::Arc, } pub async fn wire( - federation_repo: std::sync::Arc, - review_store: std::sync::Arc, + federation_repo: std::sync::Arc, + review_store: std::sync::Arc, remote_watchlist_repo: std::sync::Arc, - user_repo: std::sync::Arc, - movie_repo: std::sync::Arc, - review_repo: std::sync::Arc, - diary_repo: std::sync::Arc, - base_url: String, - allow_registration: bool, - event_publisher: std::sync::Arc, + user_repo: std::sync::Arc, + movie_repo: std::sync::Arc, + review_repo: std::sync::Arc, + diary_repo: std::sync::Arc, + base_url: String, + allow_registration: bool, + event_publisher: std::sync::Arc, ) -> anyhow::Result { let review_handler = std::sync::Arc::new(ReviewObjectHandler { movie_repository: std::sync::Arc::clone(&movie_repo), diff --git a/crates/adapters/activitypub/src/objects.rs b/crates/adapters/activitypub/src/objects.rs index 54c00bd..d11422e 100644 --- a/crates/adapters/activitypub/src/objects.rs +++ b/crates/adapters/activitypub/src/objects.rs @@ -74,8 +74,7 @@ pub fn review_to_ap_object( let tag = vec![ ApHashtag { kind: "Hashtag".to_string(), - href: Url::parse(&format!("{}/tags/moviesdiary", base_url)) - .expect("valid base_url"), + href: Url::parse(&format!("{}/tags/moviesdiary", base_url)).expect("valid base_url"), name: "#MoviesDiary".to_string(), }, ApHashtag { @@ -152,8 +151,7 @@ pub fn watchlist_to_ap_object( let tag = vec![ ApHashtag { kind: "Hashtag".to_string(), - href: Url::parse(&format!("{}/tags/moviesdiary", base_url)) - .expect("valid base_url"), + href: Url::parse(&format!("{}/tags/moviesdiary", base_url)).expect("valid base_url"), name: "#MoviesDiary".to_string(), }, ApHashtag { diff --git a/crates/adapters/activitypub/src/review_handler.rs b/crates/adapters/activitypub/src/review_handler.rs index d75c721..8aa826a 100644 --- a/crates/adapters/activitypub/src/review_handler.rs +++ b/crates/adapters/activitypub/src/review_handler.rs @@ -101,9 +101,10 @@ impl ApObjectHandler for ReviewObjectHandler { chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc); if let Some(cutoff) = before - && published >= cutoff { - continue; - } + && published >= cutoff + { + continue; + } let ap_id = review_url(&self.base_url, review.id()); let actor_url = actor_url(&self.base_url, user_id); @@ -118,7 +119,10 @@ impl ApObjectHandler for ReviewObjectHandler { .as_ref() .map(|m| m.title().value().to_string()) .unwrap_or_else(|| "Unknown".to_string()); - let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0); + let release_year = movie + .as_ref() + .map(|m| m.release_year().value()) + .unwrap_or(0); let poster_url = movie .as_ref() .and_then(|m| m.poster_path()) diff --git a/crates/adapters/activitypub/src/tests/objects.rs b/crates/adapters/activitypub/src/tests/objects.rs index 4950f06..1ebbecd 100644 --- a/crates/adapters/activitypub/src/tests/objects.rs +++ b/crates/adapters/activitypub/src/tests/objects.rs @@ -4,7 +4,10 @@ use super::*; fn normalize_hashtag_strips_non_alphanumeric() { assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight"); assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList"); - assert_eq!(normalize_hashtag("2001: A Space Odyssey"), "2001ASpaceOdyssey"); + assert_eq!( + normalize_hashtag("2001: A Space Odyssey"), + "2001ASpaceOdyssey" + ); } #[test] diff --git a/crates/adapters/activitypub/src/urls.rs b/crates/adapters/activitypub/src/urls.rs index 09c08a6..39cd076 100644 --- a/crates/adapters/activitypub/src/urls.rs +++ b/crates/adapters/activitypub/src/urls.rs @@ -15,6 +15,9 @@ pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url { /// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}` pub fn watchlist_entry_url(base_url: &str, user_id: uuid::Uuid, movie_id: uuid::Uuid) -> Url { - Url::parse(&format!("{}/users/{}/watchlist/{}", base_url, user_id, movie_id)) - .expect("base_url is always a valid URL prefix") + Url::parse(&format!( + "{}/users/{}/watchlist/{}", + base_url, user_id, movie_id + )) + .expect("base_url is always a valid URL prefix") } diff --git a/crates/adapters/activitypub/src/user_adapter.rs b/crates/adapters/activitypub/src/user_adapter.rs index 9281dbd..6fcecc6 100644 --- a/crates/adapters/activitypub/src/user_adapter.rs +++ b/crates/adapters/activitypub/src/user_adapter.rs @@ -2,10 +2,7 @@ use std::sync::Arc; use activitypub_base::{ApProfileField, ApUser, ApUserRepository}; use async_trait::async_trait; -use domain::{ - ports::UserRepository, - value_objects::UserId, -}; +use domain::{ports::UserRepository, value_objects::UserId}; use url::Url; pub struct DomainUserRepoAdapter { @@ -14,20 +11,17 @@ pub struct DomainUserRepoAdapter { } impl DomainUserRepoAdapter { - pub fn new( - repo: Arc, - base_url: String, - ) -> Self { + pub fn new(repo: Arc, base_url: String) -> Self { Self { repo, base_url } } fn build_user(&self, u: &domain::models::User) -> ApUser { - let avatar_url = u.avatar_path().and_then(|p| { - Url::parse(&format!("{}/images/{}", self.base_url, p)).ok() - }); - let banner_url = u.banner_path().and_then(|p| { - Url::parse(&format!("{}/images/{}", self.base_url, p)).ok() - }); + let avatar_url = u + .avatar_path() + .and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()); + let banner_url = u + .banner_path() + .and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()); let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok(); ApUser { id: u.id().value(), @@ -37,7 +31,14 @@ impl DomainUserRepoAdapter { banner_url, also_known_as: u.also_known_as().map(|s| s.to_string()), profile_url, - attachment: u.profile_fields().iter().map(|f| ApProfileField { name: f.name.clone(), value: f.value.clone() }).collect(), + attachment: u + .profile_fields() + .iter() + .map(|f| ApProfileField { + name: f.name.clone(), + value: f.value.clone(), + }) + .collect(), } } } @@ -55,7 +56,8 @@ impl ApUserRepository for DomainUserRepoAdapter { async fn find_by_username(&self, username: &str) -> anyhow::Result> { use domain::value_objects::Username; - let uname = Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?; + let uname = + Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?; let user = match self.repo.find_by_username(&uname).await? { Some(u) => u, None => return Ok(None), @@ -64,7 +66,10 @@ impl ApUserRepository for DomainUserRepoAdapter { } async fn count_users(&self) -> anyhow::Result { - Ok(self.repo.list_with_stats().await + Ok(self + .repo + .list_with_stats() + .await .map_err(|e| anyhow::anyhow!(e.to_string()))? .len()) } diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/adapters/event-payload/src/lib.rs index 639755e..c0a351e 100644 --- a/crates/adapters/event-payload/src/lib.rs +++ b/crates/adapters/event-payload/src/lib.rs @@ -84,8 +84,7 @@ impl EventPayload { } fn parse_uuid(s: &str, field: &str) -> Result { - Uuid::parse_str(s) - .map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}"))) + Uuid::parse_str(s).map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}"))) } fn parse_ts(ts: i64) -> Result { @@ -97,31 +96,43 @@ fn parse_ts(ts: i64) -> Result { impl From<&DomainEvent> for EventPayload { fn from(event: &DomainEvent) -> Self { match event { - DomainEvent::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => { - EventPayload::ReviewLogged { - review_id: review_id.value().to_string(), - movie_id: movie_id.value().to_string(), - user_id: user_id.value().to_string(), - rating: rating.value(), - watched_at: watched_at.and_utc().timestamp(), - } - } - DomainEvent::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => { - EventPayload::ReviewUpdated { - review_id: review_id.value().to_string(), - movie_id: movie_id.value().to_string(), - user_id: user_id.value().to_string(), - rating: rating.value(), - watched_at: watched_at.and_utc().timestamp(), - } - } - DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => { - EventPayload::MovieDiscovered { - movie_id: movie_id.value().to_string(), - external_metadata_id: external_metadata_id.value().to_owned(), - } - } - DomainEvent::MovieDeleted { movie_id, poster_path } => EventPayload::MovieDeleted { + DomainEvent::ReviewLogged { + review_id, + movie_id, + user_id, + rating, + watched_at, + } => EventPayload::ReviewLogged { + review_id: review_id.value().to_string(), + movie_id: movie_id.value().to_string(), + user_id: user_id.value().to_string(), + rating: rating.value(), + watched_at: watched_at.and_utc().timestamp(), + }, + DomainEvent::ReviewUpdated { + review_id, + movie_id, + user_id, + rating, + watched_at, + } => EventPayload::ReviewUpdated { + review_id: review_id.value().to_string(), + movie_id: movie_id.value().to_string(), + user_id: user_id.value().to_string(), + rating: rating.value(), + watched_at: watched_at.and_utc().timestamp(), + }, + DomainEvent::MovieDiscovered { + movie_id, + external_metadata_id, + } => EventPayload::MovieDiscovered { + movie_id: movie_id.value().to_string(), + external_metadata_id: external_metadata_id.value().to_owned(), + }, + DomainEvent::MovieDeleted { + movie_id, + poster_path, + } => EventPayload::MovieDeleted { movie_id: movie_id.value().to_string(), poster_path: poster_path.as_ref().map(|p| p.value().to_string()), }, @@ -132,36 +143,44 @@ impl From<&DomainEvent> for EventPayload { review_id: review_id.value().to_string(), user_id: user_id.value().to_string(), }, - DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id } => { - EventPayload::MovieEnrichmentRequested { - movie_id: movie_id.value().to_string(), - external_metadata_id: external_metadata_id.clone(), - } - } + DomainEvent::MovieEnrichmentRequested { + movie_id, + external_metadata_id, + } => EventPayload::MovieEnrichmentRequested { + movie_id: movie_id.value().to_string(), + external_metadata_id: external_metadata_id.clone(), + }, DomainEvent::ImageStored { key } => EventPayload::ImageStored { key: key.clone() }, - DomainEvent::WatchlistEntryAdded { user_id, movie_id, movie_title, release_year, external_metadata_id, added_at } => { - EventPayload::WatchlistEntryAdded { - user_id: user_id.value().to_string(), - movie_id: movie_id.value().to_string(), - movie_title: movie_title.clone(), - release_year: *release_year, - external_metadata_id: external_metadata_id.clone(), - added_at: added_at.and_utc().timestamp(), - } - } + DomainEvent::WatchlistEntryAdded { + user_id, + movie_id, + movie_title, + release_year, + external_metadata_id, + added_at, + } => EventPayload::WatchlistEntryAdded { + user_id: user_id.value().to_string(), + movie_id: movie_id.value().to_string(), + movie_title: movie_title.clone(), + release_year: *release_year, + external_metadata_id: external_metadata_id.clone(), + added_at: added_at.and_utc().timestamp(), + }, DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => { EventPayload::WatchlistEntryRemoved { user_id: user_id.value().to_string(), movie_id: movie_id.value().to_string(), } } - DomainEvent::FollowAccepted { local_user_id, remote_actor_url, outbox_url } => { - EventPayload::FollowAccepted { - local_user_id: local_user_id.value().to_string(), - remote_actor_url: remote_actor_url.clone(), - outbox_url: outbox_url.clone(), - } - } + DomainEvent::FollowAccepted { + local_user_id, + remote_actor_url, + outbox_url, + } => EventPayload::FollowAccepted { + local_user_id: local_user_id.value().to_string(), + remote_actor_url: remote_actor_url.clone(), + outbox_url: outbox_url.clone(), + }, } } } @@ -170,81 +189,98 @@ impl TryFrom for DomainEvent { type Error = DomainError; fn try_from(payload: EventPayload) -> Result { match payload { - EventPayload::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => { - Ok(DomainEvent::ReviewLogged { - review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?), - movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), - user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), - rating: Rating::new(rating)?, - watched_at: parse_ts(watched_at)?, - }) - } - EventPayload::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => { - Ok(DomainEvent::ReviewUpdated { - review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?), - movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), - user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), - rating: Rating::new(rating)?, - watched_at: parse_ts(watched_at)?, - }) - } - EventPayload::MovieDiscovered { movie_id, external_metadata_id } => { - Ok(DomainEvent::MovieDiscovered { - movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), - external_metadata_id: ExternalMetadataId::new(external_metadata_id)?, - }) - } - EventPayload::MovieDeleted { movie_id, poster_path } => { + EventPayload::ReviewLogged { + review_id, + movie_id, + user_id, + rating, + watched_at, + } => Ok(DomainEvent::ReviewLogged { + review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?), + movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), + user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), + rating: Rating::new(rating)?, + watched_at: parse_ts(watched_at)?, + }), + EventPayload::ReviewUpdated { + review_id, + movie_id, + user_id, + rating, + watched_at, + } => Ok(DomainEvent::ReviewUpdated { + review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?), + movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), + user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), + rating: Rating::new(rating)?, + watched_at: parse_ts(watched_at)?, + }), + EventPayload::MovieDiscovered { + movie_id, + external_metadata_id, + } => Ok(DomainEvent::MovieDiscovered { + movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), + external_metadata_id: ExternalMetadataId::new(external_metadata_id)?, + }), + EventPayload::MovieDeleted { + movie_id, + poster_path, + } => { let movie_id = MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?); let poster_path = poster_path .map(PosterPath::new) .transpose() .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(DomainEvent::MovieDeleted { movie_id, poster_path }) - } - EventPayload::UserUpdated { user_id } => { - Ok(DomainEvent::UserUpdated { - user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), - }) - } - EventPayload::ReviewDeleted { review_id, user_id } => { - Ok(DomainEvent::ReviewDeleted { - review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?), - user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), - }) - } - EventPayload::MovieEnrichmentRequested { movie_id, external_metadata_id } => { - Ok(DomainEvent::MovieEnrichmentRequested { - movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), - external_metadata_id, - }) - } - EventPayload::ImageStored { key } => { - Ok(DomainEvent::ImageStored { key }) - } - EventPayload::WatchlistEntryAdded { user_id, movie_id, movie_title, release_year, external_metadata_id, added_at } => { - Ok(DomainEvent::WatchlistEntryAdded { - user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), - movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), - movie_title, - release_year, - external_metadata_id, - added_at: parse_ts(added_at)?, + Ok(DomainEvent::MovieDeleted { + movie_id, + poster_path, }) } + EventPayload::UserUpdated { user_id } => Ok(DomainEvent::UserUpdated { + user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), + }), + EventPayload::ReviewDeleted { review_id, user_id } => Ok(DomainEvent::ReviewDeleted { + review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?), + user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), + }), + EventPayload::MovieEnrichmentRequested { + movie_id, + external_metadata_id, + } => Ok(DomainEvent::MovieEnrichmentRequested { + movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), + external_metadata_id, + }), + EventPayload::ImageStored { key } => Ok(DomainEvent::ImageStored { key }), + EventPayload::WatchlistEntryAdded { + user_id, + movie_id, + movie_title, + release_year, + external_metadata_id, + added_at, + } => Ok(DomainEvent::WatchlistEntryAdded { + user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), + movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), + movie_title, + release_year, + external_metadata_id, + added_at: parse_ts(added_at)?, + }), EventPayload::WatchlistEntryRemoved { user_id, movie_id } => { Ok(DomainEvent::WatchlistEntryRemoved { user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), }) } - EventPayload::FollowAccepted { local_user_id, remote_actor_url, outbox_url } => { - Ok(DomainEvent::FollowAccepted { - local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?), - remote_actor_url, - outbox_url, - }) - } + EventPayload::FollowAccepted { + local_user_id, + remote_actor_url, + outbox_url, + } => Ok(DomainEvent::FollowAccepted { + local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?), + remote_actor_url, + outbox_url, + }), } } } diff --git a/crates/adapters/event-payload/src/tests/lib.rs b/crates/adapters/event-payload/src/tests/lib.rs index 6dcb58a..6eb8b5c 100644 --- a/crates/adapters/event-payload/src/tests/lib.rs +++ b/crates/adapters/event-payload/src/tests/lib.rs @@ -1,7 +1,9 @@ use super::*; fn fixed_dt() -> NaiveDateTime { - chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc() + chrono::DateTime::from_timestamp(1_700_000_000, 0) + .unwrap() + .naive_utc() } fn review_logged() -> DomainEvent { @@ -64,14 +66,25 @@ fn serialized_format_is_tagged() { #[test] fn event_type_strings() { - assert_eq!(EventPayload::from(&review_logged()).event_type(), "ReviewLogged"); - assert_eq!(EventPayload::from(&review_updated()).event_type(), "ReviewUpdated"); - assert_eq!(EventPayload::from(&movie_discovered()).event_type(), "MovieDiscovered"); + assert_eq!( + EventPayload::from(&review_logged()).event_type(), + "ReviewLogged" + ); + assert_eq!( + EventPayload::from(&review_updated()).event_type(), + "ReviewUpdated" + ); + assert_eq!( + EventPayload::from(&movie_discovered()).event_type(), + "MovieDiscovered" + ); } #[test] fn round_trip_image_stored() { - let event = DomainEvent::ImageStored { key: "avatars/abc123".into() }; + let event = DomainEvent::ImageStored { + key: "avatars/abc123".into(), + }; let payload = EventPayload::from(&event); let json = serde_json::to_string(&payload).unwrap(); let back: EventPayload = serde_json::from_str(&json).unwrap(); @@ -81,6 +94,8 @@ fn round_trip_image_stored() { #[test] fn image_stored_event_type() { - let payload = EventPayload::from(&DomainEvent::ImageStored { key: "posters/x".into() }); + let payload = EventPayload::from(&DomainEvent::ImageStored { + key: "posters/x".into(), + }); assert_eq!(payload.event_type(), "ImageStored"); } diff --git a/crates/adapters/event-publisher/src/lib.rs b/crates/adapters/event-publisher/src/lib.rs index 8c2dd8a..302ed8f 100644 --- a/crates/adapters/event-publisher/src/lib.rs +++ b/crates/adapters/event-publisher/src/lib.rs @@ -43,8 +43,12 @@ struct NoopAck; #[async_trait] impl AckHandle for NoopAck { - async fn ack(&self) -> Result<(), DomainError> { Ok(()) } - async fn nack(&self) -> Result<(), DomainError> { Ok(()) } + async fn ack(&self) -> Result<(), DomainError> { + Ok(()) + } + async fn nack(&self) -> Result<(), DomainError> { + Ok(()) + } } pub struct ChannelEventConsumer { diff --git a/crates/adapters/event-publisher/src/tests/lib.rs b/crates/adapters/event-publisher/src/tests/lib.rs index c843854..922a95a 100644 --- a/crates/adapters/event-publisher/src/tests/lib.rs +++ b/crates/adapters/event-publisher/src/tests/lib.rs @@ -22,7 +22,10 @@ async fn consumer_yields_published_events() { let mut stream = consumer.consume(); let envelope = stream.next().await.unwrap().unwrap(); - assert!(matches!(envelope.event, DomainEvent::MovieDiscovered { .. })); + assert!(matches!( + envelope.event, + DomainEvent::MovieDiscovered { .. } + )); assert!(stream.next().await.is_none()); } diff --git a/crates/adapters/export/src/tests/lib.rs b/crates/adapters/export/src/tests/lib.rs index 9145ae3..5d1960b 100644 --- a/crates/adapters/export/src/tests/lib.rs +++ b/crates/adapters/export/src/tests/lib.rs @@ -61,9 +61,7 @@ async fn csv_has_header_and_one_row() { .unwrap(); let text = String::from_utf8(bytes).unwrap(); assert!( - text.starts_with( - "title,year,director,rating,comment,watched_at,external_metadata_id\n" - ) + text.starts_with("title,year,director,rating,comment,watched_at,external_metadata_id\n") ); assert!(text.contains("Inception")); assert!(text.contains("2010")); diff --git a/crates/adapters/image-converter/src/backfill.rs b/crates/adapters/image-converter/src/backfill.rs index 7bf9a4f..effebab 100644 --- a/crates/adapters/image-converter/src/backfill.rs +++ b/crates/adapters/image-converter/src/backfill.rs @@ -17,7 +17,10 @@ impl ConversionBackfillJob { image_ref: Arc, event_publisher: Arc, ) -> Self { - Self { image_ref, event_publisher } + Self { + image_ref, + event_publisher, + } } } @@ -34,7 +37,8 @@ impl PeriodicJob for ConversionBackfillJob { if key.ends_with(".avif") || key.ends_with(".webp") { continue; } - if let Err(e) = self.event_publisher + if let Err(e) = self + .event_publisher .publish(&DomainEvent::ImageStored { key: key.clone() }) .await { diff --git a/crates/adapters/image-converter/src/handler.rs b/crates/adapters/image-converter/src/handler.rs index 01c7880..7ca3790 100644 --- a/crates/adapters/image-converter/src/handler.rs +++ b/crates/adapters/image-converter/src/handler.rs @@ -21,7 +21,11 @@ impl ImageConversionHandler { image_ref: Arc, format: Format, ) -> Self { - Self { storage, image_ref, format } + Self { + storage, + image_ref, + format, + } } } @@ -73,7 +77,12 @@ fn convert(bytes: Vec, format: Format) -> Result, String> { let height = rgba.height() as usize; let pixels: Vec = rgba .pixels() - .map(|p| ravif::RGBA8 { r: p.0[0], g: p.0[1], b: p.0[2], a: p.0[3] }) + .map(|p| ravif::RGBA8 { + r: p.0[0], + g: p.0[1], + b: p.0[2], + a: p.0[3], + }) .collect(); let result = ravif::Encoder::new() .with_quality(80.0) diff --git a/crates/adapters/image-converter/src/lib.rs b/crates/adapters/image-converter/src/lib.rs index 45b76df..5d8949c 100644 --- a/crates/adapters/image-converter/src/lib.rs +++ b/crates/adapters/image-converter/src/lib.rs @@ -6,8 +6,10 @@ pub use backfill::ConversionBackfillJob; pub use config::{ConversionConfig, Format}; pub use handler::ImageConversionHandler; +use domain::ports::{ + EventHandler, EventPublisher, ImageRefCommand, ImageRefQuery, ImageStorage, PeriodicJob, +}; use std::sync::Arc; -use domain::ports::{EventHandler, EventPublisher, ImageRefCommand, ImageRefQuery, ImageStorage, PeriodicJob}; pub fn build( image_storage: Arc, diff --git a/crates/adapters/image-converter/src/tests/backfill.rs b/crates/adapters/image-converter/src/tests/backfill.rs index 80851c4..7b0a35a 100644 --- a/crates/adapters/image-converter/src/tests/backfill.rs +++ b/crates/adapters/image-converter/src/tests/backfill.rs @@ -18,7 +18,9 @@ struct MockPublisher { impl MockPublisher { fn new() -> Arc { - Arc::new(Self { emitted: Mutex::new(vec![]) }) + Arc::new(Self { + emitted: Mutex::new(vec![]), + }) } fn emitted(&self) -> Vec { @@ -42,10 +44,8 @@ async fn emits_image_stored_for_unconverted_keys() { keys: vec!["avatars/u1".into(), "posters/m1".into()], }); let publisher = MockPublisher::new(); - let job = ConversionBackfillJob::new( - image_ref, - Arc::clone(&publisher) as Arc, - ); + let job = + ConversionBackfillJob::new(image_ref, Arc::clone(&publisher) as Arc); job.run().await.unwrap(); @@ -64,10 +64,8 @@ async fn skips_already_converted_keys() { ], }); let publisher = MockPublisher::new(); - let job = ConversionBackfillJob::new( - image_ref, - Arc::clone(&publisher) as Arc, - ); + let job = + ConversionBackfillJob::new(image_ref, Arc::clone(&publisher) as Arc); job.run().await.unwrap(); @@ -78,10 +76,8 @@ async fn skips_already_converted_keys() { async fn empty_keys_emits_nothing() { let image_ref = Arc::new(MockImageRef { keys: vec![] }); let publisher = MockPublisher::new(); - let job = ConversionBackfillJob::new( - image_ref, - Arc::clone(&publisher) as Arc, - ); + let job = + ConversionBackfillJob::new(image_ref, Arc::clone(&publisher) as Arc); job.run().await.unwrap(); diff --git a/crates/adapters/image-converter/src/tests/config.rs b/crates/adapters/image-converter/src/tests/config.rs index af8cd94..65941a7 100644 --- a/crates/adapters/image-converter/src/tests/config.rs +++ b/crates/adapters/image-converter/src/tests/config.rs @@ -3,18 +3,24 @@ use super::*; #[test] fn disabled_by_default() { assert!(ConversionConfig::from_vars(None, None).unwrap().is_none()); - assert!(ConversionConfig::from_vars(Some("false"), None).unwrap().is_none()); + assert!(ConversionConfig::from_vars(Some("false"), None) + .unwrap() + .is_none()); } #[test] fn enabled_avif() { - let cfg = ConversionConfig::from_vars(Some("true"), Some("avif")).unwrap().unwrap(); + let cfg = ConversionConfig::from_vars(Some("true"), Some("avif")) + .unwrap() + .unwrap(); assert_eq!(cfg.format, Format::Avif); } #[test] fn enabled_webp() { - let cfg = ConversionConfig::from_vars(Some("true"), Some("webp")).unwrap().unwrap(); + let cfg = ConversionConfig::from_vars(Some("true"), Some("webp")) + .unwrap() + .unwrap(); assert_eq!(cfg.format, Format::Webp); } diff --git a/crates/adapters/image-converter/src/tests/handler.rs b/crates/adapters/image-converter/src/tests/handler.rs index d7c0e2f..616e737 100644 --- a/crates/adapters/image-converter/src/tests/handler.rs +++ b/crates/adapters/image-converter/src/tests/handler.rs @@ -1,7 +1,7 @@ use super::*; -use std::sync::Mutex; -use object_store::memory::InMemory; use image_storage::ImageStorageAdapter; +use object_store::memory::InMemory; +use std::sync::Mutex; struct MockImageRef { swaps: Mutex>, @@ -9,7 +9,9 @@ struct MockImageRef { impl MockImageRef { fn new() -> Arc { - Arc::new(Self { swaps: Mutex::new(vec![]) }) + Arc::new(Self { + swaps: Mutex::new(vec![]), + }) } fn swaps(&self) -> Vec<(String, String)> { @@ -31,9 +33,7 @@ fn in_memory_storage() -> Arc { fn tiny_jpeg() -> Vec { use image::{DynamicImage, ImageBuffer, Rgb}; - let img = DynamicImage::ImageRgb8( - ImageBuffer::from_pixel(4, 4, Rgb([200u8, 100, 50])), - ); + let img = DynamicImage::ImageRgb8(ImageBuffer::from_pixel(4, 4, Rgb([200u8, 100, 50]))); let mut buf = std::io::Cursor::new(Vec::new()); img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap(); buf.into_inner() @@ -49,9 +49,12 @@ async fn ignores_non_image_stored_events() { Format::Avif, ); - handler.handle(&DomainEvent::UserUpdated { - user_id: domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4()), - }).await.unwrap(); + handler + .handle(&DomainEvent::UserUpdated { + user_id: domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4()), + }) + .await + .unwrap(); assert!(image_ref.swaps().is_empty()); } @@ -59,7 +62,10 @@ async fn ignores_non_image_stored_events() { #[tokio::test] async fn skips_already_converted_avif_key() { let storage = in_memory_storage(); - storage.store("avatars/u1.avif", &tiny_jpeg()).await.unwrap(); + storage + .store("avatars/u1.avif", &tiny_jpeg()) + .await + .unwrap(); let image_ref = MockImageRef::new(); let handler = ImageConversionHandler::new( Arc::clone(&storage) as Arc, @@ -67,7 +73,12 @@ async fn skips_already_converted_avif_key() { Format::Avif, ); - handler.handle(&DomainEvent::ImageStored { key: "avatars/u1.avif".into() }).await.unwrap(); + handler + .handle(&DomainEvent::ImageStored { + key: "avatars/u1.avif".into(), + }) + .await + .unwrap(); assert!(image_ref.swaps().is_empty()); } @@ -75,7 +86,10 @@ async fn skips_already_converted_avif_key() { #[tokio::test] async fn skips_already_converted_webp_key() { let storage = in_memory_storage(); - storage.store("posters/m1.webp", &tiny_jpeg()).await.unwrap(); + storage + .store("posters/m1.webp", &tiny_jpeg()) + .await + .unwrap(); let image_ref = MockImageRef::new(); let handler = ImageConversionHandler::new( Arc::clone(&storage) as Arc, @@ -83,7 +97,12 @@ async fn skips_already_converted_webp_key() { Format::Webp, ); - handler.handle(&DomainEvent::ImageStored { key: "posters/m1.webp".into() }).await.unwrap(); + handler + .handle(&DomainEvent::ImageStored { + key: "posters/m1.webp".into(), + }) + .await + .unwrap(); assert!(image_ref.swaps().is_empty()); } @@ -99,9 +118,17 @@ async fn converts_jpeg_to_avif_and_swaps_key() { Format::Avif, ); - handler.handle(&DomainEvent::ImageStored { key: "avatars/u1".into() }).await.unwrap(); + handler + .handle(&DomainEvent::ImageStored { + key: "avatars/u1".into(), + }) + .await + .unwrap(); - assert_eq!(image_ref.swaps(), vec![("avatars/u1".into(), "avatars/u1.avif".into())]); + assert_eq!( + image_ref.swaps(), + vec![("avatars/u1".into(), "avatars/u1.avif".into())] + ); assert!(storage.get("avatars/u1.avif").await.is_ok()); assert!(storage.get("avatars/u1").await.is_err()); } @@ -117,9 +144,17 @@ async fn converts_jpeg_to_webp_and_swaps_key() { Format::Webp, ); - handler.handle(&DomainEvent::ImageStored { key: "avatars/u1".into() }).await.unwrap(); + handler + .handle(&DomainEvent::ImageStored { + key: "avatars/u1".into(), + }) + .await + .unwrap(); - assert_eq!(image_ref.swaps(), vec![("avatars/u1".into(), "avatars/u1.webp".into())]); + assert_eq!( + image_ref.swaps(), + vec![("avatars/u1".into(), "avatars/u1.webp".into())] + ); assert!(storage.get("avatars/u1.webp").await.is_ok()); assert!(storage.get("avatars/u1").await.is_err()); } diff --git a/crates/adapters/image-storage/src/lib.rs b/crates/adapters/image-storage/src/lib.rs index 25915e8..3d8eb47 100644 --- a/crates/adapters/image-storage/src/lib.rs +++ b/crates/adapters/image-storage/src/lib.rs @@ -10,7 +10,6 @@ use domain::{ use object_store::{ObjectStore, path::Path}; use std::sync::Arc; - pub struct ImageStorageAdapter { store: Arc, } @@ -76,7 +75,9 @@ impl EventHandler for ImageCleanupHandler { DomainEvent::MovieDeleted { poster_path, .. } => poster_path, _ => return Ok(()), }; - let Some(path) = poster_path else { return Ok(()) }; + let Some(path) = poster_path else { + return Ok(()); + }; if let Err(e) = self.image_storage.delete(path.value()).await { tracing::warn!("image cleanup failed for {}: {e}", path.value()); } @@ -85,7 +86,9 @@ impl EventHandler for ImageCleanupHandler { } pub fn create() -> anyhow::Result> { - Ok(Arc::new(ImageStorageAdapter::from_config(StorageConfig::from_env()?))) + Ok(Arc::new(ImageStorageAdapter::from_config( + StorageConfig::from_env()?, + ))) } #[cfg(test)] diff --git a/crates/adapters/image-storage/src/tests/lib.rs b/crates/adapters/image-storage/src/tests/lib.rs index f6a4460..0ffa6d4 100644 --- a/crates/adapters/image-storage/src/tests/lib.rs +++ b/crates/adapters/image-storage/src/tests/lib.rs @@ -39,7 +39,10 @@ async fn delete_missing_returns_ok() { #[tokio::test] async fn cleanup_handler_deletes_on_movie_deleted() { - use domain::{events::DomainEvent, value_objects::{MovieId, PosterPath}}; + use domain::{ + events::DomainEvent, + value_objects::{MovieId, PosterPath}, + }; let inner = Arc::new(adapter()); inner.store("some-uuid", b"img").await.unwrap(); let path = PosterPath::new("some-uuid".to_string()).unwrap(); @@ -51,5 +54,8 @@ async fn cleanup_handler_deletes_on_movie_deleted() { }) .await .unwrap(); - assert!(matches!(inner.get("some-uuid").await, Err(DomainError::NotFound(_)))); + assert!(matches!( + inner.get("some-uuid").await, + Err(DomainError::NotFound(_)) + )); } diff --git a/crates/adapters/importer/src/lib.rs b/crates/adapters/importer/src/lib.rs index 9e20362..a274c66 100644 --- a/crates/adapters/importer/src/lib.rs +++ b/crates/adapters/importer/src/lib.rs @@ -15,9 +15,13 @@ impl DocumentParser for ImporterDocumentParser { FileFormat::Json => parsers::parse_json(bytes), FileFormat::Xlsx => { #[cfg(feature = "xlsx")] - { parsers::parse_xlsx(bytes) } + { + parsers::parse_xlsx(bytes) + } #[cfg(not(feature = "xlsx"))] - { Err(ImportError::Xlsx("XLSX support not compiled in".into())) } + { + Err(ImportError::Xlsx("XLSX support not compiled in".into())) + } } } } diff --git a/crates/adapters/importer/src/mapper.rs b/crates/adapters/importer/src/mapper.rs index a0cd221..fddb772 100644 --- a/crates/adapters/importer/src/mapper.rs +++ b/crates/adapters/importer/src/mapper.rs @@ -3,10 +3,16 @@ use domain::models::{ }; pub fn apply_mapping(file: &ParsedFile, mappings: &[FieldMapping]) -> Vec { - file.rows.iter().map(|row| { - let result = map_row(row, &file.columns, mappings); - AnnotatedRow { result, is_duplicate: false } - }).collect() + file.rows + .iter() + .map(|row| { + let result = map_row(row, &file.columns, mappings); + AnnotatedRow { + result, + is_duplicate: false, + } + }) + .collect() } fn map_row(row: &[String], columns: &[String], mappings: &[FieldMapping]) -> RowResult { @@ -39,7 +45,8 @@ fn map_row(row: &[String], columns: &[String], mappings: &[FieldMapping]) -> Row if errors.is_empty() { RowResult::Valid(import_row) } else { - let raw = columns.iter() + let raw = columns + .iter() .zip(row.iter()) .map(|(c, v)| (c.clone(), v.clone())) .collect(); @@ -51,15 +58,13 @@ fn apply_transform(value: &str, transform: &Transform, errors: &mut Vec) match transform { Transform::Identity => Some(value.to_string()), Transform::DateFormat(_) => Some(value.to_string()), - Transform::RatingScale(factor) => { - match value.parse::() { - Ok(n) => Some((n * factor).round().to_string()), - Err(_) => { - errors.push(format!("rating '{}' is not a number", value)); - None - } + Transform::RatingScale(factor) => match value.parse::() { + Ok(n) => Some((n * factor).round().to_string()), + Err(_) => { + errors.push(format!("rating '{}' is not a number", value)); + None } - } + }, } } diff --git a/crates/adapters/importer/src/parsers/csv.rs b/crates/adapters/importer/src/parsers/csv.rs index 53f24d0..b717d2c 100644 --- a/crates/adapters/importer/src/parsers/csv.rs +++ b/crates/adapters/importer/src/parsers/csv.rs @@ -24,13 +24,12 @@ pub fn parse_csv(bytes: &[u8]) -> Result { let rows: Vec> = rdr .records() .map(|r| { - r.map_err(|e| ImportError::Csv(e.to_string())) - .map(|rec| { - let mut cells: Vec = rec.iter().map(|f| f.trim().to_string()).collect(); - cells.resize(columns.len(), String::new()); - cells.truncate(columns.len()); - cells - }) + r.map_err(|e| ImportError::Csv(e.to_string())).map(|rec| { + let mut cells: Vec = rec.iter().map(|f| f.trim().to_string()).collect(); + cells.resize(columns.len(), String::new()); + cells.truncate(columns.len()); + cells + }) }) .collect::>()?; diff --git a/crates/adapters/importer/src/parsers/json.rs b/crates/adapters/importer/src/parsers/json.rs index a355a6d..50a7066 100644 --- a/crates/adapters/importer/src/parsers/json.rs +++ b/crates/adapters/importer/src/parsers/json.rs @@ -2,17 +2,19 @@ use domain::models::{ImportError, ParsedFile}; use serde_json::Value; pub fn parse_json(bytes: &[u8]) -> Result { - let value: Value = serde_json::from_slice(bytes) - .map_err(|e| ImportError::Json(e.to_string()))?; + let value: Value = + serde_json::from_slice(bytes).map_err(|e| ImportError::Json(e.to_string()))?; - let arr = value.as_array() + let arr = value + .as_array() .ok_or_else(|| ImportError::Json("expected a JSON array".into()))?; if arr.is_empty() { return Err(ImportError::Empty); } - let first = arr[0].as_object() + let first = arr[0] + .as_object() .ok_or_else(|| ImportError::Json("array elements must be objects".into()))?; let columns: Vec = first.keys().cloned().collect(); @@ -20,12 +22,15 @@ pub fn parse_json(bytes: &[u8]) -> Result { return Err(ImportError::NoHeader); } - let rows: Vec> = arr.iter() + let rows: Vec> = arr + .iter() .enumerate() .map(|(idx, item)| { - let obj = item.as_object() - .ok_or_else(|| ImportError::Json(format!("element at index {} is not an object", idx)))?; - Ok(columns.iter() + let obj = item.as_object().ok_or_else(|| { + ImportError::Json(format!("element at index {} is not an object", idx)) + })?; + Ok(columns + .iter() .map(|col| obj.get(col).map(value_to_string).unwrap_or_default()) .collect()) }) diff --git a/crates/adapters/importer/src/parsers/xlsx.rs b/crates/adapters/importer/src/parsers/xlsx.rs index a88b706..0d5eb09 100644 --- a/crates/adapters/importer/src/parsers/xlsx.rs +++ b/crates/adapters/importer/src/parsers/xlsx.rs @@ -1,24 +1,27 @@ -use calamine::{Reader, open_workbook_from_rs, Xlsx, Data}; -use std::io::Cursor; +use calamine::{Data, Reader, Xlsx, open_workbook_from_rs}; use domain::models::{ImportError, ParsedFile}; +use std::io::Cursor; pub fn parse_xlsx(bytes: &[u8]) -> Result { let cursor = Cursor::new(bytes); let mut workbook: Xlsx<_> = open_workbook_from_rs(cursor) .map_err(|e: calamine::XlsxError| ImportError::Xlsx(e.to_string()))?; - let sheet_name = workbook.sheet_names() + let sheet_name = workbook + .sheet_names() .first() .cloned() .ok_or(ImportError::Empty)?; - let range = workbook.worksheet_range(&sheet_name) + let range = workbook + .worksheet_range(&sheet_name) .map_err(|e| ImportError::Xlsx(e.to_string()))?; let mut iter = range.rows(); let header = iter.next().ok_or(ImportError::NoHeader)?; - let columns: Vec = header.iter() + let columns: Vec = header + .iter() .map(|c| cell_to_string(c).trim().to_string()) .collect(); @@ -46,7 +49,11 @@ fn cell_to_string(cell: &Data) -> String { match cell { Data::String(s) => s.clone(), Data::Float(f) => { - if f.fract() == 0.0 { format!("{}", *f as i64) } else { format!("{}", f) } + if f.fract() == 0.0 { + format!("{}", *f as i64) + } else { + format!("{}", f) + } } Data::Int(i) => i.to_string(), Data::Bool(b) => b.to_string(), diff --git a/crates/adapters/importer/src/tests/mapper.rs b/crates/adapters/importer/src/tests/mapper.rs index 05483d7..5b14c99 100644 --- a/crates/adapters/importer/src/tests/mapper.rs +++ b/crates/adapters/importer/src/tests/mapper.rs @@ -14,9 +14,21 @@ fn sample_file() -> ParsedFile { fn full_mappings() -> Vec { vec![ - FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, - FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) }, - FieldMapping { source_column: "Date".into(), domain_field: DomainField::WatchedAt, transform: Transform::Identity }, + FieldMapping { + source_column: "Name".into(), + domain_field: DomainField::Title, + transform: Transform::Identity, + }, + FieldMapping { + source_column: "Stars".into(), + domain_field: DomainField::Rating, + transform: Transform::RatingScale(0.5), + }, + FieldMapping { + source_column: "Date".into(), + domain_field: DomainField::WatchedAt, + transform: Transform::Identity, + }, ] } @@ -51,9 +63,11 @@ fn marks_missing_required_fields_invalid() { #[test] fn ignores_unmapped_columns() { - let mappings = vec![ - FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, - ]; + let mappings = vec![FieldMapping { + source_column: "Name".into(), + domain_field: DomainField::Title, + transform: Transform::Identity, + }]; let file = ParsedFile { columns: vec!["Name".into(), "Extra".into()], rows: vec![vec!["Inception".into(), "ignored".into()]], @@ -66,9 +80,11 @@ fn ignores_unmapped_columns() { #[test] fn nonexistent_source_column_skipped() { - let mappings = vec![ - FieldMapping { source_column: "DoesNotExist".into(), domain_field: DomainField::Title, transform: Transform::Identity }, - ]; + let mappings = vec![FieldMapping { + source_column: "DoesNotExist".into(), + domain_field: DomainField::Title, + transform: Transform::Identity, + }]; let file = ParsedFile { columns: vec!["Name".into()], rows: vec![vec!["Inception".into()]], @@ -81,8 +97,16 @@ fn nonexistent_source_column_skipped() { #[test] fn collects_all_errors_not_just_first() { let mappings = vec![ - FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, - FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) }, + FieldMapping { + source_column: "Name".into(), + domain_field: DomainField::Title, + transform: Transform::Identity, + }, + FieldMapping { + source_column: "Stars".into(), + domain_field: DomainField::Rating, + transform: Transform::RatingScale(0.5), + }, // no watched_at mapping ]; let file = ParsedFile { @@ -91,8 +115,16 @@ fn collects_all_errors_not_just_first() { }; let results = apply_mapping(&file, &mappings); if let RowResult::Invalid { errors, .. } = &results[0].result { - assert!(errors.iter().any(|e| e.contains("not a number")), "expected rating error, got: {:?}", errors); - assert!(errors.iter().any(|e| e.contains("watched_at")), "expected watched_at error, got: {:?}", errors); + assert!( + errors.iter().any(|e| e.contains("not a number")), + "expected rating error, got: {:?}", + errors + ); + assert!( + errors.iter().any(|e| e.contains("watched_at")), + "expected watched_at error, got: {:?}", + errors + ); } else { panic!("expected Invalid"); } @@ -101,9 +133,21 @@ fn collects_all_errors_not_just_first() { #[test] fn non_numeric_rating_produces_error_in_row() { let mappings = vec![ - FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, - FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) }, - FieldMapping { source_column: "Date".into(), domain_field: DomainField::WatchedAt, transform: Transform::Identity }, + FieldMapping { + source_column: "Name".into(), + domain_field: DomainField::Title, + transform: Transform::Identity, + }, + FieldMapping { + source_column: "Stars".into(), + domain_field: DomainField::Rating, + transform: Transform::RatingScale(0.5), + }, + FieldMapping { + source_column: "Date".into(), + domain_field: DomainField::WatchedAt, + transform: Transform::Identity, + }, ]; let file = ParsedFile { columns: vec!["Name".into(), "Stars".into(), "Date".into()], diff --git a/crates/adapters/metadata/src/tmdb.rs b/crates/adapters/metadata/src/tmdb.rs index 7de9b09..f80e367 100644 --- a/crates/adapters/metadata/src/tmdb.rs +++ b/crates/adapters/metadata/src/tmdb.rs @@ -74,9 +74,7 @@ impl TmdbProvider { } let url = self.base(&format!("/movie/{}", tmdb_id)); - let d: Details = self - .get(&url, &[("append_to_response", "credits")]) - .await?; + let d: Details = self.get(&url, &[("append_to_response", "credits")]).await?; let year: u16 = d .release_date @@ -98,8 +96,8 @@ impl TmdbProvider { let imdb_id = ExternalMetadataId::new(raw_id) .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let title = - MovieTitle::new(d.title).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + let title = MovieTitle::new(d.title) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; let release_year = ReleaseYear::new(year).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; @@ -110,10 +108,7 @@ impl TmdbProvider { .find(|c| c.job == "Director") .map(|c| c.name); - let poster_url = d - .poster_path - .as_deref() - .and_then(|p| self.poster_url(p)); + let poster_url = d.poster_path.as_deref().and_then(|p| self.poster_url(p)); Ok(ProviderMovie { imdb_id, @@ -139,12 +134,13 @@ impl MetadataProvider for TmdbProvider { movie_results: Vec, } let url = self.base(&format!("/find/{}", id.value())); - let resp: FindResponse = - self.get(&url, &[("external_source", "imdb_id")]).await?; + let resp: FindResponse = self.get(&url, &[("external_source", "imdb_id")]).await?; resp.movie_results .into_iter() .next() - .ok_or_else(|| DomainError::NotFound(format!("TMDB: no movie for {}", id.value())))? + .ok_or_else(|| { + DomainError::NotFound(format!("TMDB: no movie for {}", id.value())) + })? .id } MetadataSearchCriteria::Title { title, year } => { diff --git a/crates/adapters/nats/src/config.rs b/crates/adapters/nats/src/config.rs index dc777ae..6395af5 100644 --- a/crates/adapters/nats/src/config.rs +++ b/crates/adapters/nats/src/config.rs @@ -34,16 +34,22 @@ impl NatsConfig { let url = url.ok_or_else(|| anyhow::anyhow!("NATS_URL is not set"))?; let mode = match mode.unwrap_or("jetstream") { - "core" => NatsMode::Core, + "core" => NatsMode::Core, "jetstream" => NatsMode::JetStream, - other => anyhow::bail!("unknown NATS_MODE: {other}"), + other => anyhow::bail!("unknown NATS_MODE: {other}"), }; let subject_prefix = subject_prefix.unwrap_or("movies-diary.events").to_string(); let stream_name = stream_name.unwrap_or("MOVIES_DIARY_EVENTS").to_string(); let consumer_name = consumer_name.unwrap_or("worker").to_string(); - Ok(Self { url: url.to_string(), mode, subject_prefix, stream_name, consumer_name }) + Ok(Self { + url: url.to_string(), + mode, + subject_prefix, + stream_name, + consumer_name, + }) } } diff --git a/crates/adapters/nats/src/publisher.rs b/crates/adapters/nats/src/publisher.rs index 5825e2c..d19d5f1 100644 --- a/crates/adapters/nats/src/publisher.rs +++ b/crates/adapters/nats/src/publisher.rs @@ -1,4 +1,4 @@ -use async_nats::{jetstream, Client}; +use async_nats::{Client, jetstream}; use async_trait::async_trait; use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher}; @@ -16,11 +16,17 @@ pub struct NatsEventPublisher { impl NatsEventPublisher { pub fn new_core(client: Client, subject_prefix: String) -> Self { - Self { mode: PublisherMode::Core(client), subject_prefix } + Self { + mode: PublisherMode::Core(client), + subject_prefix, + } } pub fn new_jetstream(client: Client, subject_prefix: String) -> Self { - Self { mode: PublisherMode::JetStream(jetstream::new(client)), subject_prefix } + Self { + mode: PublisherMode::JetStream(jetstream::new(client)), + subject_prefix, + } } } diff --git a/crates/adapters/nats/src/subject.rs b/crates/adapters/nats/src/subject.rs index 4ba7b1a..e3691e0 100644 --- a/crates/adapters/nats/src/subject.rs +++ b/crates/adapters/nats/src/subject.rs @@ -2,15 +2,15 @@ use domain::events::DomainEvent; pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String { let suffix = match event { - DomainEvent::ReviewLogged { .. } => "review.logged", - DomainEvent::ReviewUpdated { .. } => "review.updated", - DomainEvent::ReviewDeleted { .. } => "review.deleted", + DomainEvent::ReviewLogged { .. } => "review.logged", + DomainEvent::ReviewUpdated { .. } => "review.updated", + DomainEvent::ReviewDeleted { .. } => "review.deleted", DomainEvent::MovieDiscovered { .. } => "movie.discovered", - DomainEvent::MovieDeleted { .. } => "movie.deleted", - DomainEvent::UserUpdated { .. } => "user.updated", - DomainEvent::MovieEnrichmentRequested { .. } => "movie.enrichment.requested", - DomainEvent::ImageStored { .. } => "image.stored", - DomainEvent::WatchlistEntryAdded { .. } => "watchlist.entry.added", + DomainEvent::MovieDeleted { .. } => "movie.deleted", + DomainEvent::UserUpdated { .. } => "user.updated", + DomainEvent::MovieEnrichmentRequested { .. } => "movie.enrichment.requested", + DomainEvent::ImageStored { .. } => "image.stored", + DomainEvent::WatchlistEntryAdded { .. } => "watchlist.entry.added", DomainEvent::WatchlistEntryRemoved { .. } => "watchlist.entry.removed", DomainEvent::FollowAccepted { .. } => "follow.accepted", }; diff --git a/crates/adapters/nats/src/tests/config.rs b/crates/adapters/nats/src/tests/config.rs index 10ba147..af9ed72 100644 --- a/crates/adapters/nats/src/tests/config.rs +++ b/crates/adapters/nats/src/tests/config.rs @@ -17,11 +17,14 @@ fn defaults_with_only_url() { #[test] fn core_mode_parsed() { - let cfg = NatsConfig::from_vars(Some("nats://test:4222"), Some("core"), None, None, None).unwrap(); + let cfg = + NatsConfig::from_vars(Some("nats://test:4222"), Some("core"), None, None, None).unwrap(); assert_eq!(cfg.mode, NatsMode::Core); } #[test] fn invalid_mode_errors() { - assert!(NatsConfig::from_vars(Some("nats://test:4222"), Some("kafka"), None, None, None).is_err()); + assert!( + NatsConfig::from_vars(Some("nats://test:4222"), Some("kafka"), None, None, None).is_err() + ); } diff --git a/crates/adapters/nats/src/tests/subject.rs b/crates/adapters/nats/src/tests/subject.rs index 4bfd721..11f741a 100644 --- a/crates/adapters/nats/src/tests/subject.rs +++ b/crates/adapters/nats/src/tests/subject.rs @@ -4,7 +4,9 @@ use domain::value_objects::{ExternalMetadataId, MovieId, Rating, ReviewId, UserI use uuid::Uuid; fn dt() -> NaiveDateTime { - chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc() + chrono::DateTime::from_timestamp(1_700_000_000, 0) + .unwrap() + .naive_utc() } #[test] diff --git a/crates/adapters/poster-fetcher/src/lib.rs b/crates/adapters/poster-fetcher/src/lib.rs index 7cd5463..b0a1c2d 100644 --- a/crates/adapters/poster-fetcher/src/lib.rs +++ b/crates/adapters/poster-fetcher/src/lib.rs @@ -38,5 +38,7 @@ impl PosterFetcherClient for ReqwestPosterFetcher { } pub fn create() -> anyhow::Result> { - Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?)) + Ok(std::sync::Arc::new(ReqwestPosterFetcher::new( + PosterFetcherConfig::from_env(), + )?)) } diff --git a/crates/adapters/poster-sync/src/lib.rs b/crates/adapters/poster-sync/src/lib.rs index b83292e..6732818 100644 --- a/crates/adapters/poster-sync/src/lib.rs +++ b/crates/adapters/poster-sync/src/lib.rs @@ -4,7 +4,10 @@ use async_trait::async_trait; use domain::{ errors::DomainError, events::DomainEvent, - ports::{EventHandler, EventPublisher, ImageStorage, MetadataClient, MovieRepository, PosterFetcherClient}, + ports::{ + EventHandler, EventPublisher, ImageStorage, MetadataClient, MovieRepository, + PosterFetcherClient, + }, value_objects::{ExternalMetadataId, MovieId, PosterPath}, }; @@ -26,10 +29,21 @@ impl PosterSyncHandler { event_publisher: Arc, max_retries: u32, ) -> Self { - Self { movie_repository, metadata_client, poster_fetcher, image_storage, event_publisher, max_retries } + Self { + movie_repository, + metadata_client, + poster_fetcher, + image_storage, + event_publisher, + max_retries, + } } - async fn sync(&self, movie_id: MovieId, external_metadata_id: ExternalMetadataId) -> Result<(), DomainError> { + async fn sync( + &self, + movie_id: MovieId, + external_metadata_id: ExternalMetadataId, + ) -> Result<(), DomainError> { let mut movie = match self.movie_repository.get_movie_by_id(&movie_id).await? { Some(m) => m, None => { @@ -38,7 +52,11 @@ impl PosterSyncHandler { } }; - let poster_url = match self.metadata_client.get_poster_url(&external_metadata_id).await { + let poster_url = match self + .metadata_client + .get_poster_url(&external_metadata_id) + .await + { Ok(Some(url)) => url, Ok(None) => return Ok(()), Err(e) => { @@ -48,9 +66,15 @@ impl PosterSyncHandler { }; let image_bytes = self.poster_fetcher.fetch_poster_bytes(&poster_url).await?; - let stored_path = self.image_storage.store(&movie_id.value().to_string(), &image_bytes).await?; - if let Err(e) = self.event_publisher - .publish(&DomainEvent::ImageStored { key: stored_path.clone() }) + let stored_path = self + .image_storage + .store(&movie_id.value().to_string(), &image_bytes) + .await?; + if let Err(e) = self + .event_publisher + .publish(&DomainEvent::ImageStored { + key: stored_path.clone(), + }) .await { tracing::warn!("failed to emit ImageStored for {stored_path}: {e}"); @@ -66,10 +90,14 @@ impl PosterSyncHandler { impl EventHandler for PosterSyncHandler { async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { let (movie_id, external_metadata_id) = match event { - DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => { - (movie_id.value(), external_metadata_id.value().to_owned()) - } - DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id } => { + DomainEvent::MovieDiscovered { + movie_id, + external_metadata_id, + } => (movie_id.value(), external_metadata_id.value().to_owned()), + DomainEvent::MovieEnrichmentRequested { + movie_id, + external_metadata_id, + } => { // Only sync poster if the movie doesn't have one yet let already_has_poster = self .movie_repository @@ -90,7 +118,10 @@ impl EventHandler for PosterSyncHandler { let mut last_err: Option = None; for attempt in 0..=self.max_retries { - match self.sync(movie_id.clone(), external_metadata_id.clone()).await { + match self + .sync(movie_id.clone(), external_metadata_id.clone()) + .await + { Ok(()) => return Ok(()), Err(e) => { if attempt < self.max_retries { @@ -109,7 +140,10 @@ impl EventHandler for PosterSyncHandler { } let err = last_err.expect("loop runs at least once"); - tracing::error!(attempts = self.max_retries + 1, "poster sync failed after all attempts: {err}"); + tracing::error!( + attempts = self.max_retries + 1, + "poster sync failed after all attempts: {err}" + ); Err(err) } } diff --git a/crates/adapters/postgres-event-queue/src/lib.rs b/crates/adapters/postgres-event-queue/src/lib.rs index 74dd74b..cc4004b 100644 --- a/crates/adapters/postgres-event-queue/src/lib.rs +++ b/crates/adapters/postgres-event-queue/src/lib.rs @@ -18,33 +18,42 @@ use payload::DbEventPayload; pub struct DbEventQueueConfig { pub poll_interval_ms: u64, - pub batch_size: i64, - pub max_attempts: i32, + pub batch_size: i64, + pub max_attempts: i32, } impl DbEventQueueConfig { pub fn from_env() -> Self { Self { poll_interval_ms: std::env::var("EVENT_QUEUE_POLL_INTERVAL_MS") - .ok().and_then(|v| v.parse().ok()).unwrap_or(500), + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(500), batch_size: std::env::var("EVENT_QUEUE_BATCH_SIZE") - .ok().and_then(|v| v.parse().ok()).unwrap_or(10), + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(10), max_attempts: std::env::var("EVENT_QUEUE_MAX_ATTEMPTS") - .ok().and_then(|v| v.parse().ok()).unwrap_or(5), + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5), } } } #[derive(Clone)] pub struct PostgresEventQueue { - pool: PgPool, + pool: PgPool, config: Arc, } impl PostgresEventQueue { pub async fn create(pool: PgPool, config: DbEventQueueConfig) -> anyhow::Result { migrations::run(&pool).await?; - Ok(Self { pool, config: Arc::new(config) }) + Ok(Self { + pool, + config: Arc::new(config), + }) } pub async fn create_publisher(pool: PgPool) -> anyhow::Result> { @@ -68,14 +77,12 @@ impl EventPublisher for PostgresEventQueue { let payload_json = serde_json::to_string(&db_payload) .map_err(|e| DomainError::InfrastructureError(format!("serialize: {e}")))?; - sqlx::query( - "INSERT INTO event_queue (event_type, payload) VALUES ($1, $2)" - ) - .bind(event_type) - .bind(payload_json) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?; + sqlx::query("INSERT INTO event_queue (event_type, payload) VALUES ($1, $2)") + .bind(event_type) + .bind(payload_json) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?; Ok(()) } @@ -83,10 +90,10 @@ impl EventPublisher for PostgresEventQueue { impl EventConsumer for PostgresEventQueue { fn consume(&self) -> BoxStream<'_, Result> { - let pool = self.pool.clone(); - let config = Arc::clone(&self.config); - let (tx, rx) = mpsc::channel(128); - let rx = Arc::new(Mutex::new(rx)); + let pool = self.pool.clone(); + let config = Arc::clone(&self.config); + let (tx, rx) = mpsc::channel(128); + let rx = Arc::new(Mutex::new(rx)); tokio::spawn(async move { let poll_interval = Duration::from_millis(config.poll_interval_ms); @@ -124,13 +131,13 @@ impl EventConsumer for PostgresEventQueue { #[derive(sqlx::FromRow)] struct QueueRow { - id: i64, - payload: String, + id: i64, + payload: String, attempts: i32, } async fn claim_batch( - pool: &PgPool, + pool: &PgPool, config: &DbEventQueueConfig, ) -> Result, DomainError> { // CTE with FOR UPDATE SKIP LOCKED — atomic and safe for multiple workers @@ -148,7 +155,7 @@ async fn claim_batch( FROM claimed WHERE q.id = claimed.id RETURNING q.id, q.payload, q.attempts - "# + "#, ) .bind(config.batch_size) .fetch_all(pool) @@ -159,25 +166,28 @@ async fn claim_batch( } fn decode_row( - pool: &PgPool, - row: QueueRow, + pool: &PgPool, + row: QueueRow, max_attempts: i32, ) -> Result { let db_payload: DbEventPayload = serde_json::from_str(&row.payload) .map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?; let event = DomainEvent::try_from(db_payload)?; - Ok(EventEnvelope::new(event, Box::new(DbAckHandle { - pool: pool.clone(), - row_id: row.id, - attempts: row.attempts, - max_attempts, - }))) + Ok(EventEnvelope::new( + event, + Box::new(DbAckHandle { + pool: pool.clone(), + row_id: row.id, + attempts: row.attempts, + max_attempts, + }), + )) } struct DbAckHandle { - pool: PgPool, - row_id: i64, - attempts: i32, + pool: PgPool, + row_id: i64, + attempts: i32, max_attempts: i32, } diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs index cb527da..ef7cf5a 100644 --- a/crates/adapters/postgres-federation/src/lib.rs +++ b/crates/adapters/postgres-federation/src/lib.rs @@ -7,7 +7,7 @@ use activitypub::RemoteReviewRepository; use activitypub_base::{ BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, }; -use domain::models::{Review, ReviewSource, RemoteWatchlistEntry}; +use domain::models::{RemoteWatchlistEntry, Review, ReviewSource}; use domain::ports::RemoteWatchlistRepository; fn datetime_to_str(dt: &NaiveDateTime) -> String { @@ -112,19 +112,31 @@ impl FederationRepository for PostgresFederationRepository { .bind(&uid) .fetch_all(&self.pool) .await?; - Ok(rows.into_iter().map(|row| { - let url: String = row.get("remote_actor_url"); - let status_str: String = row.get("status"); - let handle: String = row.try_get("handle").unwrap_or_default(); - let inbox_url: String = row.try_get("inbox_url").unwrap_or_default(); - let shared_inbox_url: Option = row.try_get("shared_inbox_url").ok().flatten(); - let display_name: Option = row.try_get("display_name").ok().flatten(); - let avatar_url: Option = row.try_get("avatar_url").ok().flatten(); - Follower { - actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url: row.try_get("outbox_url").ok().flatten() }, - status: str_to_status(&status_str), - } - }).collect()) + Ok(rows + .into_iter() + .map(|row| { + let url: String = row.get("remote_actor_url"); + let status_str: String = row.get("status"); + let handle: String = row.try_get("handle").unwrap_or_default(); + let inbox_url: String = row.try_get("inbox_url").unwrap_or_default(); + let shared_inbox_url: Option = + row.try_get("shared_inbox_url").ok().flatten(); + let display_name: Option = row.try_get("display_name").ok().flatten(); + let avatar_url: Option = row.try_get("avatar_url").ok().flatten(); + Follower { + actor: RemoteActor { + url, + handle, + inbox_url, + shared_inbox_url, + display_name, + avatar_url, + outbox_url: row.try_get("outbox_url").ok().flatten(), + }, + status: str_to_status(&status_str), + } + }) + .collect()) } async fn get_followers_page( @@ -152,22 +164,31 @@ impl FederationRepository for PostgresFederationRepository { .fetch_all(&self.pool) .await?; - Ok(rows.into_iter().map(|row| { - let url: String = row.get("remote_actor_url"); - let status_str: String = row.get("status"); - let handle: String = row.try_get("handle").unwrap_or_default(); - let inbox_url: String = row.try_get("inbox_url").unwrap_or_default(); - let shared_inbox_url: Option = row.try_get("shared_inbox_url").ok().flatten(); - let display_name: Option = row.try_get("display_name").ok().flatten(); - let avatar_url: Option = row.try_get("avatar_url").ok().flatten(); - Follower { - actor: RemoteActor { - url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, - outbox_url: row.try_get("outbox_url").ok().flatten(), - }, - status: str_to_status(&status_str), - } - }).collect()) + Ok(rows + .into_iter() + .map(|row| { + let url: String = row.get("remote_actor_url"); + let status_str: String = row.get("status"); + let handle: String = row.try_get("handle").unwrap_or_default(); + let inbox_url: String = row.try_get("inbox_url").unwrap_or_default(); + let shared_inbox_url: Option = + row.try_get("shared_inbox_url").ok().flatten(); + let display_name: Option = row.try_get("display_name").ok().flatten(); + let avatar_url: Option = row.try_get("avatar_url").ok().flatten(); + Follower { + actor: RemoteActor { + url, + handle, + inbox_url, + shared_inbox_url, + display_name, + avatar_url, + outbox_url: row.try_get("outbox_url").ok().flatten(), + }, + status: str_to_status(&status_str), + } + }) + .collect()) } async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result { @@ -264,15 +285,18 @@ impl FederationRepository for PostgresFederationRepository { .bind(&uid) .fetch_all(&self.pool) .await?; - Ok(rows.into_iter().map(|row| RemoteActor { - url: row.get("url"), - handle: row.get("handle"), - inbox_url: row.get("inbox_url"), - shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), - display_name: row.try_get("display_name").ok().flatten(), - avatar_url: row.try_get("avatar_url").ok().flatten(), - outbox_url: row.try_get("outbox_url").ok().flatten(), - }).collect()) + Ok(rows + .into_iter() + .map(|row| RemoteActor { + url: row.get("url"), + handle: row.get("handle"), + inbox_url: row.get("inbox_url"), + shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), + display_name: row.try_get("display_name").ok().flatten(), + avatar_url: row.try_get("avatar_url").ok().flatten(), + outbox_url: row.try_get("outbox_url").ok().flatten(), + }) + .collect()) } async fn count_following(&self, local_user_id: uuid::Uuid) -> Result { @@ -310,15 +334,18 @@ impl FederationRepository for PostgresFederationRepository { .fetch_all(&self.pool) .await?; - Ok(rows.into_iter().map(|row| RemoteActor { - url: row.get("url"), - handle: row.get("handle"), - inbox_url: row.get("inbox_url"), - shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), - display_name: row.try_get("display_name").ok().flatten(), - avatar_url: row.try_get("avatar_url").ok().flatten(), - outbox_url: row.try_get("outbox_url").ok().flatten(), - }).collect()) + Ok(rows + .into_iter() + .map(|row| RemoteActor { + url: row.get("url"), + handle: row.get("handle"), + inbox_url: row.get("inbox_url"), + shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), + display_name: row.try_get("display_name").ok().flatten(), + avatar_url: row.try_get("avatar_url").ok().flatten(), + outbox_url: row.try_get("outbox_url").ok().flatten(), + }) + .collect()) } async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { @@ -368,12 +395,16 @@ impl FederationRepository for PostgresFederationRepository { })) } - async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result> { + async fn get_local_actor_keypair( + &self, + user_id: uuid::Uuid, + ) -> Result> { let uid = user_id.to_string(); - let row = sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1") - .bind(&uid) - .fetch_optional(&self.pool) - .await?; + let row = + sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1") + .bind(&uid) + .fetch_optional(&self.pool) + .await?; Ok(row.map(|r| (r.get("public_key"), r.get("private_key")))) } @@ -413,15 +444,18 @@ impl FederationRepository for PostgresFederationRepository { .bind(&uid) .fetch_all(&self.pool) .await?; - Ok(rows.into_iter().map(|row| RemoteActor { - url: row.get("remote_actor_url"), - handle: row.try_get("handle").unwrap_or_default(), - inbox_url: row.try_get("inbox_url").unwrap_or_default(), - shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), - display_name: row.try_get("display_name").ok().flatten(), - avatar_url: row.try_get("avatar_url").ok().flatten(), - outbox_url: row.try_get("outbox_url").ok().flatten(), - }).collect()) + Ok(rows + .into_iter() + .map(|row| RemoteActor { + url: row.get("remote_actor_url"), + handle: row.try_get("handle").unwrap_or_default(), + inbox_url: row.try_get("inbox_url").unwrap_or_default(), + shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), + display_name: row.try_get("display_name").ok().flatten(), + avatar_url: row.try_get("avatar_url").ok().flatten(), + outbox_url: row.try_get("outbox_url").ok().flatten(), + }) + .collect()) } async fn update_following_status( @@ -536,12 +570,11 @@ impl FederationRepository for PostgresFederationRepository { } async fn is_domain_blocked(&self, domain: &str) -> Result { - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM blocked_domains WHERE domain = $1", - ) - .bind(domain) - .fetch_one(&self.pool) - .await?; + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = $1") + .bind(domain) + .fetch_one(&self.pool) + .await?; Ok(count > 0) } @@ -581,7 +614,10 @@ impl FederationRepository for PostgresFederationRepository { .bind(&uid) .fetch_all(&self.pool) .await?; - Ok(rows.iter().map(|r| r.get::("remote_actor_url")).collect()) + Ok(rows + .iter() + .map(|r| r.get::("remote_actor_url")) + .collect()) } async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result { @@ -609,7 +645,9 @@ impl RemoteReviewRepository for PostgresFederationRepository { ) -> Result<()> { let actor_url = match review.source() { ReviewSource::Remote { actor_url } => actor_url.clone(), - ReviewSource::Local => return Err(anyhow!("save_remote_review called with a local review")), + ReviewSource::Local => { + return Err(anyhow!("save_remote_review called with a local review")); + } }; let movie_id = review.movie_id().value().to_string(); sqlx::query( @@ -719,7 +757,16 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository { .fetch_all(&self.pool) .await .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - Ok(rows.into_iter().map(|(url, handle, display_name)| domain::ports::RemoteActorInfo { url, handle, display_name }).collect()) + Ok(rows + .into_iter() + .map( + |(url, handle, display_name)| domain::ports::RemoteActorInfo { + url, + handle, + display_name, + }, + ) + .collect()) } } @@ -747,19 +794,24 @@ impl RemoteWatchlistRepository for PostgresFederationRepository { Ok(()) } - async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), domain::errors::DomainError> { - sqlx::query( - "DELETE FROM ap_remote_watchlist_entries WHERE ap_id = $1 AND actor_url = $2", - ) - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; + async fn remove_by_ap_id( + &self, + ap_id: &str, + actor_url: &str, + ) -> Result<(), domain::errors::DomainError> { + sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE ap_id = $1 AND actor_url = $2") + .bind(ap_id) + .bind(actor_url) + .execute(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; Ok(()) } - async fn get_by_actor_url(&self, actor_url: &str) -> Result, domain::errors::DomainError> { + async fn get_by_actor_url( + &self, + actor_url: &str, + ) -> Result, domain::errors::DomainError> { let rows = sqlx::query( "SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \ FROM ap_remote_watchlist_entries WHERE actor_url = $1 ORDER BY added_at DESC", @@ -769,21 +821,27 @@ impl RemoteWatchlistRepository for PostgresFederationRepository { .await .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - rows.into_iter().map(|row| { - Ok(RemoteWatchlistEntry { - ap_id: row.try_get("ap_id").unwrap_or_default(), - actor_url: row.try_get("actor_url").unwrap_or_default(), - movie_title: row.try_get("movie_title").unwrap_or_default(), - release_year: row.try_get::("release_year").unwrap_or(0) as u16, - external_metadata_id: row.try_get("external_metadata_id").ok().flatten(), - poster_url: row.try_get("poster_url").ok().flatten(), - added_at: row.try_get::, _>("added_at") - .unwrap_or_else(|_| chrono::Utc::now()), + rows.into_iter() + .map(|row| { + Ok(RemoteWatchlistEntry { + ap_id: row.try_get("ap_id").unwrap_or_default(), + actor_url: row.try_get("actor_url").unwrap_or_default(), + movie_title: row.try_get("movie_title").unwrap_or_default(), + release_year: row.try_get::("release_year").unwrap_or(0) as u16, + external_metadata_id: row.try_get("external_metadata_id").ok().flatten(), + poster_url: row.try_get("poster_url").ok().flatten(), + added_at: row + .try_get::, _>("added_at") + .unwrap_or_else(|_| chrono::Utc::now()), + }) }) - }).collect() + .collect() } - async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), domain::errors::DomainError> { + async fn remove_all_by_actor( + &self, + actor_url: &str, + ) -> Result<(), domain::errors::DomainError> { sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = $1") .bind(actor_url) .execute(&self.pool) @@ -792,18 +850,22 @@ impl RemoteWatchlistRepository for PostgresFederationRepository { Ok(()) } - async fn get_by_derived_uuid(&self, uuid: uuid::Uuid) -> Result, domain::errors::DomainError> { - let actors: Vec = sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries") - .fetch_all(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))? - .into_iter() - .filter_map(|row| row.try_get::("actor_url").ok()) - .collect(); + async fn get_by_derived_uuid( + &self, + uuid: uuid::Uuid, + ) -> Result, domain::errors::DomainError> { + let actors: Vec = + sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries") + .fetch_all(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))? + .into_iter() + .filter_map(|row| row.try_get::("actor_url").ok()) + .collect(); - let target = actors.into_iter().find(|url| { - uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid - }); + let target = actors + .into_iter() + .find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid); match target { None => Ok(vec![]), @@ -812,7 +874,9 @@ impl RemoteWatchlistRepository for PostgresFederationRepository { } } -pub fn wire(pool: sqlx::PgPool) -> ( +pub fn wire( + pool: sqlx::PgPool, +) -> ( std::sync::Arc, std::sync::Arc, std::sync::Arc, diff --git a/crates/adapters/postgres-search/src/lib.rs b/crates/adapters/postgres-search/src/lib.rs index 9125714..14de58a 100644 --- a/crates/adapters/postgres-search/src/lib.rs +++ b/crates/adapters/postgres-search/src/lib.rs @@ -3,14 +3,13 @@ use std::sync::Arc; use async_trait::async_trait; use domain::{ errors::DomainError, - models::{ - EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, - SearchQuery, SearchResults, - collections::Paginated, - }, models::PersonId, - value_objects::MovieId, + models::{ + collections::Paginated, EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, + SearchQuery, SearchResults, + }, ports::{SearchCommand, SearchPort}, + value_objects::MovieId, }; use sqlx::PgPool; @@ -26,7 +25,10 @@ impl PostgresSearchAdapter { pub fn create_search_adapter(pool: PgPool) -> (Arc, Arc) { let adapter = Arc::new(PostgresSearchAdapter::new(pool)); - (Arc::clone(&adapter) as Arc, adapter as Arc) + ( + Arc::clone(&adapter) as Arc, + adapter as Arc, + ) } fn map_err(e: sqlx::Error) -> DomainError { @@ -41,17 +43,39 @@ impl SearchCommand for PostgresSearchAdapter { let movie_id = id.value().to_string(); let title = movie.title().value().to_string(); let director = movie.director().unwrap_or("").to_string(); - let (overview, genres, keywords, cast_names, crew_names) = - match profile.as_deref() { - Some(p) => ( - p.overview.clone().unwrap_or_default(), - p.genres.iter().map(|g| g.name.as_str()).collect::>().join(" "), - p.keywords.iter().map(|k| k.name.as_str()).collect::>().join(" "), - p.cast.iter().map(|c| c.name.as_str()).collect::>().join(" "), - p.crew.iter().map(|c| c.name.as_str()).collect::>().join(" "), - ), - None => (String::new(), String::new(), String::new(), String::new(), String::new()), - }; + let (overview, genres, keywords, cast_names, crew_names) = match profile.as_deref() + { + Some(p) => ( + p.overview.clone().unwrap_or_default(), + p.genres + .iter() + .map(|g| g.name.as_str()) + .collect::>() + .join(" "), + p.keywords + .iter() + .map(|k| k.name.as_str()) + .collect::>() + .join(" "), + p.cast + .iter() + .map(|c| c.name.as_str()) + .collect::>() + .join(" "), + p.crew + .iter() + .map(|c| c.name.as_str()) + .collect::>() + .join(" "), + ), + None => ( + String::new(), + String::new(), + String::new(), + String::new(), + String::new(), + ), + }; let fts_input = format!( "{} {} {} {} {} {} {}", @@ -127,7 +151,10 @@ impl SearchPort for PostgresSearchAdapter { } impl PostgresSearchAdapter { - async fn search_movies(&self, query: &SearchQuery) -> Result, DomainError> { + async fn search_movies( + &self, + query: &SearchQuery, + ) -> Result, DomainError> { let limit = query.page.limit as i64; let offset = query.page.offset as i64; @@ -214,24 +241,36 @@ impl PostgresSearchAdapter { .map_err(map_err)? }; - let items = rows.into_iter().map(|r| MovieSearchHit { - movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), - title: r.title, - release_year: r.release_year.map(|y| y as u16), - director: r.director, - poster_path: r.poster_path, - genres: r.genres - .unwrap_or_default() - .split(',') - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(), - }).collect::>(); + let items = rows + .into_iter() + .map(|r| MovieSearchHit { + movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), + title: r.title, + release_year: r.release_year.map(|y| y as u16), + director: r.director, + poster_path: r.poster_path, + genres: r + .genres + .unwrap_or_default() + .split(',') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(), + }) + .collect::>(); - Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset }) + Ok(Paginated { + items, + total_count: total, + limit: query.page.limit, + offset: query.page.offset, + }) } - async fn search_people(&self, query: &SearchQuery) -> Result, DomainError> { + async fn search_people( + &self, + query: &SearchQuery, + ) -> Result, DomainError> { let Some(text) = &query.text else { return Ok(Paginated { items: vec![], @@ -299,7 +338,7 @@ impl PostgresSearchAdapter { items.push(PersonSearchHit { person_id: PersonId::from_uuid( - uuid::Uuid::parse_str(&row.person_id).unwrap_or_default() + uuid::Uuid::parse_str(&row.person_id).unwrap_or_default(), ), name: row.name, known_for_department: row.known_for_department, @@ -308,6 +347,11 @@ impl PostgresSearchAdapter { }); } - Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset }) + Ok(Paginated { + items, + total_count: total, + limit: query.page.limit, + offset: query.page.offset, + }) } } diff --git a/crates/adapters/postgres/src/image_ref.rs b/crates/adapters/postgres/src/image_ref.rs index fe22af7..d532cfb 100644 --- a/crates/adapters/postgres/src/image_ref.rs +++ b/crates/adapters/postgres/src/image_ref.rs @@ -1,5 +1,8 @@ use async_trait::async_trait; -use domain::{errors::DomainError, ports::{ImageRefCommand, ImageRefQuery}}; +use domain::{ + errors::DomainError, + ports::{ImageRefCommand, ImageRefQuery}, +}; use sqlx::PgPool; use std::sync::Arc; @@ -15,23 +18,34 @@ impl PostgresImageRefAdapter { pub fn create_image_ref(pool: PgPool) -> (Arc, Arc) { let adapter = Arc::new(PostgresImageRefAdapter::new(pool)); - (Arc::clone(&adapter) as Arc, adapter as Arc) + ( + Arc::clone(&adapter) as Arc, + adapter as Arc, + ) } #[async_trait] impl ImageRefCommand for PostgresImageRefAdapter { async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> { - let mut tx = self.pool.begin().await + let mut tx = self + .pool + .begin() + .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; sqlx::query("UPDATE users SET avatar_path = $1 WHERE avatar_path = $2") - .bind(new_key).bind(old_key) - .execute(&mut *tx).await + .bind(new_key) + .bind(old_key) + .execute(&mut *tx) + .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; sqlx::query("UPDATE movies SET poster_path = $1 WHERE poster_path = $2") - .bind(new_key).bind(old_key) - .execute(&mut *tx).await + .bind(new_key) + .bind(old_key) + .execute(&mut *tx) + .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - tx.commit().await + tx.commit() + .await .map_err(|e| DomainError::InfrastructureError(e.to_string())) } } diff --git a/crates/adapters/postgres/src/import_profile.rs b/crates/adapters/postgres/src/import_profile.rs index d506d63..d66affb 100644 --- a/crates/adapters/postgres/src/import_profile.rs +++ b/crates/adapters/postgres/src/import_profile.rs @@ -14,12 +14,20 @@ use sqlx::PgPool; #[derive(Serialize, Deserialize)] enum DomainFieldJson { - Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId, + Title, + ReleaseYear, + Director, + Rating, + WatchedAt, + Comment, + ExternalMetadataId, } #[derive(Serialize, Deserialize)] enum TransformJson { - RatingScale(f64), DateFormat(String), Identity, + RatingScale(f64), + DateFormat(String), + Identity, } #[derive(Serialize, Deserialize)] @@ -75,8 +83,8 @@ fn serialize_mappings(ms: &[FieldMapping]) -> Result { } fn deserialize_mappings(s: &str) -> Result, DomainError> { - let js: Vec = serde_json::from_str(s) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + let js: Vec = + serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; Ok(js.into_iter().map(mapping_from_json).collect()) } @@ -85,7 +93,9 @@ pub struct PostgresImportProfileRepository { } impl PostgresImportProfileRepository { - pub fn new(pool: PgPool) -> Self { Self { pool } } + pub fn new(pool: PgPool) -> Self { + Self { pool } + } fn map_err(e: sqlx::Error) -> DomainError { tracing::error!("DB error: {:?}", e); @@ -115,7 +125,13 @@ impl ImportProfileRepository for PostgresImportProfileRepository { let uid = user_id.value().to_string(); #[derive(sqlx::FromRow)] - struct Row { id: String, user_id: String, name: String, field_mappings: String, created_at: NaiveDateTime } + struct Row { + id: String, + user_id: String, + name: String, + field_mappings: String, + created_at: NaiveDateTime, + } let rows = sqlx::query_as::<_, Row>( "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = $1 ORDER BY created_at DESC", @@ -125,25 +141,42 @@ impl ImportProfileRepository for PostgresImportProfileRepository { .await .map_err(Self::map_err)?; - rows.into_iter().map(|r| Ok(ImportProfile { - id: ImportProfileId::from_uuid( - r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? - ), - user_id: UserId::from_uuid( - r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? - ), - name: r.name, - field_mappings: deserialize_mappings(&r.field_mappings)?, - created_at: r.created_at, - })).collect() + rows.into_iter() + .map(|r| { + Ok(ImportProfile { + id: ImportProfileId::from_uuid( + r.id.parse::() + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + ), + user_id: UserId::from_uuid( + r.user_id + .parse::() + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + ), + name: r.name, + field_mappings: deserialize_mappings(&r.field_mappings)?, + created_at: r.created_at, + }) + }) + .collect() } - async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result, DomainError> { + async fn get( + &self, + id: &ImportProfileId, + user_id: &UserId, + ) -> Result, DomainError> { let id_str = id.value().to_string(); let uid_str = user_id.value().to_string(); #[derive(sqlx::FromRow)] - struct Row { id: String, user_id: String, name: String, field_mappings: String, created_at: NaiveDateTime } + struct Row { + id: String, + user_id: String, + name: String, + field_mappings: String, + created_at: NaiveDateTime, + } let row = sqlx::query_as::<_, Row>( "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = $1 AND user_id = $2", @@ -153,17 +186,23 @@ impl ImportProfileRepository for PostgresImportProfileRepository { .await .map_err(Self::map_err)?; - row.map(|r| Ok(ImportProfile { - id: ImportProfileId::from_uuid( - r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? - ), - user_id: UserId::from_uuid( - r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? - ), - name: r.name, - field_mappings: deserialize_mappings(&r.field_mappings)?, - created_at: r.created_at, - })).transpose() + row.map(|r| { + Ok(ImportProfile { + id: ImportProfileId::from_uuid( + r.id.parse::() + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + ), + user_id: UserId::from_uuid( + r.user_id + .parse::() + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + ), + name: r.name, + field_mappings: deserialize_mappings(&r.field_mappings)?, + created_at: r.created_at, + }) + }) + .transpose() } async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> { diff --git a/crates/adapters/postgres/src/import_session.rs b/crates/adapters/postgres/src/import_session.rs index 56fe134..6448d8e 100644 --- a/crates/adapters/postgres/src/import_session.rs +++ b/crates/adapters/postgres/src/import_session.rs @@ -22,7 +22,13 @@ struct ParsedFileJson { #[derive(Serialize, Deserialize)] enum DomainFieldJson { - Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId, + Title, + ReleaseYear, + Director, + Rating, + WatchedAt, + Comment, + ExternalMetadataId, } #[derive(Serialize, Deserialize)] @@ -41,19 +47,29 @@ struct FieldMappingJson { #[derive(Serialize, Deserialize, Default)] struct ImportRowJson { - #[serde(skip_serializing_if = "Option::is_none")] title: Option, - #[serde(skip_serializing_if = "Option::is_none")] release_year: Option, - #[serde(skip_serializing_if = "Option::is_none")] director: Option, - #[serde(skip_serializing_if = "Option::is_none")] rating: Option, - #[serde(skip_serializing_if = "Option::is_none")] watched_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] comment: Option, - #[serde(skip_serializing_if = "Option::is_none")] external_metadata_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + release_year: Option, + #[serde(skip_serializing_if = "Option::is_none")] + director: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rating: Option, + #[serde(skip_serializing_if = "Option::is_none")] + watched_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + comment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + external_metadata_id: Option, } #[derive(Serialize, Deserialize)] enum RowResultJson { Valid(ImportRowJson), - Invalid { errors: Vec, raw: Vec<(String, String)> }, + Invalid { + errors: Vec, + raw: Vec<(String, String)>, + }, } #[derive(Serialize, Deserialize)] @@ -182,22 +198,37 @@ pub struct PostgresImportSessionRepository { } impl PostgresImportSessionRepository { - pub fn new(pool: PgPool) -> Self { Self { pool } } + pub fn new(pool: PgPool) -> Self { + Self { pool } + } fn map_err(e: sqlx::Error) -> DomainError { tracing::error!("DB error: {:?}", e); DomainError::InfrastructureError("Database operation failed".into()) } - fn serialize_session(s: &ImportSession) -> Result<(String, Option, Option), DomainError> { - let parsed = s.parsed_file.as_ref() - .map(|f| ser(&ParsedFileJson { columns: f.columns.clone(), rows: f.rows.clone() })) + fn serialize_session( + s: &ImportSession, + ) -> Result<(String, Option, Option), DomainError> { + let parsed = s + .parsed_file + .as_ref() + .map(|f| { + ser(&ParsedFileJson { + columns: f.columns.clone(), + rows: f.rows.clone(), + }) + }) .transpose()? .unwrap_or_default(); - let mappings = s.field_mappings.as_ref() + let mappings = s + .field_mappings + .as_ref() .map(|ms| ser(&ms.iter().map(mapping_to_json).collect::>())) .transpose()?; - let results = s.row_results.as_ref() + let results = s + .row_results + .as_ref() .map(|rs| ser(&rs.iter().map(annotated_to_json).collect::>())) .transpose()?; Ok((parsed, mappings, results)) @@ -216,15 +247,20 @@ impl PostgresImportSessionRepository { None } else { let j: ParsedFileJson = de(&parsed_data)?; - Some(ParsedFile { columns: j.columns, rows: j.rows }) + Some(ParsedFile { + columns: j.columns, + rows: j.rows, + }) }; - let field_mappings = field_mappings.as_deref() + let field_mappings = field_mappings + .as_deref() .map(|s| -> Result, DomainError> { let js: Vec = de(s)?; Ok(js.into_iter().map(mapping_from_json).collect()) }) .transpose()?; - let row_results = row_results.as_deref() + let row_results = row_results + .as_deref() .map(|s| -> Result, DomainError> { let js: Vec = de(s)?; Ok(js.into_iter().map(annotated_from_json).collect()) @@ -232,10 +268,13 @@ impl PostgresImportSessionRepository { .transpose()?; Ok(ImportSession { id: ImportSessionId::from_uuid( - id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + id.parse::() + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, ), user_id: UserId::from_uuid( - user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + user_id + .parse::() + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, ), parsed_file, field_mappings, @@ -265,7 +304,11 @@ impl ImportSessionRepository for PostgresImportSessionRepository { .map_err(Self::map_err) } - async fn get(&self, id: &ImportSessionId, user_id: &UserId) -> Result, DomainError> { + async fn get( + &self, + id: &ImportSessionId, + user_id: &UserId, + ) -> Result, DomainError> { let id_str = id.value().to_string(); let uid_str = user_id.value().to_string(); @@ -284,26 +327,39 @@ impl ImportSessionRepository for PostgresImportSessionRepository { "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at FROM import_sessions WHERE id = $1 AND user_id = $2", ) - .bind(&id_str).bind(&uid_str) + .bind(&id_str) + .bind(&uid_str) .fetch_optional(&self.pool) .await .map_err(Self::map_err)?; - row.map(|r| Self::deserialize_session( - r.id, r.user_id, r.parsed_data, r.field_mappings, r.row_results, - r.created_at, r.expires_at, - )).transpose() + row.map(|r| { + Self::deserialize_session( + r.id, + r.user_id, + r.parsed_data, + r.field_mappings, + r.row_results, + r.created_at, + r.expires_at, + ) + }) + .transpose() } async fn update(&self, s: &ImportSession) -> Result<(), DomainError> { let id = s.id.value().to_string(); let (_, field_mappings, row_results) = Self::serialize_session(s)?; - sqlx::query("UPDATE import_sessions SET field_mappings = $1, row_results = $2 WHERE id = $3") - .bind(&field_mappings).bind(&row_results).bind(&id) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(Self::map_err) + sqlx::query( + "UPDATE import_sessions SET field_mappings = $1, row_results = $2 WHERE id = $3", + ) + .bind(&field_mappings) + .bind(&row_results) + .bind(&id) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) } async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> { diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index e313381..e44a9f0 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -388,11 +388,15 @@ impl MovieRepository for PostgresRepository { &self, page: &domain::models::collections::PageParams, filter: &domain::models::MovieFilter, - ) -> Result, DomainError> { + ) -> Result, DomainError> + { use sqlx::Row; let limit = page.limit as i64; let offset = page.offset as i64; - let pattern = filter.search.as_deref().map(|s| format!("%{}%", s.to_lowercase())); + let pattern = filter + .search + .as_deref() + .map(|s| format!("%{}%", s.to_lowercase())); let genre = filter.genre.as_deref(); let language = filter.language.as_deref(); @@ -612,8 +616,7 @@ impl DiaryRepository for PostgresRepository { } if let Some(f) = following { - let local_params: Vec = - f.local_user_ids.iter().map(|_| next_param()).collect(); + let local_params: Vec = f.local_user_ids.iter().map(|_| next_param()).collect(); let remote_params: Vec = f.remote_actor_urls.iter().map(|_| next_param()).collect(); @@ -691,10 +694,7 @@ impl DiaryRepository for PostgresRepository { } let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql)); - let total = count_q - .fetch_one(&self.pool) - .await - .map_err(Self::map_err)?; + let total = count_q.fetch_one(&self.pool).await.map_err(Self::map_err)?; let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql)); let rows = rows_q @@ -800,13 +800,11 @@ impl DiaryRepository for PostgresRepository { let limit = page.limit as i64; let offset = page.offset as i64; - let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM reviews WHERE movie_id = $1", - ) - .bind(&id_str) - .fetch_one(&self.pool) - .await - .map_err(Self::map_err)?; + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE movie_id = $1") + .bind(&id_str) + .fetch_one(&self.pool) + .await + .map_err(Self::map_err)?; let rows = sqlx::query_as::<_, FeedRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, @@ -845,12 +843,11 @@ impl DiaryRepository for PostgresRepository { } async fn count_local_posts(&self) -> Result { - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL" - ) - .fetch_one(&self.pool) - .await - .map_err(Self::map_err)?; + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL") + .fetch_one(&self.pool) + .await + .map_err(Self::map_err)?; Ok(count as u64) } } @@ -939,7 +936,9 @@ pub fn create_profile_fields_repo( std::sync::Arc::new(profile_fields::PostgresProfileFieldsRepository::new(pool)) } -pub async fn wire(database_url: &str) -> anyhow::Result<( +pub async fn wire( + database_url: &str, +) -> anyhow::Result<( sqlx::PgPool, std::sync::Arc, std::sync::Arc, @@ -963,8 +962,10 @@ pub async fn wire(database_url: &str) -> anyhow::Result<( .map_err(|e| anyhow::anyhow!("{e}")) .context("Database migration failed")?; - let import_session_repo = std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone())); - let import_profile_repo = std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone())); + let import_session_repo = + std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone())); + let import_profile_repo = + std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone())); let movie_profile_repo = std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone())); let watchlist_repo = std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone())); diff --git a/crates/adapters/postgres/src/persons.rs b/crates/adapters/postgres/src/persons.rs index 2fb4254..d87b3f6 100644 --- a/crates/adapters/postgres/src/persons.rs +++ b/crates/adapters/postgres/src/persons.rs @@ -20,7 +20,10 @@ impl PostgresPersonAdapter { pub fn create_person_adapter(pool: PgPool) -> (Arc, Arc) { let adapter = Arc::new(PostgresPersonAdapter::new(pool)); - (Arc::clone(&adapter) as Arc, adapter as Arc) + ( + Arc::clone(&adapter) as Arc, + adapter as Arc, + ) } fn map_err(e: sqlx::Error) -> DomainError { @@ -88,7 +91,10 @@ impl PersonQuery for PostgresPersonAdapter { })) } - async fn get_by_external_id(&self, id: &ExternalPersonId) -> Result, DomainError> { + async fn get_by_external_id( + &self, + id: &ExternalPersonId, + ) -> Result, DomainError> { #[derive(sqlx::FromRow)] struct Row { id: String, @@ -119,21 +125,25 @@ impl PersonQuery for PostgresPersonAdapter { } async fn get_credits(&self, id: &PersonId) -> Result { - let person = self.get_by_id(id).await?.ok_or_else(|| { - DomainError::NotFound(format!("Person {} not found", id.value())) - })?; + let person = self + .get_by_id(id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?; - let tmdb_id: Option = sqlx::query_scalar( - "SELECT tmdb_person_id FROM persons WHERE id = $1", - ) - .bind(id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(map_err)? - .flatten(); + let tmdb_id: Option = + sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = $1") + .bind(id.value().to_string()) + .fetch_optional(&self.pool) + .await + .map_err(map_err)? + .flatten(); let Some(tmdb_id) = tmdb_id else { - return Ok(PersonCredits { person, cast: vec![], crew: vec![] }); + return Ok(PersonCredits { + person, + cast: vec![], + crew: vec![], + }); }; #[derive(sqlx::FromRow)] diff --git a/crates/adapters/postgres/src/profile.rs b/crates/adapters/postgres/src/profile.rs index 45ec0cd..3bcaf9e 100644 --- a/crates/adapters/postgres/src/profile.rs +++ b/crates/adapters/postgres/src/profile.rs @@ -65,7 +65,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository { sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1") .bind(&movie_id) - .execute(&mut *tx).await.map_err(Self::map_err)?; + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; for g in &p.genres { sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING") .bind(&movie_id).bind(g.tmdb_id as i32).bind(&g.name) @@ -74,7 +76,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository { sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1") .bind(&movie_id) - .execute(&mut *tx).await.map_err(Self::map_err)?; + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; for k in &p.keywords { sqlx::query("INSERT INTO movie_keywords (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING") .bind(&movie_id).bind(k.tmdb_id as i32).bind(&k.name) @@ -83,30 +87,46 @@ impl MovieProfileRepository for PostgresMovieProfileRepository { sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1") .bind(&movie_id) - .execute(&mut *tx).await.map_err(Self::map_err)?; + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; for c in &p.cast { sqlx::query( "INSERT INTO movie_cast \ (movie_id, tmdb_person_id, name, character, billing_order, profile_path) \ VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING", ) - .bind(&movie_id).bind(c.tmdb_person_id as i64).bind(&c.name) - .bind(&c.character).bind(c.billing_order as i32).bind(&c.profile_path) - .execute(&mut *tx).await.map_err(Self::map_err)?; + .bind(&movie_id) + .bind(c.tmdb_person_id as i64) + .bind(&c.name) + .bind(&c.character) + .bind(c.billing_order as i32) + .bind(&c.profile_path) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; } sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1") .bind(&movie_id) - .execute(&mut *tx).await.map_err(Self::map_err)?; + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; for cr in &p.crew { sqlx::query( "INSERT INTO movie_crew \ (movie_id, tmdb_person_id, name, job, department, profile_path) \ VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING", ) - .bind(&movie_id).bind(cr.tmdb_person_id as i64).bind(&cr.name) - .bind(&cr.job).bind(&cr.department).bind(&cr.profile_path) - .execute(&mut *tx).await.map_err(Self::map_err)?; + .bind(&movie_id) + .bind(cr.tmdb_person_id as i64) + .bind(&cr.name) + .bind(&cr.job) + .bind(&cr.department) + .bind(&cr.profile_path) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; } tx.commit().await.map_err(Self::map_err) @@ -131,12 +151,15 @@ impl MovieProfileRepository for PostgresMovieProfileRepository { None => return Ok(None), }; - let enriched_at: DateTime = row.try_get("enriched_at") + let enriched_at: DateTime = row + .try_get("enriched_at") .map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?; let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = $1") .bind(&movie_id) - .fetch_all(&self.pool).await.map_err(Self::map_err)? + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)? .into_iter() .map(|r| Genre { tmdb_id: r.try_get::("tmdb_id").unwrap_or(0) as u32, @@ -146,7 +169,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository { let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = $1") .bind(&movie_id) - .fetch_all(&self.pool).await.map_err(Self::map_err)? + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)? .into_iter() .map(|r| Keyword { tmdb_id: r.try_get::("tmdb_id").unwrap_or(0) as u32, @@ -159,7 +184,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository { FROM movie_cast WHERE movie_id = $1 ORDER BY billing_order", ) .bind(&movie_id) - .fetch_all(&self.pool).await.map_err(Self::map_err)? + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)? .into_iter() .map(|r| CastMember { tmdb_person_id: r.try_get::("tmdb_person_id").unwrap_or(0) as u64, @@ -175,7 +202,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository { FROM movie_crew WHERE movie_id = $1", ) .bind(&movie_id) - .fetch_all(&self.pool).await.map_err(Self::map_err)? + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)? .into_iter() .map(|r| CrewMember { tmdb_person_id: r.try_get::("tmdb_person_id").unwrap_or(0) as u64, @@ -192,11 +221,19 @@ impl MovieProfileRepository for PostgresMovieProfileRepository { imdb_id: row.try_get("imdb_id").ok(), overview: row.try_get("overview").ok(), tagline: row.try_get("tagline").ok(), - runtime_minutes: row.try_get::, _>("runtime_minutes").ok().flatten().map(|v| v as u32), + runtime_minutes: row + .try_get::, _>("runtime_minutes") + .ok() + .flatten() + .map(|v| v as u32), budget_usd: row.try_get("budget_usd").ok(), revenue_usd: row.try_get("revenue_usd").ok(), vote_average: row.try_get("vote_average").ok(), - vote_count: row.try_get::, _>("vote_count").ok().flatten().map(|v| v as u32), + vote_count: row + .try_get::, _>("vote_count") + .ok() + .flatten() + .map(|v| v as u32), original_language: row.try_get("original_language").ok(), collection_name: row.try_get("collection_name").ok(), genres, diff --git a/crates/adapters/postgres/src/profile_fields.rs b/crates/adapters/postgres/src/profile_fields.rs index 2994edc..3b2b0fc 100644 --- a/crates/adapters/postgres/src/profile_fields.rs +++ b/crates/adapters/postgres/src/profile_fields.rs @@ -2,9 +2,7 @@ use async_trait::async_trait; use sqlx::PgPool; use domain::{ - errors::DomainError, - models::ProfileField, - ports::UserProfileFieldsRepository, + errors::DomainError, models::ProfileField, ports::UserProfileFieldsRepository, value_objects::UserId, }; diff --git a/crates/adapters/postgres/src/users.rs b/crates/adapters/postgres/src/users.rs index 1581e1c..e0ddc06 100644 --- a/crates/adapters/postgres/src/users.rs +++ b/crates/adapters/postgres/src/users.rs @@ -46,8 +46,8 @@ impl PostgresUserRepository { ) -> Result { let id = uuid::Uuid::parse_str(&id_str) .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let email = Email::new(email_str) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + let email = + Email::new(email_str).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; let username = Username::new(username_str) .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; let hash = PasswordHash::new(hash_str) @@ -208,7 +208,10 @@ impl UserRepository for PostgresUserRepository { let Some(r) = row else { return Ok(None) }; #[derive(sqlx::FromRow)] - struct FieldRow { name: String, value: String } + struct FieldRow { + name: String, + value: String, + } let field_rows = sqlx::query_as::<_, FieldRow>( "SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC", ) @@ -217,7 +220,13 @@ impl UserRepository for PostgresUserRepository { .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let profile_fields = field_rows.into_iter().map(|f| ProfileField { name: f.name, value: f.value }).collect(); + let profile_fields = field_rows + .into_iter() + .map(|f| ProfileField { + name: f.name, + value: f.value, + }) + .collect(); Self::row_to_user( r.id, @@ -230,7 +239,8 @@ impl UserRepository for PostgresUserRepository { r.banner_path, r.also_known_as, profile_fields, - ).map(Some) + ) + .map(Some) } async fn update_profile( diff --git a/crates/adapters/postgres/src/watchlist.rs b/crates/adapters/postgres/src/watchlist.rs index ba010c5..4893ba1 100644 --- a/crates/adapters/postgres/src/watchlist.rs +++ b/crates/adapters/postgres/src/watchlist.rs @@ -1,13 +1,16 @@ use async_trait::async_trait; use domain::{ errors::DomainError, - models::{WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}}, + models::{ + WatchlistEntry, WatchlistWithMovie, + collections::{PageParams, Paginated}, + }, ports::WatchlistRepository, value_objects::{MovieId, UserId, WatchlistEntryId}, }; use sqlx::{PgPool, Row}; -use crate::models::{parse_uuid, parse_datetime, MovieRow}; +use crate::models::{MovieRow, parse_datetime, parse_uuid}; pub struct PostgresWatchlistRepository { pool: PgPool, @@ -52,14 +55,13 @@ impl WatchlistRepository for PostgresWatchlistRepository { let uid = user_id.value().to_string(); let mid = movie_id.value().to_string(); - let result = sqlx::query( - "DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2", - ) - .bind(&uid) - .bind(&mid) - .execute(&self.pool) - .await - .map_err(Self::map_err)?; + let result = + sqlx::query("DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2") + .bind(&uid) + .bind(&mid) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; if result.rows_affected() == 0 { return Err(DomainError::NotFound(format!( @@ -77,14 +79,13 @@ impl WatchlistRepository for PostgresWatchlistRepository { ) -> Result { let uid = user_id.value().to_string(); let mid = movie_id.value().to_string(); - let result = sqlx::query( - "DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2", - ) - .bind(&uid) - .bind(&mid) - .execute(&self.pool) - .await - .map_err(Self::map_err)?; + let result = + sqlx::query("DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2") + .bind(&uid) + .bind(&mid) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; Ok(result.rows_affected() > 0) } @@ -115,30 +116,53 @@ impl WatchlistRepository for PostgresWatchlistRepository { .await .map_err(Self::map_err)?; - let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await - .map_err(Self::map_err)?; + let total: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1") + .bind(&uid) + .fetch_one(&self.pool) + .await + .map_err(Self::map_err)?; let items = rows .into_iter() .map(|row| { let entry = WatchlistEntry { - id: WatchlistEntryId::from_uuid(parse_uuid(&row.try_get::("id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?), - user_id: UserId::from_uuid(parse_uuid(&row.try_get::("user_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?), - movie_id: MovieId::from_uuid(parse_uuid(&row.try_get::("movie_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?), - added_at: parse_datetime(&row.try_get::("added_at").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?, + id: WatchlistEntryId::from_uuid(parse_uuid( + &row.try_get::("id") + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + )?), + user_id: UserId::from_uuid(parse_uuid( + &row.try_get::("user_id") + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + )?), + movie_id: MovieId::from_uuid(parse_uuid( + &row.try_get::("movie_id") + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + )?), + added_at: parse_datetime( + &row.try_get::("added_at") + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + )?, }; let movie = MovieRow { - id: row.try_get("m_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - external_metadata_id: row.try_get("external_metadata_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - title: row.try_get("title").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - release_year: row.try_get("release_year").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - director: row.try_get("director").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - poster_path: row.try_get("poster_path").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + id: row + .try_get("m_id") + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + external_metadata_id: row + .try_get("external_metadata_id") + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + title: row + .try_get("title") + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + release_year: row + .try_get("release_year") + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + director: row + .try_get("director") + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + poster_path: row + .try_get("poster_path") + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, } .into_domain()?; Ok(WatchlistWithMovie { entry, movie }) diff --git a/crates/adapters/sqlite-event-queue/src/lib.rs b/crates/adapters/sqlite-event-queue/src/lib.rs index c5425fa..5cb3cfd 100644 --- a/crates/adapters/sqlite-event-queue/src/lib.rs +++ b/crates/adapters/sqlite-event-queue/src/lib.rs @@ -18,33 +18,42 @@ use payload::DbEventPayload; pub struct DbEventQueueConfig { pub poll_interval_ms: u64, - pub batch_size: i64, - pub max_attempts: i32, + pub batch_size: i64, + pub max_attempts: i32, } impl DbEventQueueConfig { pub fn from_env() -> Self { Self { poll_interval_ms: std::env::var("EVENT_QUEUE_POLL_INTERVAL_MS") - .ok().and_then(|v| v.parse().ok()).unwrap_or(500), + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(500), batch_size: std::env::var("EVENT_QUEUE_BATCH_SIZE") - .ok().and_then(|v| v.parse().ok()).unwrap_or(10), + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(10), max_attempts: std::env::var("EVENT_QUEUE_MAX_ATTEMPTS") - .ok().and_then(|v| v.parse().ok()).unwrap_or(5), + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5), } } } #[derive(Clone)] pub struct SqliteEventQueue { - pool: SqlitePool, + pool: SqlitePool, config: Arc, } impl SqliteEventQueue { pub async fn create(pool: SqlitePool, config: DbEventQueueConfig) -> anyhow::Result { migrations::run(&pool).await?; - Ok(Self { pool, config: Arc::new(config) }) + Ok(Self { + pool, + config: Arc::new(config), + }) } pub async fn create_publisher(pool: SqlitePool) -> anyhow::Result> { @@ -68,14 +77,12 @@ impl EventPublisher for SqliteEventQueue { let payload_json = serde_json::to_string(&db_payload) .map_err(|e| DomainError::InfrastructureError(format!("serialize: {e}")))?; - sqlx::query( - "INSERT INTO event_queue (event_type, payload) VALUES (?, ?)" - ) - .bind(event_type) - .bind(payload_json) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?; + sqlx::query("INSERT INTO event_queue (event_type, payload) VALUES (?, ?)") + .bind(event_type) + .bind(payload_json) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?; Ok(()) } @@ -83,10 +90,10 @@ impl EventPublisher for SqliteEventQueue { impl EventConsumer for SqliteEventQueue { fn consume(&self) -> BoxStream<'_, Result> { - let pool = self.pool.clone(); - let config = Arc::clone(&self.config); - let (tx, rx) = mpsc::channel(128); - let rx = Arc::new(Mutex::new(rx)); + let pool = self.pool.clone(); + let config = Arc::clone(&self.config); + let (tx, rx) = mpsc::channel(128); + let rx = Arc::new(Mutex::new(rx)); tokio::spawn(async move { let poll_interval = Duration::from_millis(config.poll_interval_ms); @@ -124,16 +131,18 @@ impl EventConsumer for SqliteEventQueue { #[derive(sqlx::FromRow)] struct QueueRow { - id: i64, - payload: String, + id: i64, + payload: String, attempts: i32, } async fn claim_batch( - pool: &SqlitePool, + pool: &SqlitePool, config: &DbEventQueueConfig, ) -> Result, DomainError> { - let mut tx = pool.begin().await + let mut tx = pool + .begin() + .await .map_err(|e| DomainError::InfrastructureError(format!("begin tx: {e}")))?; let rows = sqlx::query_as::<_, QueueRow>( @@ -141,7 +150,7 @@ async fn claim_batch( WHERE status = 'pending' AND next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now') ORDER BY next_attempt_at ASC - LIMIT ?" + LIMIT ?", ) .bind(config.batch_size) .fetch_all(&mut *tx) @@ -159,36 +168,43 @@ async fn claim_batch( placeholders ); let mut q = sqlx::query(&sql); - for r in &rows { q = q.bind(r.id); } - q.execute(&mut *tx).await + for r in &rows { + q = q.bind(r.id); + } + q.execute(&mut *tx) + .await .map_err(|e| DomainError::InfrastructureError(format!("mark processing: {e}")))?; - tx.commit().await + tx.commit() + .await .map_err(|e| DomainError::InfrastructureError(format!("commit claim: {e}")))?; Ok(rows) } fn decode_row( - pool: &SqlitePool, - row: QueueRow, + pool: &SqlitePool, + row: QueueRow, max_attempts: i32, ) -> Result { let db_payload: DbEventPayload = serde_json::from_str(&row.payload) .map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?; let event = DomainEvent::try_from(db_payload)?; - Ok(EventEnvelope::new(event, Box::new(DbAckHandle { - pool: pool.clone(), - row_id: row.id, - attempts: row.attempts, - max_attempts, - }))) + Ok(EventEnvelope::new( + event, + Box::new(DbAckHandle { + pool: pool.clone(), + row_id: row.id, + attempts: row.attempts, + max_attempts, + }), + )) } struct DbAckHandle { - pool: SqlitePool, - row_id: i64, - attempts: i32, + pool: SqlitePool, + row_id: i64, + attempts: i32, max_attempts: i32, } diff --git a/crates/adapters/sqlite-federation/src/lib.rs b/crates/adapters/sqlite-federation/src/lib.rs index a5a2291..105c886 100644 --- a/crates/adapters/sqlite-federation/src/lib.rs +++ b/crates/adapters/sqlite-federation/src/lib.rs @@ -7,7 +7,7 @@ use activitypub::RemoteReviewRepository; use activitypub_base::{ BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, }; -use domain::models::{Review, ReviewSource, RemoteWatchlistEntry}; +use domain::models::{RemoteWatchlistEntry, Review, ReviewSource}; use domain::ports::RemoteWatchlistRepository; fn datetime_to_str(dt: &NaiveDateTime) -> String { @@ -178,7 +178,8 @@ impl FederationRepository for SqliteFederationRepository { let status_str: String = row.get("status"); let handle: String = row.try_get("handle").unwrap_or_default(); let inbox_url: String = row.try_get("inbox_url").unwrap_or_default(); - let shared_inbox_url: Option = row.try_get("shared_inbox_url").ok().flatten(); + let shared_inbox_url: Option = + row.try_get("shared_inbox_url").ok().flatten(); let display_name: Option = row.try_get("display_name").ok().flatten(); let avatar_url: Option = row.try_get("avatar_url").ok().flatten(); Follower { @@ -595,12 +596,11 @@ impl FederationRepository for SqliteFederationRepository { } async fn is_domain_blocked(&self, domain: &str) -> Result { - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM blocked_domains WHERE domain = ?1", - ) - .bind(domain) - .fetch_one(&self.pool) - .await?; + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = ?1") + .bind(domain) + .fetch_one(&self.pool) + .await?; Ok(count > 0) } @@ -639,7 +639,10 @@ impl FederationRepository for SqliteFederationRepository { .bind(&uid) .fetch_all(&self.pool) .await?; - Ok(rows.iter().map(|r| r.get::("remote_actor_url")).collect()) + Ok(rows + .iter() + .map(|r| r.get::("remote_actor_url")) + .collect()) } async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result { @@ -789,11 +792,13 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository { Ok(rows .into_iter() - .map(|(url, handle, display_name)| domain::ports::RemoteActorInfo { - url, - handle, - display_name, - }) + .map( + |(url, handle, display_name)| domain::ports::RemoteActorInfo { + url, + handle, + display_name, + }, + ) .collect()) } } @@ -822,19 +827,24 @@ impl RemoteWatchlistRepository for SqliteFederationRepository { Ok(()) } - async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), domain::errors::DomainError> { - sqlx::query( - "DELETE FROM ap_remote_watchlist_entries WHERE ap_id = ? AND actor_url = ?", - ) - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; + async fn remove_by_ap_id( + &self, + ap_id: &str, + actor_url: &str, + ) -> Result<(), domain::errors::DomainError> { + sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE ap_id = ? AND actor_url = ?") + .bind(ap_id) + .bind(actor_url) + .execute(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; Ok(()) } - async fn get_by_actor_url(&self, actor_url: &str) -> Result, domain::errors::DomainError> { + async fn get_by_actor_url( + &self, + actor_url: &str, + ) -> Result, domain::errors::DomainError> { let rows = sqlx::query( "SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \ FROM ap_remote_watchlist_entries WHERE actor_url = ? ORDER BY added_at DESC", @@ -844,24 +854,35 @@ impl RemoteWatchlistRepository for SqliteFederationRepository { .await .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - rows.into_iter().map(|row| { - let added_at_str: String = row.try_get("added_at").unwrap_or_default(); - let added_at = chrono::NaiveDateTime::parse_from_str(&added_at_str, "%Y-%m-%d %H:%M:%S") - .map(|dt| chrono::DateTime::::from_naive_utc_and_offset(dt, chrono::Utc)) - .unwrap_or_else(|_| chrono::Utc::now()); - Ok(RemoteWatchlistEntry { - ap_id: row.try_get("ap_id").unwrap_or_default(), - actor_url: row.try_get("actor_url").unwrap_or_default(), - movie_title: row.try_get("movie_title").unwrap_or_default(), - release_year: row.try_get::("release_year").unwrap_or(0) as u16, - external_metadata_id: row.try_get("external_metadata_id").ok().flatten(), - poster_url: row.try_get("poster_url").ok().flatten(), - added_at, + rows.into_iter() + .map(|row| { + let added_at_str: String = row.try_get("added_at").unwrap_or_default(); + let added_at = + chrono::NaiveDateTime::parse_from_str(&added_at_str, "%Y-%m-%d %H:%M:%S") + .map(|dt| { + chrono::DateTime::::from_naive_utc_and_offset( + dt, + chrono::Utc, + ) + }) + .unwrap_or_else(|_| chrono::Utc::now()); + Ok(RemoteWatchlistEntry { + ap_id: row.try_get("ap_id").unwrap_or_default(), + actor_url: row.try_get("actor_url").unwrap_or_default(), + movie_title: row.try_get("movie_title").unwrap_or_default(), + release_year: row.try_get::("release_year").unwrap_or(0) as u16, + external_metadata_id: row.try_get("external_metadata_id").ok().flatten(), + poster_url: row.try_get("poster_url").ok().flatten(), + added_at, + }) }) - }).collect() + .collect() } - async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), domain::errors::DomainError> { + async fn remove_all_by_actor( + &self, + actor_url: &str, + ) -> Result<(), domain::errors::DomainError> { sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = ?") .bind(actor_url) .execute(&self.pool) @@ -870,18 +891,22 @@ impl RemoteWatchlistRepository for SqliteFederationRepository { Ok(()) } - async fn get_by_derived_uuid(&self, uuid: uuid::Uuid) -> Result, domain::errors::DomainError> { - let actors: Vec = sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries") - .fetch_all(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))? - .into_iter() - .filter_map(|row| row.try_get::("actor_url").ok()) - .collect(); + async fn get_by_derived_uuid( + &self, + uuid: uuid::Uuid, + ) -> Result, domain::errors::DomainError> { + let actors: Vec = + sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries") + .fetch_all(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))? + .into_iter() + .filter_map(|row| row.try_get::("actor_url").ok()) + .collect(); - let target = actors.into_iter().find(|url| { - uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid - }); + let target = actors + .into_iter() + .find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid); match target { None => Ok(vec![]), @@ -890,7 +915,9 @@ impl RemoteWatchlistRepository for SqliteFederationRepository { } } -pub fn wire(pool: sqlx::SqlitePool) -> ( +pub fn wire( + pool: sqlx::SqlitePool, +) -> ( std::sync::Arc, std::sync::Arc, std::sync::Arc, diff --git a/crates/adapters/sqlite-federation/src/tests/actor_block_tests.rs b/crates/adapters/sqlite-federation/src/tests/actor_block_tests.rs index 02f7c5b..5d0dfb4 100644 --- a/crates/adapters/sqlite-federation/src/tests/actor_block_tests.rs +++ b/crates/adapters/sqlite-federation/src/tests/actor_block_tests.rs @@ -3,14 +3,23 @@ use sqlx::SqlitePool; async fn test_pool() -> SqlitePool { let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); - sqlx::query("CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, password_hash TEXT, created_at TEXT)") - .execute(&pool).await.unwrap(); + sqlx::query( + "CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, password_hash TEXT, created_at TEXT)", + ) + .execute(&pool) + .await + .unwrap(); sqlx::query("CREATE TABLE blocked_actors (local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, remote_actor_url TEXT NOT NULL, blocked_at TEXT NOT NULL, PRIMARY KEY (local_user_id, remote_actor_url))") .execute(&pool).await.unwrap(); let uid = uuid::Uuid::new_v4().to_string(); sqlx::query("INSERT INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)") - .bind(&uid).bind("a@b.com").bind("hash").bind("2024-01-01") - .execute(&pool).await.unwrap(); + .bind(&uid) + .bind("a@b.com") + .bind("hash") + .bind("2024-01-01") + .execute(&pool) + .await + .unwrap(); pool } @@ -19,8 +28,11 @@ async fn block_and_check_actor() { let pool = test_pool().await; let user_id = uuid::Uuid::parse_str( &sqlx::query_scalar::<_, String>("SELECT id FROM users LIMIT 1") - .fetch_one(&pool).await.unwrap() - ).unwrap(); + .fetch_one(&pool) + .await + .unwrap(), + ) + .unwrap(); let repo = SqliteFederationRepository::new(pool); let actor_url = "https://mastodon.social/users/alice"; assert!(!repo.is_actor_blocked(user_id, actor_url).await.unwrap()); diff --git a/crates/adapters/sqlite-federation/src/tests/domain_block_tests.rs b/crates/adapters/sqlite-federation/src/tests/domain_block_tests.rs index c96c7af..2f7a9af 100644 --- a/crates/adapters/sqlite-federation/src/tests/domain_block_tests.rs +++ b/crates/adapters/sqlite-federation/src/tests/domain_block_tests.rs @@ -13,7 +13,9 @@ async fn blocked_domain_is_detected() { let pool = test_pool().await; let repo = SqliteFederationRepository::new(pool); assert!(!repo.is_domain_blocked("mastodon.social").await.unwrap()); - repo.add_blocked_domain("mastodon.social", Some("spam")).await.unwrap(); + repo.add_blocked_domain("mastodon.social", Some("spam")) + .await + .unwrap(); assert!(repo.is_domain_blocked("mastodon.social").await.unwrap()); } @@ -30,7 +32,9 @@ async fn remove_unblocks_domain() { async fn get_blocked_domains_returns_all() { let pool = test_pool().await; let repo = SqliteFederationRepository::new(pool); - repo.add_blocked_domain("a.com", Some("reason a")).await.unwrap(); + repo.add_blocked_domain("a.com", Some("reason a")) + .await + .unwrap(); repo.add_blocked_domain("b.com", None).await.unwrap(); let domains = repo.get_blocked_domains().await.unwrap(); assert_eq!(domains.len(), 2); diff --git a/crates/adapters/sqlite-federation/src/tests/lib.rs b/crates/adapters/sqlite-federation/src/tests/lib.rs index 1455ef4..ed8ff2b 100644 --- a/crates/adapters/sqlite-federation/src/tests/lib.rs +++ b/crates/adapters/sqlite-federation/src/tests/lib.rs @@ -14,7 +14,14 @@ async fn test_pool() -> SqlitePool { async fn add_announce_stores_and_counts() { let pool = test_pool().await; let repo = SqliteFederationRepository::new(pool); - repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap(); + repo.add_announce( + "https://remote/ann/1", + "https://local/r/1", + "https://remote/u/1", + Utc::now(), + ) + .await + .unwrap(); assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1); } @@ -22,8 +29,22 @@ async fn add_announce_stores_and_counts() { async fn duplicate_announce_is_ignored() { let pool = test_pool().await; let repo = SqliteFederationRepository::new(pool); - repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap(); - repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap(); + repo.add_announce( + "https://remote/ann/1", + "https://local/r/1", + "https://remote/u/1", + Utc::now(), + ) + .await + .unwrap(); + repo.add_announce( + "https://remote/ann/1", + "https://local/r/1", + "https://remote/u/1", + Utc::now(), + ) + .await + .unwrap(); assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1); } diff --git a/crates/adapters/sqlite-search/src/lib.rs b/crates/adapters/sqlite-search/src/lib.rs index 9fe4fae..8109b58 100644 --- a/crates/adapters/sqlite-search/src/lib.rs +++ b/crates/adapters/sqlite-search/src/lib.rs @@ -3,14 +3,13 @@ use std::sync::Arc; use async_trait::async_trait; use domain::{ errors::DomainError, - models::{ - EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, - SearchQuery, SearchResults, - collections::Paginated, - }, models::PersonId, - value_objects::MovieId, + models::{ + collections::Paginated, EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, + SearchQuery, SearchResults, + }, ports::{SearchCommand, SearchPort}, + value_objects::MovieId, }; use sqlx::SqlitePool; @@ -26,7 +25,10 @@ impl SqliteSearchAdapter { pub fn create_search_adapter(pool: SqlitePool) -> (Arc, Arc) { let adapter = Arc::new(SqliteSearchAdapter::new(pool)); - (Arc::clone(&adapter) as Arc, adapter as Arc) + ( + Arc::clone(&adapter) as Arc, + adapter as Arc, + ) } fn map_err(e: sqlx::Error) -> DomainError { @@ -46,13 +48,36 @@ impl SearchCommand for SqliteSearchAdapter { match profile.as_deref() { Some(p) => ( p.overview.clone().unwrap_or_default(), - p.genres.iter().map(|g| g.name.as_str()).collect::>().join(" "), - p.keywords.iter().map(|k| k.name.as_str()).collect::>().join(" "), - p.cast.iter().map(|c| c.name.as_str()).collect::>().join(" "), - p.crew.iter().map(|c| c.name.as_str()).collect::>().join(" "), + p.genres + .iter() + .map(|g| g.name.as_str()) + .collect::>() + .join(" "), + p.keywords + .iter() + .map(|k| k.name.as_str()) + .collect::>() + .join(" "), + p.cast + .iter() + .map(|c| c.name.as_str()) + .collect::>() + .join(" "), + p.crew + .iter() + .map(|c| c.name.as_str()) + .collect::>() + .join(" "), p.original_language.clone().unwrap_or_default(), ), - None => (String::new(), String::new(), String::new(), String::new(), String::new(), String::new()), + None => ( + String::new(), + String::new(), + String::new(), + String::new(), + String::new(), + String::new(), + ), }; sqlx::query( @@ -145,7 +170,10 @@ impl SearchPort for SqliteSearchAdapter { } impl SqliteSearchAdapter { - async fn search_movies(&self, query: &SearchQuery) -> Result, DomainError> { + async fn search_movies( + &self, + query: &SearchQuery, + ) -> Result, DomainError> { let limit = query.page.limit as i64; let offset = query.page.offset as i64; @@ -244,24 +272,36 @@ impl SqliteSearchAdapter { .await .map_err(map_err)? }; - let items = rows.into_iter().map(|r| MovieSearchHit { - movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), - title: r.title, - release_year: r.release_year.map(|y| y as u16), - director: r.director, - poster_path: r.poster_path, - genres: r.genres - .unwrap_or_default() - .split(',') - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(), - }).collect::>(); + let items = rows + .into_iter() + .map(|r| MovieSearchHit { + movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), + title: r.title, + release_year: r.release_year.map(|y| y as u16), + director: r.director, + poster_path: r.poster_path, + genres: r + .genres + .unwrap_or_default() + .split(',') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(), + }) + .collect::>(); - Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset }) + Ok(Paginated { + items, + total_count: total, + limit: query.page.limit, + offset: query.page.offset, + }) } - async fn search_people(&self, query: &SearchQuery) -> Result, DomainError> { + async fn search_people( + &self, + query: &SearchQuery, + ) -> Result, DomainError> { let Some(text) = &query.text else { return Ok(Paginated { items: vec![], @@ -276,13 +316,12 @@ impl SqliteSearchAdapter { let fts_query = format!("{}*", text.replace(['"', '*'], "")); let total: u64 = { - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM people_fts WHERE people_fts MATCH ?", - ) - .bind(&fts_query) - .fetch_one(&self.pool) - .await - .map_err(map_err)?; + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM people_fts WHERE people_fts MATCH ?") + .bind(&fts_query) + .fetch_one(&self.pool) + .await + .map_err(map_err)?; count as u64 }; @@ -311,14 +350,13 @@ impl SqliteSearchAdapter { let mut items = Vec::with_capacity(rows.len()); for row in rows { - let tmdb_id: Option = sqlx::query_scalar( - "SELECT tmdb_person_id FROM persons WHERE id = ?", - ) - .bind(&row.person_id) - .fetch_optional(&self.pool) - .await - .map_err(map_err)? - .flatten(); + let tmdb_id: Option = + sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = ?") + .bind(&row.person_id) + .fetch_optional(&self.pool) + .await + .map_err(map_err)? + .flatten(); let known_for_titles = if let Some(tid) = tmdb_id { sqlx::query_scalar::<_, String>( @@ -338,7 +376,7 @@ impl SqliteSearchAdapter { items.push(PersonSearchHit { person_id: PersonId::from_uuid( - uuid::Uuid::parse_str(&row.person_id).unwrap_or_default() + uuid::Uuid::parse_str(&row.person_id).unwrap_or_default(), ), name: row.name, known_for_department: row.known_for_department, @@ -347,7 +385,12 @@ impl SqliteSearchAdapter { }); } - Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset }) + Ok(Paginated { + items, + total_count: total, + limit: query.page.limit, + offset: query.page.offset, + }) } } diff --git a/crates/adapters/sqlite-search/src/tests/lib.rs b/crates/adapters/sqlite-search/src/tests/lib.rs index 5d552de..bc42d16 100644 --- a/crates/adapters/sqlite-search/src/tests/lib.rs +++ b/crates/adapters/sqlite-search/src/tests/lib.rs @@ -1,13 +1,11 @@ -use super::{SqliteSearchAdapter, create_search_adapter}; +use super::{create_search_adapter, SqliteSearchAdapter}; use domain::{ models::{ - EntityType, IndexableDocument, Movie, - Person, PersonId, SearchFilters, SearchQuery, - ExternalPersonId, - collections::PageParams, + collections::PageParams, EntityType, ExternalPersonId, IndexableDocument, Movie, Person, + PersonId, SearchFilters, SearchQuery, }, - value_objects::{MovieId, MovieTitle, ReleaseYear}, ports::{SearchCommand, SearchPort}, + value_objects::{MovieId, MovieTitle, ReleaseYear}, }; use sqlx::SqlitePool; @@ -17,33 +15,43 @@ async fn pool_with_schema() -> SqlitePool { "CREATE TABLE movies (id TEXT PRIMARY KEY, title TEXT NOT NULL, release_year INTEGER, director TEXT, poster_path TEXT, external_metadata_id TEXT)", ) - .execute(&pool).await.unwrap(); + .execute(&pool) + .await + .unwrap(); sqlx::query( "CREATE TABLE persons (id TEXT PRIMARY KEY, external_id TEXT UNIQUE, tmdb_person_id INTEGER UNIQUE, name TEXT NOT NULL, known_for_department TEXT, profile_path TEXT)", ) - .execute(&pool).await.unwrap(); + .execute(&pool) + .await + .unwrap(); sqlx::query( "CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER, name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT)", ) - .execute(&pool).await.unwrap(); - sqlx::query( - "CREATE TABLE movie_genres (movie_id TEXT, tmdb_id INTEGER, name TEXT)", - ) - .execute(&pool).await.unwrap(); + .execute(&pool) + .await + .unwrap(); + sqlx::query("CREATE TABLE movie_genres (movie_id TEXT, tmdb_id INTEGER, name TEXT)") + .execute(&pool) + .await + .unwrap(); sqlx::query( "CREATE VIRTUAL TABLE movies_fts USING fts5( movie_id UNINDEXED, title, director, overview, genres, keywords, cast_names, crew_names, release_year UNINDEXED, language UNINDEXED)", ) - .execute(&pool).await.unwrap(); + .execute(&pool) + .await + .unwrap(); sqlx::query( "CREATE VIRTUAL TABLE people_fts USING fts5( person_id UNINDEXED, name, known_for_department UNINDEXED)", ) - .execute(&pool).await.unwrap(); + .execute(&pool) + .await + .unwrap(); pool } @@ -72,18 +80,32 @@ async fn index_and_search_movie_by_title() { let movie_id = movie.id().clone(); sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)") - .bind(id_str).bind("Interstellar").bind(2014i32) - .bind("Christopher Nolan").bind::>(None).bind::>(None) - .execute(&pool).await.unwrap(); + .bind(id_str) + .bind("Interstellar") + .bind(2014i32) + .bind("Christopher Nolan") + .bind::>(None) + .bind::>(None) + .execute(&pool) + .await + .unwrap(); - cmd.index(IndexableDocument::Movie { id: movie_id.clone(), movie: Box::new(movie), profile: None }) - .await.unwrap(); + cmd.index(IndexableDocument::Movie { + id: movie_id.clone(), + movie: Box::new(movie), + profile: None, + }) + .await + .unwrap(); - let results = query.search(&SearchQuery { - text: Some("Interstellar".to_string()), - filters: SearchFilters::default(), - page: default_page(), - }).await.unwrap(); + let results = query + .search(&SearchQuery { + text: Some("Interstellar".to_string()), + filters: SearchFilters::default(), + page: default_page(), + }) + .await + .unwrap(); assert_eq!(results.movies.items.len(), 1); assert_eq!(results.movies.items[0].title, "Interstellar"); @@ -99,19 +121,33 @@ async fn remove_movie_clears_from_index() { let movie_id = movie.id().clone(); sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)") - .bind(id_str).bind("Inception").bind(2010i32) - .bind("Christopher Nolan").bind::>(None).bind::>(None) - .execute(&pool).await.unwrap(); + .bind(id_str) + .bind("Inception") + .bind(2010i32) + .bind("Christopher Nolan") + .bind::>(None) + .bind::>(None) + .execute(&pool) + .await + .unwrap(); - cmd.index(IndexableDocument::Movie { id: movie_id.clone(), movie: Box::new(movie), profile: None }) - .await.unwrap(); + cmd.index(IndexableDocument::Movie { + id: movie_id.clone(), + movie: Box::new(movie), + profile: None, + }) + .await + .unwrap(); cmd.remove(EntityType::Movie, id_str).await.unwrap(); - let results = query.search(&SearchQuery { - text: Some("Inception".to_string()), - filters: SearchFilters::default(), - page: default_page(), - }).await.unwrap(); + let results = query + .search(&SearchQuery { + text: Some("Inception".to_string()), + filters: SearchFilters::default(), + page: default_page(), + }) + .await + .unwrap(); assert!(results.movies.items.is_empty()); } @@ -126,32 +162,54 @@ async fn search_with_genre_filter() { let movie_id = movie.id().clone(); sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)") - .bind(id_str).bind("The Dark Knight").bind(2008i32) - .bind("Christopher Nolan").bind::>(None).bind::>(None) - .execute(&pool).await.unwrap(); + .bind(id_str) + .bind("The Dark Knight") + .bind(2008i32) + .bind("Christopher Nolan") + .bind::>(None) + .bind::>(None) + .execute(&pool) + .await + .unwrap(); sqlx::query("INSERT INTO movie_genres VALUES (?, 1, 'Action')") .bind(id_str) - .execute(&pool).await.unwrap(); + .execute(&pool) + .await + .unwrap(); cmd.index(IndexableDocument::Movie { id: movie_id.clone(), movie: Box::new(movie), profile: None, - }).await.unwrap(); + }) + .await + .unwrap(); // Matching genre — no text filter - let results = query.search(&SearchQuery { - text: None, - filters: SearchFilters { genre: Some("Action".to_string()), ..Default::default() }, - page: default_page(), - }).await.unwrap(); + let results = query + .search(&SearchQuery { + text: None, + filters: SearchFilters { + genre: Some("Action".to_string()), + ..Default::default() + }, + page: default_page(), + }) + .await + .unwrap(); assert_eq!(results.movies.items.len(), 1); // Non-matching genre - let results = query.search(&SearchQuery { - text: None, - filters: SearchFilters { genre: Some("Comedy".to_string()), ..Default::default() }, - page: default_page(), - }).await.unwrap(); + let results = query + .search(&SearchQuery { + text: None, + filters: SearchFilters { + genre: Some("Comedy".to_string()), + ..Default::default() + }, + page: default_page(), + }) + .await + .unwrap(); assert!(results.movies.items.is_empty()); } diff --git a/crates/adapters/sqlite/src/image_ref.rs b/crates/adapters/sqlite/src/image_ref.rs index b747794..0da8a0b 100644 --- a/crates/adapters/sqlite/src/image_ref.rs +++ b/crates/adapters/sqlite/src/image_ref.rs @@ -1,5 +1,8 @@ use async_trait::async_trait; -use domain::{errors::DomainError, ports::{ImageRefCommand, ImageRefQuery}}; +use domain::{ + errors::DomainError, + ports::{ImageRefCommand, ImageRefQuery}, +}; use sqlx::SqlitePool; use std::sync::Arc; @@ -15,23 +18,34 @@ impl SqliteImageRefAdapter { pub fn create_image_ref(pool: SqlitePool) -> (Arc, Arc) { let adapter = Arc::new(SqliteImageRefAdapter::new(pool)); - (Arc::clone(&adapter) as Arc, adapter as Arc) + ( + Arc::clone(&adapter) as Arc, + adapter as Arc, + ) } #[async_trait] impl ImageRefCommand for SqliteImageRefAdapter { async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> { - let mut tx = self.pool.begin().await + let mut tx = self + .pool + .begin() + .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; sqlx::query("UPDATE users SET avatar_path = ? WHERE avatar_path = ?") - .bind(new_key).bind(old_key) - .execute(&mut *tx).await + .bind(new_key) + .bind(old_key) + .execute(&mut *tx) + .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; sqlx::query("UPDATE movies SET poster_path = ? WHERE poster_path = ?") - .bind(new_key).bind(old_key) - .execute(&mut *tx).await + .bind(new_key) + .bind(old_key) + .execute(&mut *tx) + .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - tx.commit().await + tx.commit() + .await .map_err(|e| DomainError::InfrastructureError(e.to_string())) } } diff --git a/crates/adapters/sqlite/src/import_profile.rs b/crates/adapters/sqlite/src/import_profile.rs index 7ea2d1c..ee791ca 100644 --- a/crates/adapters/sqlite/src/import_profile.rs +++ b/crates/adapters/sqlite/src/import_profile.rs @@ -14,12 +14,20 @@ use sqlx::SqlitePool; #[derive(Serialize, Deserialize)] enum DomainFieldJson { - Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId, + Title, + ReleaseYear, + Director, + Rating, + WatchedAt, + Comment, + ExternalMetadataId, } #[derive(Serialize, Deserialize)] enum TransformJson { - RatingScale(f64), DateFormat(String), Identity, + RatingScale(f64), + DateFormat(String), + Identity, } #[derive(Serialize, Deserialize)] @@ -75,8 +83,8 @@ fn serialize_mappings(ms: &[FieldMapping]) -> Result { } fn deserialize_mappings(s: &str) -> Result, DomainError> { - let js: Vec = serde_json::from_str(s) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + let js: Vec = + serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; Ok(js.into_iter().map(mapping_from_json).collect()) } @@ -85,7 +93,9 @@ pub struct SqliteImportProfileRepository { } impl SqliteImportProfileRepository { - pub fn new(pool: SqlitePool) -> Self { Self { pool } } + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } fn map_err(e: sqlx::Error) -> DomainError { tracing::error!("DB error: {:?}", e); @@ -95,7 +105,9 @@ impl SqliteImportProfileRepository { fn parse_dt(s: &str) -> Result { NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")) - .map_err(|e| DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e))) + .map_err(|e| { + DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e)) + }) } } @@ -109,7 +121,11 @@ impl ImportProfileRepository for SqliteImportProfileRepository { sqlx::query!( "INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at) VALUES (?, ?, ?, ?, ?)", - id, user_id, p.name, field_mappings, created_at + id, + user_id, + p.name, + field_mappings, + created_at ) .execute(&self.pool) .await @@ -127,18 +143,31 @@ impl ImportProfileRepository for SqliteImportProfileRepository { .await .map_err(Self::map_err)?; - rows.into_iter().map(|r| { - Ok(ImportProfile { - id: ImportProfileId::from_uuid(r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))?), - user_id: UserId::from_uuid(r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))?), - name: r.name, - field_mappings: deserialize_mappings(&r.field_mappings)?, - created_at: Self::parse_dt(&r.created_at)?, + rows.into_iter() + .map(|r| { + Ok(ImportProfile { + id: ImportProfileId::from_uuid( + r.id.parse::() + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + ), + user_id: UserId::from_uuid( + r.user_id + .parse::() + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + ), + name: r.name, + field_mappings: deserialize_mappings(&r.field_mappings)?, + created_at: Self::parse_dt(&r.created_at)?, + }) }) - }).collect() + .collect() } - async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result, DomainError> { + async fn get( + &self, + id: &ImportProfileId, + user_id: &UserId, + ) -> Result, DomainError> { let id_str = id.value().to_string(); let uid_str = user_id.value().to_string(); let row = sqlx::query!( @@ -151,13 +180,21 @@ impl ImportProfileRepository for SqliteImportProfileRepository { row.map(|r| { Ok(ImportProfile { - id: ImportProfileId::from_uuid(r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))?), - user_id: UserId::from_uuid(r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))?), + id: ImportProfileId::from_uuid( + r.id.parse::() + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + ), + user_id: UserId::from_uuid( + r.user_id + .parse::() + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + ), name: r.name, field_mappings: deserialize_mappings(&r.field_mappings)?, created_at: Self::parse_dt(&r.created_at)?, }) - }).transpose() + }) + .transpose() } async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> { diff --git a/crates/adapters/sqlite/src/import_session.rs b/crates/adapters/sqlite/src/import_session.rs index ec353fe..b6aded1 100644 --- a/crates/adapters/sqlite/src/import_session.rs +++ b/crates/adapters/sqlite/src/import_session.rs @@ -22,7 +22,13 @@ struct ParsedFileJson { #[derive(Serialize, Deserialize)] enum DomainFieldJson { - Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId, + Title, + ReleaseYear, + Director, + Rating, + WatchedAt, + Comment, + ExternalMetadataId, } #[derive(Serialize, Deserialize)] @@ -41,19 +47,29 @@ struct FieldMappingJson { #[derive(Serialize, Deserialize, Default)] struct ImportRowJson { - #[serde(skip_serializing_if = "Option::is_none")] title: Option, - #[serde(skip_serializing_if = "Option::is_none")] release_year: Option, - #[serde(skip_serializing_if = "Option::is_none")] director: Option, - #[serde(skip_serializing_if = "Option::is_none")] rating: Option, - #[serde(skip_serializing_if = "Option::is_none")] watched_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] comment: Option, - #[serde(skip_serializing_if = "Option::is_none")] external_metadata_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + release_year: Option, + #[serde(skip_serializing_if = "Option::is_none")] + director: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rating: Option, + #[serde(skip_serializing_if = "Option::is_none")] + watched_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + comment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + external_metadata_id: Option, } #[derive(Serialize, Deserialize)] enum RowResultJson { Valid(ImportRowJson), - Invalid { errors: Vec, raw: Vec<(String, String)> }, + Invalid { + errors: Vec, + raw: Vec<(String, String)>, + }, } #[derive(Serialize, Deserialize)] @@ -182,7 +198,9 @@ pub struct SqliteImportSessionRepository { } impl SqliteImportSessionRepository { - pub fn new(pool: SqlitePool) -> Self { Self { pool } } + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } fn map_err(e: sqlx::Error) -> DomainError { tracing::error!("DB error: {:?}", e); @@ -192,18 +210,33 @@ impl SqliteImportSessionRepository { fn parse_dt(s: &str) -> Result { NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")) - .map_err(|e| DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e))) + .map_err(|e| { + DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e)) + }) } - fn serialize_session(s: &ImportSession) -> Result<(String, Option, Option), DomainError> { - let parsed = s.parsed_file.as_ref() - .map(|f| ser(&ParsedFileJson { columns: f.columns.clone(), rows: f.rows.clone() })) + fn serialize_session( + s: &ImportSession, + ) -> Result<(String, Option, Option), DomainError> { + let parsed = s + .parsed_file + .as_ref() + .map(|f| { + ser(&ParsedFileJson { + columns: f.columns.clone(), + rows: f.rows.clone(), + }) + }) .transpose()? .unwrap_or_default(); - let mappings = s.field_mappings.as_ref() + let mappings = s + .field_mappings + .as_ref() .map(|ms| ser(&ms.iter().map(mapping_to_json).collect::>())) .transpose()?; - let results = s.row_results.as_ref() + let results = s + .row_results + .as_ref() .map(|rs| ser(&rs.iter().map(annotated_to_json).collect::>())) .transpose()?; Ok((parsed, mappings, results)) @@ -222,15 +255,20 @@ impl SqliteImportSessionRepository { None } else { let j: ParsedFileJson = de(&parsed_data)?; - Some(ParsedFile { columns: j.columns, rows: j.rows }) + Some(ParsedFile { + columns: j.columns, + rows: j.rows, + }) }; - let field_mappings = field_mappings.as_deref() + let field_mappings = field_mappings + .as_deref() .map(|s| -> Result, DomainError> { let js: Vec = de(s)?; Ok(js.into_iter().map(mapping_from_json).collect()) }) .transpose()?; - let row_results = row_results.as_deref() + let row_results = row_results + .as_deref() .map(|s| -> Result, DomainError> { let js: Vec = de(s)?; Ok(js.into_iter().map(annotated_from_json).collect()) @@ -239,10 +277,13 @@ impl SqliteImportSessionRepository { Ok(ImportSession { id: ImportSessionId::from_uuid( - id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + id.parse::() + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, ), user_id: UserId::from_uuid( - user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + user_id + .parse::() + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, ), parsed_file, field_mappings, @@ -272,22 +313,35 @@ impl ImportSessionRepository for SqliteImportSessionRepository { .map_err(Self::map_err) } - async fn get(&self, id: &ImportSessionId, user_id: &UserId) -> Result, DomainError> { + async fn get( + &self, + id: &ImportSessionId, + user_id: &UserId, + ) -> Result, DomainError> { let id_str = id.value().to_string(); let uid_str = user_id.value().to_string(); let row = sqlx::query!( "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at FROM import_sessions WHERE id = ? AND user_id = ?", - id_str, uid_str + id_str, + uid_str ) .fetch_optional(&self.pool) .await .map_err(Self::map_err)?; - row.map(|r| Self::deserialize_session( - r.id, r.user_id, r.parsed_data, r.field_mappings, r.row_results, - &r.created_at, &r.expires_at, - )).transpose() + row.map(|r| { + Self::deserialize_session( + r.id, + r.user_id, + r.parsed_data, + r.field_mappings, + r.row_results, + &r.created_at, + &r.expires_at, + ) + }) + .transpose() } async fn update(&self, s: &ImportSession) -> Result<(), DomainError> { @@ -295,7 +349,9 @@ impl ImportSessionRepository for SqliteImportSessionRepository { let (_, field_mappings, row_results) = Self::serialize_session(s)?; sqlx::query!( "UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?", - field_mappings, row_results, id + field_mappings, + row_results, + id ) .execute(&self.pool) .await @@ -322,10 +378,13 @@ impl ImportSessionRepository for SqliteImportSessionRepository { async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { let uid = user_id.value().to_string(); - sqlx::query!("DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')", uid) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(Self::map_err) + sqlx::query!( + "DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')", + uid + ) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) } } diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 6240c16..704dcfc 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -402,11 +402,15 @@ impl MovieRepository for SqliteMovieRepository { &self, page: &domain::models::collections::PageParams, filter: &domain::models::MovieFilter, - ) -> Result, DomainError> { + ) -> Result, DomainError> + { use sqlx::Row; let limit = page.limit as i64; let offset = page.offset as i64; - let pattern = filter.search.as_deref().map(|s| format!("%{}%", s.to_lowercase())); + let pattern = filter + .search + .as_deref() + .map(|s| format!("%{}%", s.to_lowercase())); let genre = filter.genre.as_deref(); let language = filter.language.as_deref(); @@ -694,10 +698,7 @@ impl DiaryRepository for SqliteMovieRepository { } let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql)); - let total = count_q - .fetch_one(&self.pool) - .await - .map_err(Self::map_err)?; + let total = count_q.fetch_one(&self.pool).await.map_err(Self::map_err)?; let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql)); let rows = rows_q @@ -800,13 +801,10 @@ impl DiaryRepository for SqliteMovieRepository { let limit = page.limit as i64; let offset = page.offset as i64; - let total = sqlx::query_scalar!( - "SELECT COUNT(*) FROM reviews WHERE movie_id = ?", - id_str - ) - .fetch_one(&self.pool) - .await - .map_err(Self::map_err)?; + let total = sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE movie_id = ?", id_str) + .fetch_one(&self.pool) + .await + .map_err(Self::map_err)?; let rows = sqlx::query_as::<_, FeedRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, @@ -843,12 +841,11 @@ impl DiaryRepository for SqliteMovieRepository { } async fn count_local_posts(&self) -> Result { - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL" - ) - .fetch_one(&self.pool) - .await - .map_err(Self::map_err)?; + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL") + .fetch_one(&self.pool) + .await + .map_err(Self::map_err)?; Ok(count as u64) } } @@ -934,7 +931,9 @@ impl StatsRepository for SqliteMovieRepository { } } -pub async fn wire(database_url: &str) -> anyhow::Result<( +pub async fn wire( + database_url: &str, +) -> anyhow::Result<( sqlx::SqlitePool, std::sync::Arc, std::sync::Arc, @@ -946,9 +945,9 @@ pub async fn wire(database_url: &str) -> anyhow::Result<( std::sync::Arc, std::sync::Arc, )> { - use std::str::FromStr; use anyhow::Context; use sqlx::sqlite::SqliteConnectOptions; + use std::str::FromStr; let opts = SqliteConnectOptions::from_str(database_url) .context("Invalid DATABASE_URL")? @@ -1073,8 +1072,9 @@ mod feed_filter_tests { let repo = SqliteMovieRepository::new(pool); let filter = FollowingFilter { - local_user_ids: vec![uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111") - .unwrap()], + local_user_ids: vec![ + uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(), + ], remote_actor_urls: vec!["https://remote.social/users/carol".to_string()], }; let page = PageParams::new(Some(10), Some(0)).unwrap(); @@ -1147,7 +1147,10 @@ mod feed_filter_tests { assert_eq!(result.total_count, 1); assert_eq!(result.items.len(), 1); assert!(result.items[0].review().is_remote()); - assert_eq!(result.items[0].user_email(), "https://remote.social/users/carol"); + assert_eq!( + result.items[0].user_email(), + "https://remote.social/users/carol" + ); } #[tokio::test] @@ -1209,8 +1212,12 @@ mod diary_count_tests { .bind(&user_id).bind("a@b.com").bind("hash").bind("2024-01-01 00:00:00").bind("alice") .execute(&pool).await.unwrap(); sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)") - .bind(&movie_id).bind("Test Movie").bind(2024i32) - .execute(&pool).await.unwrap(); + .bind(&movie_id) + .bind("Test Movie") + .bind(2024i32) + .execute(&pool) + .await + .unwrap(); // Local review (remote_actor_url IS NULL) let r1 = uuid::Uuid::new_v4().to_string(); diff --git a/crates/adapters/sqlite/src/models.rs b/crates/adapters/sqlite/src/models.rs index b90f0e1..ef7cbd6 100644 --- a/crates/adapters/sqlite/src/models.rs +++ b/crates/adapters/sqlite/src/models.rs @@ -1,7 +1,10 @@ use chrono::NaiveDateTime; use domain::{ errors::DomainError, - models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary, WatchlistEntry, WatchlistWithMovie}, + models::{ + DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary, + WatchlistEntry, WatchlistWithMovie, + }, value_objects::{ Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, ReviewId, UserId, WatchlistEntryId, diff --git a/crates/adapters/sqlite/src/persons.rs b/crates/adapters/sqlite/src/persons.rs index 8a8c0b7..2ec373b 100644 --- a/crates/adapters/sqlite/src/persons.rs +++ b/crates/adapters/sqlite/src/persons.rs @@ -20,7 +20,10 @@ impl SqlitePersonAdapter { pub fn create_person_adapter(pool: SqlitePool) -> (Arc, Arc) { let adapter = Arc::new(SqlitePersonAdapter::new(pool)); - (Arc::clone(&adapter) as Arc, adapter as Arc) + ( + Arc::clone(&adapter) as Arc, + adapter as Arc, + ) } fn map_err(e: sqlx::Error) -> DomainError { @@ -70,7 +73,10 @@ impl PersonQuery for SqlitePersonAdapter { Ok(row.map(PersonRow::into_person)) } - async fn get_by_external_id(&self, id: &ExternalPersonId) -> Result, DomainError> { + async fn get_by_external_id( + &self, + id: &ExternalPersonId, + ) -> Result, DomainError> { let row = sqlx::query_as::<_, PersonRow>( "SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE external_id = ?", ) @@ -83,21 +89,25 @@ impl PersonQuery for SqlitePersonAdapter { } async fn get_credits(&self, id: &PersonId) -> Result { - let person = self.get_by_id(id).await?.ok_or_else(|| { - DomainError::NotFound(format!("Person {} not found", id.value())) - })?; + let person = self + .get_by_id(id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?; - let tmdb_id: Option = sqlx::query_scalar( - "SELECT tmdb_person_id FROM persons WHERE id = ?", - ) - .bind(id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(map_err)? - .flatten(); + let tmdb_id: Option = + sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = ?") + .bind(id.value().to_string()) + .fetch_optional(&self.pool) + .await + .map_err(map_err)? + .flatten(); let Some(tmdb_id) = tmdb_id else { - return Ok(PersonCredits { person, cast: vec![], crew: vec![] }); + return Ok(PersonCredits { + person, + cast: vec![], + crew: vec![], + }); }; let cast = sqlx::query_as::<_, CastRow>( diff --git a/crates/adapters/sqlite/src/profile.rs b/crates/adapters/sqlite/src/profile.rs index 8fd7617..80bcec5 100644 --- a/crates/adapters/sqlite/src/profile.rs +++ b/crates/adapters/sqlite/src/profile.rs @@ -66,48 +66,80 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?") .bind(&movie_id) - .execute(&mut *tx).await.map_err(Self::map_err)?; + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; for g in &p.genres { - sqlx::query("INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name) VALUES (?,?,?)") - .bind(&movie_id).bind(g.tmdb_id as i64).bind(&g.name) - .execute(&mut *tx).await.map_err(Self::map_err)?; + sqlx::query( + "INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name) VALUES (?,?,?)", + ) + .bind(&movie_id) + .bind(g.tmdb_id as i64) + .bind(&g.name) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; } sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?") .bind(&movie_id) - .execute(&mut *tx).await.map_err(Self::map_err)?; + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; for k in &p.keywords { - sqlx::query("INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) VALUES (?,?,?)") - .bind(&movie_id).bind(k.tmdb_id as i64).bind(&k.name) - .execute(&mut *tx).await.map_err(Self::map_err)?; + sqlx::query( + "INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) VALUES (?,?,?)", + ) + .bind(&movie_id) + .bind(k.tmdb_id as i64) + .bind(&k.name) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; } sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?") .bind(&movie_id) - .execute(&mut *tx).await.map_err(Self::map_err)?; + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; for c in &p.cast { sqlx::query( "INSERT OR IGNORE INTO movie_cast \ (movie_id, tmdb_person_id, name, character, billing_order, profile_path) \ VALUES (?,?,?,?,?,?)", ) - .bind(&movie_id).bind(c.tmdb_person_id as i64).bind(&c.name) - .bind(&c.character).bind(c.billing_order as i64).bind(&c.profile_path) - .execute(&mut *tx).await.map_err(Self::map_err)?; + .bind(&movie_id) + .bind(c.tmdb_person_id as i64) + .bind(&c.name) + .bind(&c.character) + .bind(c.billing_order as i64) + .bind(&c.profile_path) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; } sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?") .bind(&movie_id) - .execute(&mut *tx).await.map_err(Self::map_err)?; + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; for cr in &p.crew { sqlx::query( "INSERT OR IGNORE INTO movie_crew \ (movie_id, tmdb_person_id, name, job, department, profile_path) \ VALUES (?,?,?,?,?,?)", ) - .bind(&movie_id).bind(cr.tmdb_person_id as i64).bind(&cr.name) - .bind(&cr.job).bind(&cr.department).bind(&cr.profile_path) - .execute(&mut *tx).await.map_err(Self::map_err)?; + .bind(&movie_id) + .bind(cr.tmdb_person_id as i64) + .bind(&cr.name) + .bind(&cr.job) + .bind(&cr.department) + .bind(&cr.profile_path) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; } tx.commit().await.map_err(Self::map_err) @@ -132,7 +164,8 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { None => return Ok(None), }; - let enriched_at_str: String = row.try_get("enriched_at") + let enriched_at_str: String = row + .try_get("enriched_at") .map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?; let enriched_at: DateTime = enriched_at_str .parse() @@ -140,7 +173,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = ?") .bind(&movie_id) - .fetch_all(&self.pool).await.map_err(Self::map_err)? + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)? .into_iter() .map(|r| Genre { tmdb_id: r.try_get::("tmdb_id").unwrap_or(0) as u32, @@ -150,7 +185,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = ?") .bind(&movie_id) - .fetch_all(&self.pool).await.map_err(Self::map_err)? + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)? .into_iter() .map(|r| Keyword { tmdb_id: r.try_get::("tmdb_id").unwrap_or(0) as u32, @@ -163,7 +200,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { FROM movie_cast WHERE movie_id = ? ORDER BY billing_order", ) .bind(&movie_id) - .fetch_all(&self.pool).await.map_err(Self::map_err)? + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)? .into_iter() .map(|r| CastMember { tmdb_person_id: r.try_get::("tmdb_person_id").unwrap_or(0) as u64, @@ -179,7 +218,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { FROM movie_crew WHERE movie_id = ?", ) .bind(&movie_id) - .fetch_all(&self.pool).await.map_err(Self::map_err)? + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)? .into_iter() .map(|r| CrewMember { tmdb_person_id: r.try_get::("tmdb_person_id").unwrap_or(0) as u64, @@ -196,11 +237,19 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { imdb_id: row.try_get("imdb_id").ok(), overview: row.try_get("overview").ok(), tagline: row.try_get("tagline").ok(), - runtime_minutes: row.try_get::, _>("runtime_minutes").ok().flatten().map(|v| v as u32), + runtime_minutes: row + .try_get::, _>("runtime_minutes") + .ok() + .flatten() + .map(|v| v as u32), budget_usd: row.try_get("budget_usd").ok(), revenue_usd: row.try_get("revenue_usd").ok(), vote_average: row.try_get("vote_average").ok(), - vote_count: row.try_get::, _>("vote_count").ok().flatten().map(|v| v as u32), + vote_count: row + .try_get::, _>("vote_count") + .ok() + .flatten() + .map(|v| v as u32), original_language: row.try_get("original_language").ok(), collection_name: row.try_get("collection_name").ok(), genres, diff --git a/crates/adapters/sqlite/src/profile_fields.rs b/crates/adapters/sqlite/src/profile_fields.rs index 18ac230..831bd30 100644 --- a/crates/adapters/sqlite/src/profile_fields.rs +++ b/crates/adapters/sqlite/src/profile_fields.rs @@ -2,9 +2,7 @@ use async_trait::async_trait; use sqlx::SqlitePool; use domain::{ - errors::DomainError, - models::ProfileField, - ports::UserProfileFieldsRepository, + errors::DomainError, models::ProfileField, ports::UserProfileFieldsRepository, value_objects::UserId, }; @@ -30,10 +28,20 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository { .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(rows.into_iter().map(|r| ProfileField { name: r.name, value: r.value }).collect()) + Ok(rows + .into_iter() + .map(|r| ProfileField { + name: r.name, + value: r.value, + }) + .collect()) } - async fn set_fields(&self, user_id: &UserId, fields: Vec) -> Result<(), DomainError> { + async fn set_fields( + &self, + user_id: &UserId, + fields: Vec, + ) -> Result<(), DomainError> { let id_str = user_id.value().to_string(); sqlx::query!("DELETE FROM user_profile_fields WHERE user_id = ?", id_str) diff --git a/crates/adapters/sqlite/src/tests/image_ref.rs b/crates/adapters/sqlite/src/tests/image_ref.rs index 2a12cd4..e71c9d3 100644 --- a/crates/adapters/sqlite/src/tests/image_ref.rs +++ b/crates/adapters/sqlite/src/tests/image_ref.rs @@ -40,7 +40,9 @@ async fn list_keys_returns_both_avatar_and_poster_paths() { sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,'avatars/u1')") .execute(&pool).await.unwrap(); sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')") - .execute(&pool).await.unwrap(); + .execute(&pool) + .await + .unwrap(); let adapter = SqliteImageRefAdapter::new(pool); let mut keys = adapter.list_keys().await.unwrap(); @@ -54,8 +56,12 @@ async fn list_keys_excludes_nulls() { let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); setup(&pool).await; - sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,NULL)") - .execute(&pool).await.unwrap(); + sqlx::query( + "INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,NULL)", + ) + .execute(&pool) + .await + .unwrap(); let adapter = SqliteImageRefAdapter::new(pool); assert_eq!(adapter.list_keys().await.unwrap(), Vec::::new()); @@ -73,7 +79,9 @@ async fn swap_updates_avatar_path() { adapter.swap("avatars/u1", "avatars/u1.avif").await.unwrap(); let row: (Option,) = sqlx::query_as("SELECT avatar_path FROM users WHERE id='u1'") - .fetch_one(&pool).await.unwrap(); + .fetch_one(&pool) + .await + .unwrap(); assert_eq!(row.0.as_deref(), Some("avatars/u1.avif")); } @@ -83,13 +91,17 @@ async fn swap_updates_poster_path() { setup(&pool).await; sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')") - .execute(&pool).await.unwrap(); + .execute(&pool) + .await + .unwrap(); let adapter = SqliteImageRefAdapter::new(pool.clone()); adapter.swap("posters/m1", "posters/m1.avif").await.unwrap(); let row: (Option,) = sqlx::query_as("SELECT poster_path FROM movies WHERE id='m1'") - .fetch_one(&pool).await.unwrap(); + .fetch_one(&pool) + .await + .unwrap(); assert_eq!(row.0.as_deref(), Some("posters/m1.avif")); } @@ -99,5 +111,8 @@ async fn swap_noop_when_key_not_found() { setup(&pool).await; let adapter = SqliteImageRefAdapter::new(pool); - adapter.swap("missing/key", "missing/key.avif").await.unwrap(); + adapter + .swap("missing/key", "missing/key.avif") + .await + .unwrap(); } diff --git a/crates/adapters/sqlite/src/tests/persons.rs b/crates/adapters/sqlite/src/tests/persons.rs index 36c9e74..37f3524 100644 --- a/crates/adapters/sqlite/src/tests/persons.rs +++ b/crates/adapters/sqlite/src/tests/persons.rs @@ -61,11 +61,16 @@ async fn upsert_batch_inserts_persons() { let pool = pool_with_schema().await; let adapter = SqlitePersonAdapter::new(pool.clone()); - let persons = vec![make_person(1, "Alice", Some("Acting")), make_person(2, "Bob", Some("Directing"))]; + let persons = vec![ + make_person(1, "Alice", Some("Acting")), + make_person(2, "Bob", Some("Directing")), + ]; adapter.upsert_batch(&persons).await.unwrap(); let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM persons") - .fetch_one(&pool).await.unwrap(); + .fetch_one(&pool) + .await + .unwrap(); assert_eq!(count.0, 2); } @@ -79,7 +84,9 @@ async fn upsert_batch_is_idempotent() { adapter.upsert_batch(&persons).await.unwrap(); let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM persons") - .fetch_one(&pool).await.unwrap(); + .fetch_one(&pool) + .await + .unwrap(); assert_eq!(count.0, 1); } @@ -114,9 +121,13 @@ async fn get_credits_returns_cast_and_crew() { adapter.upsert_batch(&[p.clone()]).await.unwrap(); sqlx::query("INSERT INTO movies VALUES ('m1', 'The Film', 2020, 'Dir', NULL, NULL)") - .execute(&pool).await.unwrap(); + .execute(&pool) + .await + .unwrap(); sqlx::query("INSERT INTO movie_cast VALUES ('m1', 7, 'Diana', 'Hero', 1, NULL)") - .execute(&pool).await.unwrap(); + .execute(&pool) + .await + .unwrap(); let credits = adapter.get_credits(p.id()).await.unwrap(); assert_eq!(credits.person.name(), "Diana"); diff --git a/crates/adapters/sqlite/src/tests/users.rs b/crates/adapters/sqlite/src/tests/users.rs index e373e02..dd8d17b 100644 --- a/crates/adapters/sqlite/src/tests/users.rs +++ b/crates/adapters/sqlite/src/tests/users.rs @@ -36,7 +36,7 @@ async fn find_by_id_returns_user_when_found() { let (pool, repo) = setup().await; let id = uuid::Uuid::new_v4(); sqlx::query( - "INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)" + "INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)", ) .bind(id.to_string()) .bind("test@example.com") @@ -88,10 +88,18 @@ async fn update_profile_clears_fields_with_none() { UserRole::Standard, ); repo.save(&user).await.unwrap(); - repo.update_profile(user.id(), Some("bio".to_string()), Some("path".to_string()), None, None) + repo.update_profile( + user.id(), + Some("bio".to_string()), + Some("path".to_string()), + None, + None, + ) + .await + .unwrap(); + repo.update_profile(user.id(), None, None, None, None) .await .unwrap(); - repo.update_profile(user.id(), None, None, None, None).await.unwrap(); let found = repo.find_by_id(user.id()).await.unwrap().unwrap(); assert_eq!(found.bio(), None); diff --git a/crates/adapters/sqlite/src/users.rs b/crates/adapters/sqlite/src/users.rs index 05a2203..767f505 100644 --- a/crates/adapters/sqlite/src/users.rs +++ b/crates/adapters/sqlite/src/users.rs @@ -177,7 +177,13 @@ impl UserRepository for SqliteUserRepository { .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let profile_fields = field_rows.into_iter().map(|f| ProfileField { name: f.name, value: f.value }).collect(); + let profile_fields = field_rows + .into_iter() + .map(|f| ProfileField { + name: f.name, + value: f.value, + }) + .collect(); Self::row_to_user( r.id.unwrap_or_default(), @@ -190,7 +196,8 @@ impl UserRepository for SqliteUserRepository { r.banner_path, r.also_known_as, profile_fields, - ).map(Some) + ) + .map(Some) } async fn update_profile( diff --git a/crates/adapters/sqlite/src/watchlist.rs b/crates/adapters/sqlite/src/watchlist.rs index aac455c..e63f558 100644 --- a/crates/adapters/sqlite/src/watchlist.rs +++ b/crates/adapters/sqlite/src/watchlist.rs @@ -1,7 +1,10 @@ use async_trait::async_trait; use domain::{ errors::DomainError, - models::{WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}}, + models::{ + WatchlistEntry, WatchlistWithMovie, + collections::{PageParams, Paginated}, + }, ports::WatchlistRepository, value_objects::{MovieId, UserId}, }; @@ -51,14 +54,13 @@ impl WatchlistRepository for SqliteWatchlistRepository { let uid = user_id.value().to_string(); let mid = movie_id.value().to_string(); - let result = sqlx::query( - "DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?", - ) - .bind(&uid) - .bind(&mid) - .execute(&self.pool) - .await - .map_err(Self::map_err)?; + let result = + sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?") + .bind(&uid) + .bind(&mid) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; if result.rows_affected() == 0 { return Err(DomainError::NotFound(format!( @@ -76,14 +78,13 @@ impl WatchlistRepository for SqliteWatchlistRepository { ) -> Result { let uid = user_id.value().to_string(); let mid = movie_id.value().to_string(); - let result = sqlx::query( - "DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?", - ) - .bind(&uid) - .bind(&mid) - .execute(&self.pool) - .await - .map_err(Self::map_err)?; + let result = + sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?") + .bind(&uid) + .bind(&mid) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; Ok(result.rows_affected() > 0) } @@ -113,15 +114,13 @@ impl WatchlistRepository for SqliteWatchlistRepository { .await .map_err(Self::map_err)?; - let total: i64 = sqlx::query( - "SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ?", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await - .map_err(Self::map_err)? - .try_get(0) - .unwrap_or(0); + let total: i64 = sqlx::query("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ?") + .bind(&uid) + .fetch_one(&self.pool) + .await + .map_err(Self::map_err)? + .try_get(0) + .unwrap_or(0); let items = rows .into_iter() diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index aaa3095..6f75ed6 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -14,7 +14,10 @@ use domain::models::{ mod filters { #[askama::filter_fn] - pub fn poster_src(path: T, _env: &dyn askama::Values) -> askama::Result { + pub fn poster_src( + path: T, + _env: &dyn askama::Values, + ) -> askama::Result { let p = path.to_string(); if p.starts_with("http://") || p.starts_with("https://") { Ok(p) @@ -142,7 +145,8 @@ impl<'a> ActivityFeedTemplate<'a> { format!("sort_by={}", self.sort_by), ]; if !self.search.is_empty() { - let encoded = self.search + let encoded = self + .search .replace(' ', "+") .replace('#', "%23") .replace('&', "%26") @@ -217,7 +221,8 @@ impl<'a> ProfileTemplate<'a> { format!("sort_by={}", self.sort_by), ]; if !self.search.is_empty() { - let encoded = self.search + let encoded = self + .search .replace(' ', "+") .replace('#', "%23") .replace('&', "%26") @@ -493,7 +498,8 @@ impl HtmlRenderer for AskamaHtmlRenderer { } }) .collect(); - let remote_actors = data.remote_actors + let remote_actors = data + .remote_actors .into_iter() .map(|a| { let name = a.display_name.unwrap_or_else(|| a.handle.clone()); @@ -543,9 +549,7 @@ impl HtmlRenderer for AskamaHtmlRenderer { let total_pages = data .entries .as_ref() - .map(|e| { - e.total_count.div_ceil(e.limit.max(1) as u64) as u32 - }) + .map(|e| e.total_count.div_ceil(e.limit.max(1) as u64) as u32) .unwrap_or(0); let current_page = data.current_offset.checked_div(data.limit).unwrap_or(0); let avg_rating_display = data diff --git a/crates/adapters/tmdb-enrichment/src/lib.rs b/crates/adapters/tmdb-enrichment/src/lib.rs index 72e2ab4..543765b 100644 --- a/crates/adapters/tmdb-enrichment/src/lib.rs +++ b/crates/adapters/tmdb-enrichment/src/lib.rs @@ -1,13 +1,16 @@ use std::sync::Arc; +use application::{commands::EnrichMovieCommand, use_cases::enrich_movie}; use async_trait::async_trait; use chrono::Utc; -use application::{commands::EnrichMovieCommand, use_cases::enrich_movie}; use domain::{ errors::DomainError, events::DomainEvent, models::{CastMember, CrewMember, Genre, Keyword, MovieProfile}, - ports::{EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository, PersonCommand, SearchCommand}, + ports::{ + EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository, + PersonCommand, SearchCommand, + }, value_objects::MovieId, }; use serde::Deserialize; @@ -21,40 +24,56 @@ pub struct TmdbEnrichmentClient { impl TmdbEnrichmentClient { pub fn from_env() -> Result { - let api_key = std::env::var("TMDB_API_KEY").map_err(|_| { - DomainError::InfrastructureError("TMDB_API_KEY is not set".into()) - })?; - Ok(Self { api_key, http: reqwest::Client::new() }) + let api_key = std::env::var("TMDB_API_KEY") + .map_err(|_| DomainError::InfrastructureError("TMDB_API_KEY is not set".into()))?; + Ok(Self { + api_key, + http: reqwest::Client::new(), + }) } fn base(&self, path: &str) -> String { format!("https://api.themoviedb.org/3{}", path) } - async fn get Deserialize<'de>>(&self, url: &str, extra: &[(&str, &str)]) -> Result { - let mut req = self.http.get(url).query(&[("api_key", self.api_key.as_str())]); + async fn get Deserialize<'de>>( + &self, + url: &str, + extra: &[(&str, &str)], + ) -> Result { + let mut req = self + .http + .get(url) + .query(&[("api_key", self.api_key.as_str())]); for (k, v) in extra { req = req.query(&[(k, v)]); } - req.send().await + req.send() + .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))? .error_for_status() .map_err(|e| DomainError::InfrastructureError(e.to_string()))? - .json::().await + .json::() + .await .map_err(|e| DomainError::InfrastructureError(e.to_string())) } async fn resolve_tmdb_id(&self, external_id: &str) -> Result { if let Some(numeric) = external_id.strip_prefix("tmdb:") { - return numeric.parse::() - .map_err(|_| DomainError::InfrastructureError(format!("Invalid tmdb id: {numeric}"))); + return numeric.parse::().map_err(|_| { + DomainError::InfrastructureError(format!("Invalid tmdb id: {numeric}")) + }); } // Assume IMDb ID (tt…) — use /find #[derive(Deserialize)] - struct FindResult { id: u64 } + struct FindResult { + id: u64, + } #[derive(Deserialize)] - struct FindResponse { movie_results: Vec } + struct FindResponse { + movie_results: Vec, + } let url = self.base(&format!("/find/{}", external_id)); let resp: FindResponse = self.get(&url, &[("external_source", "imdb_id")]).await?; @@ -68,14 +87,23 @@ impl TmdbEnrichmentClient { #[async_trait] impl MovieEnrichmentClient for TmdbEnrichmentClient { - async fn fetch_profile(&self, movie_id: MovieId, external_metadata_id: &str) -> Result { + async fn fetch_profile( + &self, + movie_id: MovieId, + external_metadata_id: &str, + ) -> Result { let tmdb_id = self.resolve_tmdb_id(external_metadata_id).await?; #[derive(Deserialize)] - struct GenreDto { id: u32, name: String } + struct GenreDto { + id: u32, + name: String, + } #[derive(Deserialize)] - struct CollectionDto { name: String } + struct CollectionDto { + name: String, + } #[derive(Deserialize)] struct CastDto { @@ -96,13 +124,21 @@ impl MovieEnrichmentClient for TmdbEnrichmentClient { } #[derive(Deserialize)] - struct Credits { cast: Vec, crew: Vec } + struct Credits { + cast: Vec, + crew: Vec, + } #[derive(Deserialize)] - struct KeywordDto { id: u32, name: String } + struct KeywordDto { + id: u32, + name: String, + } #[derive(Deserialize)] - struct Keywords { keywords: Vec } + struct Keywords { + keywords: Vec, + } #[derive(Deserialize)] struct Details { @@ -122,7 +158,9 @@ impl MovieEnrichmentClient for TmdbEnrichmentClient { } let url = self.base(&format!("/movie/{}", tmdb_id)); - let d: Details = self.get(&url, &[("append_to_response", "credits,keywords")]).await?; + let d: Details = self + .get(&url, &[("append_to_response", "credits,keywords")]) + .await?; Ok(MovieProfile { movie_id, @@ -137,24 +175,47 @@ impl MovieEnrichmentClient for TmdbEnrichmentClient { vote_count: d.vote_count, original_language: d.original_language, collection_name: d.belongs_to_collection.map(|c| c.name), - genres: d.genres.into_iter().map(|g| Genre { tmdb_id: g.id, name: g.name }).collect(), - keywords: d.keywords.keywords.into_iter() - .map(|k| Keyword { tmdb_id: k.id, name: k.name }) + genres: d + .genres + .into_iter() + .map(|g| Genre { + tmdb_id: g.id, + name: g.name, + }) + .collect(), + keywords: d + .keywords + .keywords + .into_iter() + .map(|k| Keyword { + tmdb_id: k.id, + name: k.name, + }) + .collect(), + cast: d + .credits + .cast + .into_iter() + .map(|c| CastMember { + tmdb_person_id: c.id, + name: c.name, + character: c.character, + billing_order: c.order, + profile_path: c.profile_path, + }) + .collect(), + crew: d + .credits + .crew + .into_iter() + .map(|c| CrewMember { + tmdb_person_id: c.id, + name: c.name, + job: c.job, + department: c.department, + profile_path: c.profile_path, + }) .collect(), - cast: d.credits.cast.into_iter().map(|c| CastMember { - tmdb_person_id: c.id, - name: c.name, - character: c.character, - billing_order: c.order, - profile_path: c.profile_path, - }).collect(), - crew: d.credits.crew.into_iter().map(|c| CrewMember { - tmdb_person_id: c.id, - name: c.name, - job: c.job, - department: c.department, - profile_path: c.profile_path, - }).collect(), enriched_at: Utc::now(), }) } @@ -164,19 +225,20 @@ impl MovieEnrichmentClient for TmdbEnrichmentClient { pub struct EnrichmentHandler { pub enrichment_client: Arc, - pub movie_repository: Arc, - pub profile_repo: Arc, - pub person_command: Arc, - pub search_command: Arc, + pub movie_repository: Arc, + pub profile_repo: Arc, + pub person_command: Arc, + pub search_command: Arc, } #[async_trait] impl EventHandler for EnrichmentHandler { async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { let (movie_id, external_metadata_id) = match event { - DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id } => { - (movie_id.clone(), external_metadata_id.clone()) - } + DomainEvent::MovieEnrichmentRequested { + movie_id, + external_metadata_id, + } => (movie_id.clone(), external_metadata_id.clone()), _ => return Ok(()), }; @@ -195,7 +257,11 @@ impl EventHandler for EnrichmentHandler { tracing::info!(movie_id = %movie_id.value(), external_id = %external_metadata_id, "enriching movie"); - match self.enrichment_client.fetch_profile(movie_id.clone(), &external_metadata_id).await { + match self + .enrichment_client + .fetch_profile(movie_id.clone(), &external_metadata_id) + .await + { Ok(profile) => { enrich_movie::execute( &self.movie_repository, diff --git a/crates/application/src/context.rs b/crates/application/src/context.rs index 76585ba..81456bd 100644 --- a/crates/application/src/context.rs +++ b/crates/application/src/context.rs @@ -1,16 +1,14 @@ use std::sync::Arc; -use domain::ports::{ - AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, - ImageStorage, - ImportProfileRepository, ImportSessionRepository, - MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher, PosterFetcherClient, - PersonCommand, PersonQuery, SearchCommand, SearchPort, - ReviewRepository, StatsRepository, UserProfileFieldsRepository, UserRepository, - WatchlistRepository, -}; #[cfg(feature = "federation")] use domain::ports::RemoteWatchlistRepository; +use domain::ports::{ + AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage, + ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository, + MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, + ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository, + UserRepository, WatchlistRepository, +}; use crate::config::AppConfig; diff --git a/crates/application/src/jobs.rs b/crates/application/src/jobs.rs index eb39646..f1996f6 100644 --- a/crates/application/src/jobs.rs +++ b/crates/application/src/jobs.rs @@ -51,10 +51,12 @@ impl PeriodicJob for EnrichmentStalenessJob { } tracing::info!("enrichment scan: {} stale movies", stale.len()); for (movie_id, external_metadata_id) in stale { - let event = DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id }; + let event = DomainEvent::MovieEnrichmentRequested { + movie_id, + external_metadata_id, + }; self.ctx.event_publisher.publish(&event).await?; } Ok(()) } } - diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index aa921c0..653105a 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,14 +1,14 @@ pub mod commands; -pub mod jobs; -pub mod worker; pub mod config; pub mod context; +pub mod jobs; +pub mod movie_discovery_indexer; pub mod movie_resolver; pub mod ports; pub mod queries; -pub mod use_cases; -pub mod movie_discovery_indexer; pub mod search_cleanup; +pub mod use_cases; +pub mod worker; pub use movie_discovery_indexer::MovieDiscoveryIndexer; pub use search_cleanup::SearchCleanupHandler; diff --git a/crates/application/src/movie_discovery_indexer.rs b/crates/application/src/movie_discovery_indexer.rs index 893dff4..20332f8 100644 --- a/crates/application/src/movie_discovery_indexer.rs +++ b/crates/application/src/movie_discovery_indexer.rs @@ -13,12 +13,18 @@ use domain::{ /// Enrichment will later overwrite this with the full document (cast, genres, etc.). pub struct MovieDiscoveryIndexer { movie_repository: Arc, - search_command: Arc, + search_command: Arc, } impl MovieDiscoveryIndexer { - pub fn new(movie_repository: Arc, search_command: Arc) -> Self { - Self { movie_repository, search_command } + pub fn new( + movie_repository: Arc, + search_command: Arc, + ) -> Self { + Self { + movie_repository, + search_command, + } } } @@ -35,7 +41,8 @@ impl EventHandler for MovieDiscoveryIndexer { return Ok(()); }; - if let Err(e) = self.search_command + if let Err(e) = self + .search_command .index(IndexableDocument::Movie { id: movie_id.clone(), movie: Box::new(movie), diff --git a/crates/application/src/movie_resolver.rs b/crates/application/src/movie_resolver.rs index 42181d3..57d959e 100644 --- a/crates/application/src/movie_resolver.rs +++ b/crates/application/src/movie_resolver.rs @@ -49,9 +49,10 @@ impl MovieResolver { ) -> Result<(Movie, bool), DomainError> { for strategy in &self.strategies { if strategy.can_handle(input) - && let Some(result) = strategy.resolve(input, deps).await? { - return Ok(result); - } + && let Some(result) = strategy.resolve(input, deps).await? + { + return Ok(result); + } } Err(DomainError::ValidationError( "Manual title required if TMDB fetch fails or is omitted".into(), @@ -108,13 +109,17 @@ impl ResolutionStrategy for TitleSearchStrategy { let title = input.manual_title.as_deref().unwrap(); let criteria = MetadataSearchCriteria::Title { title: MovieTitle::new(title.to_string())?, - year: input.manual_release_year.map(ReleaseYear::new).transpose()?, + year: input + .manual_release_year + .map(ReleaseYear::new) + .transpose()?, }; match deps.metadata_client.fetch_movie_metadata(&criteria).await { Ok(m) => { // Movie may already exist in DB under this external_metadata_id if let Some(ext_id) = m.external_metadata_id() { - if let Some(existing) = deps.repository.get_movie_by_external_id(ext_id).await? { + if let Some(existing) = deps.repository.get_movie_by_external_id(ext_id).await? + { return Ok(Some((existing, false))); } } @@ -164,8 +169,13 @@ impl ResolutionStrategy for ManualMovieStrategy { if let Some(existing) = matched { Ok(Some((existing, false))) } else { - let new_movie = - Movie::new(None, title, release_year, input.manual_director.clone(), None); + let new_movie = Movie::new( + None, + title, + release_year, + input.manual_director.clone(), + None, + ); Ok(Some((new_movie, true))) } } diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index 118a39f..983a244 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -224,10 +224,8 @@ pub trait HtmlRenderer: Send + Sync { fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result; fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result; fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result; - fn render_profile_settings_page( - &self, - data: ProfileSettingsPageData, - ) -> Result; + fn render_profile_settings_page(&self, data: ProfileSettingsPageData) + -> Result; fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result; fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result; fn render_watchlist_page(&self, data: WatchlistPageData) -> Result; diff --git a/crates/application/src/search_cleanup.rs b/crates/application/src/search_cleanup.rs index c1d32b9..280c42a 100644 --- a/crates/application/src/search_cleanup.rs +++ b/crates/application/src/search_cleanup.rs @@ -10,12 +10,15 @@ use domain::{ pub struct SearchCleanupHandler { search_command: Arc, - person_query: Arc, + person_query: Arc, } impl SearchCleanupHandler { pub fn new(search_command: Arc, person_query: Arc) -> Self { - Self { search_command, person_query } + Self { + search_command, + person_query, + } } } @@ -27,7 +30,11 @@ impl EventHandler for SearchCleanupHandler { _ => return Ok(()), }; - if let Err(e) = self.search_command.remove(EntityType::Movie, &movie_id).await { + if let Err(e) = self + .search_command + .remove(EntityType::Movie, &movie_id) + .await + { tracing::warn!("search cleanup failed for movie {movie_id}: {e}"); } @@ -41,7 +48,9 @@ impl EventHandler for SearchCleanupHandler { } } } - Err(e) => tracing::warn!("failed to list orphaned persons after movie {movie_id} deletion: {e}"), + Err(e) => tracing::warn!( + "failed to list orphaned persons after movie {movie_id} deletion: {e}" + ), } Ok(()) diff --git a/crates/application/src/tests/movie_resolver.rs b/crates/application/src/tests/movie_resolver.rs index 339d5e4..bb676f8 100644 --- a/crates/application/src/tests/movie_resolver.rs +++ b/crates/application/src/tests/movie_resolver.rs @@ -52,8 +52,17 @@ impl MovieRepository for RepoWithExternalMovie { async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } - async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result, DomainError> { panic!("unexpected") } + async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { + panic!("unexpected") + } + async fn list_movies( + &self, + _: &domain::models::collections::PageParams, + _: &domain::models::MovieFilter, + ) -> Result, DomainError> + { + panic!("unexpected") + } } #[async_trait::async_trait] @@ -74,9 +83,20 @@ impl MovieRepository for RepoEmpty { ) -> Result, DomainError> { Ok(vec![]) } - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } - async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result, DomainError> { panic!("unexpected") } + async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { + panic!("unexpected") + } + async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { + panic!("unexpected") + } + async fn list_movies( + &self, + _: &domain::models::collections::PageParams, + _: &domain::models::MovieFilter, + ) -> Result, DomainError> + { + panic!("unexpected") + } } #[async_trait::async_trait] @@ -97,9 +117,20 @@ impl MovieRepository for RepoWithTitleMatch { ) -> Result, DomainError> { Ok(vec![self.0.clone()]) } - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } - async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result, DomainError> { panic!("unexpected") } + async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { + panic!("unexpected") + } + async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { + panic!("unexpected") + } + async fn list_movies( + &self, + _: &domain::models::collections::PageParams, + _: &domain::models::MovieFilter, + ) -> Result, DomainError> + { + panic!("unexpected") + } } struct MetaReturnsMovie(Movie); @@ -107,10 +138,7 @@ struct MetaErrors; #[async_trait::async_trait] impl MetadataClient for MetaReturnsMovie { - async fn fetch_movie_metadata( - &self, - _: &MetadataSearchCriteria, - ) -> Result { + async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result { Ok(self.0.clone()) } async fn get_poster_url( @@ -123,10 +151,7 @@ impl MetadataClient for MetaReturnsMovie { #[async_trait::async_trait] impl MetadataClient for MetaErrors { - async fn fetch_movie_metadata( - &self, - _: &MetadataSearchCriteria, - ) -> Result { + async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result { Err(DomainError::InfrastructureError( "metadata unavailable".into(), )) @@ -299,7 +324,9 @@ async fn resolver_returns_error_when_no_strategy_matches() { metadata_client: &meta, }; let input = make_input(None, None, None); - let result = MovieResolver::default_pipeline().resolve(&input, &deps).await; + let result = MovieResolver::default_pipeline() + .resolve(&input, &deps) + .await; assert!(result.is_err()); } diff --git a/crates/application/src/use_cases/add_to_watchlist.rs b/crates/application/src/use_cases/add_to_watchlist.rs index 0a9177f..7b28905 100644 --- a/crates/application/src/use_cases/add_to_watchlist.rs +++ b/crates/application/src/use_cases/add_to_watchlist.rs @@ -31,10 +31,13 @@ pub async fn execute(ctx: &AppContext, cmd: AddToWatchlistCommand) -> Result<(), if is_new { ctx.movie_repository.upsert_movie(&movie).await?; if let Some(ext_id) = movie.external_metadata_id() { - let _ = ctx.event_publisher.publish(&DomainEvent::MovieDiscovered { - movie_id: movie.id().clone(), - external_metadata_id: ext_id.clone(), - }).await; + let _ = ctx + .event_publisher + .publish(&DomainEvent::MovieDiscovered { + movie_id: movie.id().clone(), + external_metadata_id: ext_id.clone(), + }) + .await; } } movie @@ -43,14 +46,17 @@ pub async fn execute(ctx: &AppContext, cmd: AddToWatchlistCommand) -> Result<(), let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone()); ctx.watchlist_repository.add(&entry).await?; - let _ = ctx.event_publisher.publish(&DomainEvent::WatchlistEntryAdded { - user_id, - movie_id: movie.id().clone(), - movie_title: movie.title().value().to_string(), - release_year: movie.release_year().value(), - external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()), - added_at: entry.added_at, - }).await; + let _ = ctx + .event_publisher + .publish(&DomainEvent::WatchlistEntryAdded { + user_id, + movie_id: movie.id().clone(), + movie_title: movie.title().value().to_string(), + release_year: movie.release_year().value(), + external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()), + added_at: entry.added_at, + }) + .await; Ok(()) } diff --git a/crates/application/src/use_cases/apply_import_mapping.rs b/crates/application/src/use_cases/apply_import_mapping.rs index a58c9a0..d38e9a1 100644 --- a/crates/application/src/use_cases/apply_import_mapping.rs +++ b/crates/application/src/use_cases/apply_import_mapping.rs @@ -6,17 +6,23 @@ use domain::{ use crate::{commands::ApplyImportMappingCommand, context::AppContext}; -pub async fn execute(ctx: &AppContext, cmd: ApplyImportMappingCommand) -> Result, DomainError> { +pub async fn execute( + ctx: &AppContext, + cmd: ApplyImportMappingCommand, +) -> Result, DomainError> { let user_id = UserId::from_uuid(cmd.user_id); let session_id = ImportSessionId::from_uuid(cmd.session_id); let mappings = cmd.mappings; - let mut session = ctx.import_session_repository + let mut session = ctx + .import_session_repository .get(&session_id, &user_id) .await? .ok_or_else(|| DomainError::NotFound("import session".into()))?; // clone to avoid borrow conflict when mutating session fields below - let parsed = session.parsed_file.clone() + let parsed = session + .parsed_file + .clone() .ok_or_else(|| DomainError::ValidationError("session has no parsed file".into()))?; let mut annotated = ctx.document_parser.apply_mapping(&parsed, &mappings); @@ -35,17 +41,31 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportMappingCommand) -> Result Ok(annotated) } -async fn check_duplicate(ctx: &AppContext, row: &domain::models::ImportRow) -> Result { +async fn check_duplicate( + ctx: &AppContext, + row: &domain::models::ImportRow, +) -> Result { if let Some(ext_id) = &row.external_metadata_id && let Ok(eid) = ExternalMetadataId::new(ext_id.clone()) - && ctx.movie_repository.get_movie_by_external_id(&eid).await?.is_some() { - return Ok(true); - } + && ctx + .movie_repository + .get_movie_by_external_id(&eid) + .await? + .is_some() + { + return Ok(true); + } if let (Some(title), Some(year_str)) = (&row.title, &row.release_year) { let title_vo = MovieTitle::new(title.clone()); - let year_vo = year_str.parse::().ok().and_then(|y| ReleaseYear::new(y).ok()); + let year_vo = year_str + .parse::() + .ok() + .and_then(|y| ReleaseYear::new(y).ok()); if let (Ok(t), Some(y)) = (title_vo, year_vo) { - let matches = ctx.movie_repository.get_movies_by_title_and_year(&t, &y).await?; + let matches = ctx + .movie_repository + .get_movies_by_title_and_year(&t, &y) + .await?; if !matches.is_empty() { return Ok(true); } diff --git a/crates/application/src/use_cases/apply_import_profile.rs b/crates/application/src/use_cases/apply_import_profile.rs index e85bcdd..787bf5c 100644 --- a/crates/application/src/use_cases/apply_import_profile.rs +++ b/crates/application/src/use_cases/apply_import_profile.rs @@ -1,5 +1,8 @@ -use domain::{errors::DomainError, value_objects::{ImportProfileId, ImportSessionId, UserId}}; use crate::{commands::ApplyImportProfileCommand, context::AppContext}; +use domain::{ + errors::DomainError, + value_objects::{ImportProfileId, ImportSessionId, UserId}, +}; /// Copies the profile's field_mappings onto the session. Caller must then invoke /// apply_import_mapping to regenerate row_results with the new mappings. @@ -8,11 +11,15 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportProfileCommand) -> Result let session_id = ImportSessionId::from_uuid(cmd.session_id); let profile_id = ImportProfileId::from_uuid(cmd.profile_id); - let profile = ctx.import_profile_repository - .get(&profile_id, &user_id).await? + let profile = ctx + .import_profile_repository + .get(&profile_id, &user_id) + .await? .ok_or_else(|| DomainError::NotFound("import profile".into()))?; - let mut session = ctx.import_session_repository - .get(&session_id, &user_id).await? + let mut session = ctx + .import_session_repository + .get(&session_id, &user_id) + .await? .ok_or_else(|| DomainError::NotFound("import session".into()))?; session.field_mappings = Some(profile.field_mappings); session.row_results = None; diff --git a/crates/application/src/use_cases/cleanup_expired_import_sessions.rs b/crates/application/src/use_cases/cleanup_expired_import_sessions.rs index f2048a4..2610ef5 100644 --- a/crates/application/src/use_cases/cleanup_expired_import_sessions.rs +++ b/crates/application/src/use_cases/cleanup_expired_import_sessions.rs @@ -1,5 +1,5 @@ -use domain::errors::DomainError; use crate::context::AppContext; +use domain::errors::DomainError; pub async fn execute(ctx: &AppContext) -> Result { ctx.import_session_repository.delete_expired().await diff --git a/crates/application/src/use_cases/create_import_session.rs b/crates/application/src/use_cases/create_import_session.rs index 90967ab..b295a26 100644 --- a/crates/application/src/use_cases/create_import_session.rs +++ b/crates/application/src/use_cases/create_import_session.rs @@ -13,11 +13,17 @@ pub struct CreateSessionResult { pub sample_rows: Vec>, } -pub async fn execute(ctx: &AppContext, cmd: CreateImportSessionCommand) -> Result { +pub async fn execute( + ctx: &AppContext, + cmd: CreateImportSessionCommand, +) -> Result { let user_id = UserId::from_uuid(cmd.user_id); - ctx.import_session_repository.delete_expired_for_user(&user_id).await?; + ctx.import_session_repository + .delete_expired_for_user(&user_id) + .await?; - let parsed = ctx.document_parser + let parsed = ctx + .document_parser .parse(&cmd.bytes, cmd.format) .map_err(|e| DomainError::ValidationError(e.to_string()))?; @@ -31,5 +37,9 @@ pub async fn execute(ctx: &AppContext, cmd: CreateImportSessionCommand) -> Resul ctx.import_session_repository.create(&session).await?; - Ok(CreateSessionResult { session_id, columns, sample_rows }) + Ok(CreateSessionResult { + session_id, + columns, + sample_rows, + }) } diff --git a/crates/application/src/use_cases/delete_import_profile.rs b/crates/application/src/use_cases/delete_import_profile.rs index 4951cdf..bf3c7df 100644 --- a/crates/application/src/use_cases/delete_import_profile.rs +++ b/crates/application/src/use_cases/delete_import_profile.rs @@ -1,12 +1,16 @@ -use domain::{errors::DomainError, value_objects::{ImportProfileId, UserId}}; use crate::{commands::DeleteImportProfileCommand, context::AppContext}; +use domain::{ + errors::DomainError, + value_objects::{ImportProfileId, UserId}, +}; pub async fn execute(ctx: &AppContext, cmd: DeleteImportProfileCommand) -> Result<(), DomainError> { let user_id = UserId::from_uuid(cmd.user_id); let profile_id = ImportProfileId::from_uuid(cmd.profile_id); ctx.import_profile_repository - .get(&profile_id, &user_id).await? + .get(&profile_id, &user_id) + .await? .ok_or_else(|| DomainError::NotFound("import profile".into()))?; ctx.import_profile_repository.delete(&profile_id).await } diff --git a/crates/application/src/use_cases/delete_review.rs b/crates/application/src/use_cases/delete_review.rs index 36c257f..0916800 100644 --- a/crates/application/src/use_cases/delete_review.rs +++ b/crates/application/src/use_cases/delete_review.rs @@ -38,8 +38,12 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), D let poster_path = history.movie().poster_path().cloned(); ctx.movie_repository.delete_movie(&movie_id).await?; // best-effort: movie is already deleted, so publish failure is non-fatal - if let Err(e) = ctx.event_publisher - .publish(&DomainEvent::MovieDeleted { movie_id, poster_path }) + if let Err(e) = ctx + .event_publisher + .publish(&DomainEvent::MovieDeleted { + movie_id, + poster_path, + }) .await { tracing::warn!("failed to publish MovieDeleted event: {e}"); diff --git a/crates/application/src/use_cases/enrich_movie.rs b/crates/application/src/use_cases/enrich_movie.rs index e1f6455..4c23e95 100644 --- a/crates/application/src/use_cases/enrich_movie.rs +++ b/crates/application/src/use_cases/enrich_movie.rs @@ -3,9 +3,7 @@ use std::sync::Arc; use domain::{ errors::DomainError, - models::{ - CastMember, CrewMember, ExternalPersonId, IndexableDocument, Person, PersonId, - }, + models::{CastMember, CrewMember, ExternalPersonId, IndexableDocument, Person, PersonId}, ports::{MovieProfileRepository, MovieRepository, PersonCommand, SearchCommand}, }; diff --git a/crates/application/src/use_cases/execute_import.rs b/crates/application/src/use_cases/execute_import.rs index 3ed98bb..0f83068 100644 --- a/crates/application/src/use_cases/execute_import.rs +++ b/crates/application/src/use_cases/execute_import.rs @@ -6,7 +6,11 @@ use domain::{ }; use uuid::Uuid; -use crate::{commands::{ExecuteImportCommand, LogReviewCommand, MovieInput}, context::AppContext, use_cases::log_review}; +use crate::{ + commands::{ExecuteImportCommand, LogReviewCommand, MovieInput}, + context::AppContext, + use_cases::log_review, +}; pub struct ImportSummary { pub imported: usize, @@ -14,11 +18,15 @@ pub struct ImportSummary { pub failed: Vec<(usize, String)>, } -pub async fn execute(ctx: &AppContext, cmd: ExecuteImportCommand) -> Result { +pub async fn execute( + ctx: &AppContext, + cmd: ExecuteImportCommand, +) -> Result { let user_id = UserId::from_uuid(cmd.user_id); let session_id = ImportSessionId::from_uuid(cmd.session_id); let confirmed_indices = cmd.confirmed_indices; - let session = ctx.import_session_repository + let session = ctx + .import_session_repository .get(&session_id, &user_id) .await? .ok_or_else(|| DomainError::NotFound("import session".into()))?; @@ -36,17 +44,13 @@ pub async fn execute(ctx: &AppContext, cmd: ExecuteImportCommand) -> Result { - match row_to_command(&row, user_id.value()) { - Ok(cmd) => { - match log_review::execute(ctx, cmd).await { - Ok(_) => imported += 1, - Err(e) => failed.push((idx, e.to_string())), - } - } - Err(e) => failed.push((idx, e)), - } - } + RowResult::Valid(row) => match row_to_command(&row, user_id.value()) { + Ok(cmd) => match log_review::execute(ctx, cmd).await { + Ok(_) => imported += 1, + Err(e) => failed.push((idx, e.to_string())), + }, + Err(e) => failed.push((idx, e)), + }, RowResult::Invalid { errors, .. } => { failed.push((idx, errors.join("; "))); } @@ -55,20 +59,27 @@ pub async fn execute(ctx: &AppContext, cmd: ExecuteImportCommand) -> Result Result { - let rating = row.rating.as_deref() + let rating = row + .rating + .as_deref() .ok_or("missing rating")? .parse::() .map_err(|_| "rating is not a valid u8".to_string())?; let watched_at_str = row.watched_at.as_deref().ok_or("missing watched_at")?; - let watched_at = NaiveDateTime::parse_from_str(&format!("{} 00:00:00", watched_at_str), "%Y-%m-%d %H:%M:%S") - .or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%d %H:%M:%S")) - .or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%dT%H:%M:%S")) - .map_err(|_| format!("cannot parse watched_at: '{}'", watched_at_str))?; + let watched_at = + NaiveDateTime::parse_from_str(&format!("{} 00:00:00", watched_at_str), "%Y-%m-%d %H:%M:%S") + .or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%d %H:%M:%S")) + .or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%dT%H:%M:%S")) + .map_err(|_| format!("cannot parse watched_at: '{}'", watched_at_str))?; Ok(LogReviewCommand { user_id, diff --git a/crates/application/src/use_cases/get_movie_social_page.rs b/crates/application/src/use_cases/get_movie_social_page.rs index 696e226..2df33a6 100644 --- a/crates/application/src/use_cases/get_movie_social_page.rs +++ b/crates/application/src/use_cases/get_movie_social_page.rs @@ -1,6 +1,9 @@ use domain::{ errors::DomainError, - models::{FeedEntry, Movie, MovieProfile, MovieStats, collections::{PageParams, Paginated}}, + models::{ + FeedEntry, Movie, MovieProfile, MovieStats, + collections::{PageParams, Paginated}, + }, value_objects::MovieId, }; @@ -32,5 +35,10 @@ pub async fn execute( ctx.movie_profile_repository.get_by_movie_id(&movie_id), )?; - Ok(MovieSocialPageResult { movie, stats, reviews, profile }) + Ok(MovieSocialPageResult { + movie, + stats, + reviews, + profile, + }) } diff --git a/crates/application/src/use_cases/get_movies.rs b/crates/application/src/use_cases/get_movies.rs index 163995e..2cff3ac 100644 --- a/crates/application/src/use_cases/get_movies.rs +++ b/crates/application/src/use_cases/get_movies.rs @@ -6,7 +6,10 @@ use domain::{ use crate::{context::AppContext, queries::GetMoviesQuery}; -pub async fn execute(ctx: &AppContext, query: GetMoviesQuery) -> Result, DomainError> { +pub async fn execute( + ctx: &AppContext, + query: GetMoviesQuery, +) -> Result, DomainError> { let page = PageParams::new(query.limit, query.offset)?; let filter = MovieFilter { search: query.search, diff --git a/crates/application/src/use_cases/get_person.rs b/crates/application/src/use_cases/get_person.rs index b010917..8ec6fbd 100644 --- a/crates/application/src/use_cases/get_person.rs +++ b/crates/application/src/use_cases/get_person.rs @@ -1,5 +1,8 @@ -use domain::{errors::DomainError, models::{Person, PersonId}}; use crate::context::AppContext; +use domain::{ + errors::DomainError, + models::{Person, PersonId}, +}; pub async fn execute(ctx: &AppContext, id: PersonId) -> Result, DomainError> { ctx.person_query.get_by_id(&id).await diff --git a/crates/application/src/use_cases/get_person_credits.rs b/crates/application/src/use_cases/get_person_credits.rs index 6ff194a..1eebfa5 100644 --- a/crates/application/src/use_cases/get_person_credits.rs +++ b/crates/application/src/use_cases/get_person_credits.rs @@ -1,5 +1,8 @@ -use domain::{errors::DomainError, models::{PersonCredits, PersonId}}; use crate::context::AppContext; +use domain::{ + errors::DomainError, + models::{PersonCredits, PersonId}, +}; pub async fn execute(ctx: &AppContext, id: PersonId) -> Result { ctx.person_query.get_credits(&id).await diff --git a/crates/application/src/use_cases/get_remote_watchlist.rs b/crates/application/src/use_cases/get_remote_watchlist.rs index 4be235c..a3c8685 100644 --- a/crates/application/src/use_cases/get_remote_watchlist.rs +++ b/crates/application/src/use_cases/get_remote_watchlist.rs @@ -2,6 +2,11 @@ use domain::{errors::DomainError, models::RemoteWatchlistEntry}; use crate::context::AppContext; -pub async fn execute(ctx: &AppContext, uuid: uuid::Uuid) -> Result, DomainError> { - ctx.remote_watchlist_repository.get_by_derived_uuid(uuid).await +pub async fn execute( + ctx: &AppContext, + uuid: uuid::Uuid, +) -> Result, DomainError> { + ctx.remote_watchlist_repository + .get_by_derived_uuid(uuid) + .await } diff --git a/crates/application/src/use_cases/get_watchlist.rs b/crates/application/src/use_cases/get_watchlist.rs index be2ecae..b9f0fa6 100644 --- a/crates/application/src/use_cases/get_watchlist.rs +++ b/crates/application/src/use_cases/get_watchlist.rs @@ -1,6 +1,9 @@ use domain::{ errors::DomainError, - models::{WatchlistWithMovie, collections::{PageParams, Paginated}}, + models::{ + WatchlistWithMovie, + collections::{PageParams, Paginated}, + }, value_objects::UserId, }; diff --git a/crates/application/src/use_cases/list_import_profiles.rs b/crates/application/src/use_cases/list_import_profiles.rs index 830b872..89282af 100644 --- a/crates/application/src/use_cases/list_import_profiles.rs +++ b/crates/application/src/use_cases/list_import_profiles.rs @@ -1,6 +1,9 @@ -use domain::{errors::DomainError, models::ImportProfile, value_objects::UserId}; use crate::context::AppContext; +use domain::{errors::DomainError, models::ImportProfile, value_objects::UserId}; -pub async fn execute(ctx: &AppContext, user_id: &UserId) -> Result, DomainError> { +pub async fn execute( + ctx: &AppContext, + user_id: &UserId, +) -> Result, DomainError> { ctx.import_profile_repository.list_for_user(user_id).await } diff --git a/crates/application/src/use_cases/log_review.rs b/crates/application/src/use_cases/log_review.rs index 14b3dd4..71a7b33 100644 --- a/crates/application/src/use_cases/log_review.rs +++ b/crates/application/src/use_cases/log_review.rs @@ -39,14 +39,18 @@ pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), Doma let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?; let review_event = ctx.review_repository.save_review(&review).await?; - let was_on_watchlist = ctx.watchlist_repository + let was_on_watchlist = ctx + .watchlist_repository .remove_if_present(review.user_id(), review.movie_id()) .await?; if was_on_watchlist { - let _ = ctx.event_publisher.publish(&DomainEvent::WatchlistEntryRemoved { - user_id: review.user_id().clone(), - movie_id: review.movie_id().clone(), - }).await; + let _ = ctx + .event_publisher + .publish(&DomainEvent::WatchlistEntryRemoved { + user_id: review.user_id().clone(), + movie_id: review.movie_id().clone(), + }) + .await; } publish_events(ctx, &movie, is_new_movie, review_event).await?; @@ -60,14 +64,13 @@ async fn publish_events( is_new_movie: bool, review_event: DomainEvent, ) -> Result<(), DomainError> { - if is_new_movie - && let Some(ext_id) = movie.external_metadata_id() { - let discovery_event = DomainEvent::MovieDiscovered { - movie_id: movie.id().clone(), - external_metadata_id: ext_id.clone(), - }; - ctx.event_publisher.publish(&discovery_event).await?; - } + if is_new_movie && let Some(ext_id) = movie.external_metadata_id() { + let discovery_event = DomainEvent::MovieDiscovered { + movie_id: movie.id().clone(), + external_metadata_id: ext_id.clone(), + }; + ctx.event_publisher.publish(&discovery_event).await?; + } if let Some(ext_id) = movie.external_metadata_id() { let enrichment_event = DomainEvent::MovieEnrichmentRequested { diff --git a/crates/application/src/use_cases/mod.rs b/crates/application/src/use_cases/mod.rs index 8d9a9f7..a892b70 100644 --- a/crates/application/src/use_cases/mod.rs +++ b/crates/application/src/use_cases/mod.rs @@ -1,13 +1,12 @@ -pub mod enrich_movie; +pub mod add_to_watchlist; pub mod apply_import_mapping; pub mod apply_import_profile; pub mod cleanup_expired_import_sessions; pub mod create_import_session; pub mod delete_import_profile; pub mod delete_review; +pub mod enrich_movie; pub mod execute_import; -pub mod list_import_profiles; -pub mod save_import_profile; pub mod export_diary; pub mod get_activity_feed; pub mod get_diary; @@ -15,19 +14,20 @@ pub mod get_movie_social_page; pub mod get_movies; pub mod get_person; pub mod get_person_credits; +#[cfg(feature = "federation")] +pub mod get_remote_watchlist; pub mod get_review_history; pub mod get_user_profile; pub mod get_users; +pub mod get_watchlist; +pub mod is_on_watchlist; +pub mod list_import_profiles; pub mod log_review; pub mod login; pub mod register; +pub mod remove_from_watchlist; +pub mod save_import_profile; pub mod search; pub mod sync_poster; pub mod update_profile; pub mod update_profile_fields; -pub mod add_to_watchlist; -pub mod remove_from_watchlist; -pub mod get_watchlist; -pub mod is_on_watchlist; -#[cfg(feature = "federation")] -pub mod get_remote_watchlist; diff --git a/crates/application/src/use_cases/remove_from_watchlist.rs b/crates/application/src/use_cases/remove_from_watchlist.rs index e3770e6..8fefdc1 100644 --- a/crates/application/src/use_cases/remove_from_watchlist.rs +++ b/crates/application/src/use_cases/remove_from_watchlist.rs @@ -11,10 +11,10 @@ pub async fn execute(ctx: &AppContext, cmd: RemoveFromWatchlistCommand) -> Resul let movie_id = MovieId::from_uuid(cmd.movie_id); ctx.watchlist_repository.remove(&user_id, &movie_id).await?; - let _ = ctx.event_publisher.publish(&DomainEvent::WatchlistEntryRemoved { - user_id, - movie_id, - }).await; + let _ = ctx + .event_publisher + .publish(&DomainEvent::WatchlistEntryRemoved { user_id, movie_id }) + .await; Ok(()) } diff --git a/crates/application/src/use_cases/save_import_profile.rs b/crates/application/src/use_cases/save_import_profile.rs index 5b695f9..0a1a1a5 100644 --- a/crates/application/src/use_cases/save_import_profile.rs +++ b/crates/application/src/use_cases/save_import_profile.rs @@ -1,17 +1,33 @@ -use chrono::Utc; -use domain::{errors::DomainError, models::ImportProfile, value_objects::{ImportProfileId, ImportSessionId, UserId}}; use crate::{commands::SaveImportProfileCommand, context::AppContext}; +use chrono::Utc; +use domain::{ + errors::DomainError, + models::ImportProfile, + value_objects::{ImportProfileId, ImportSessionId, UserId}, +}; -pub async fn execute(ctx: &AppContext, cmd: SaveImportProfileCommand) -> Result { +pub async fn execute( + ctx: &AppContext, + cmd: SaveImportProfileCommand, +) -> Result { let user_id = UserId::from_uuid(cmd.user_id); let session_id = ImportSessionId::from_uuid(cmd.session_id); - let session = ctx.import_session_repository - .get(&session_id, &user_id).await? + let session = ctx + .import_session_repository + .get(&session_id, &user_id) + .await? .ok_or_else(|| DomainError::NotFound("import session".into()))?; - let mappings = session.field_mappings - .ok_or_else(|| DomainError::ValidationError("no mapping applied to this session yet".into()))?; - let profile = ImportProfile::new(ImportProfileId::generate(), user_id, cmd.name, mappings, Utc::now().naive_utc()); + let mappings = session.field_mappings.ok_or_else(|| { + DomainError::ValidationError("no mapping applied to this session yet".into()) + })?; + let profile = ImportProfile::new( + ImportProfileId::generate(), + user_id, + cmd.name, + mappings, + Utc::now().naive_utc(), + ); let id = profile.id.clone(); ctx.import_profile_repository.save(&profile).await?; Ok(id) diff --git a/crates/application/src/use_cases/search.rs b/crates/application/src/use_cases/search.rs index 1236449..62cccb0 100644 --- a/crates/application/src/use_cases/search.rs +++ b/crates/application/src/use_cases/search.rs @@ -1,5 +1,8 @@ -use domain::{errors::DomainError, models::{SearchQuery, SearchResults}}; use crate::context::AppContext; +use domain::{ + errors::DomainError, + models::{SearchQuery, SearchResults}, +}; pub async fn execute(ctx: &AppContext, query: SearchQuery) -> Result { ctx.search_port.search(&query).await diff --git a/crates/application/src/use_cases/sync_poster.rs b/crates/application/src/use_cases/sync_poster.rs index ac62798..aea5ff8 100644 --- a/crates/application/src/use_cases/sync_poster.rs +++ b/crates/application/src/use_cases/sync_poster.rs @@ -42,8 +42,11 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom .store(&movie_id.value().to_string(), &image_bytes) .await?; - if let Err(e) = ctx.event_publisher - .publish(&DomainEvent::ImageStored { key: stored_path.clone() }) + if let Err(e) = ctx + .event_publisher + .publish(&DomainEvent::ImageStored { + key: stored_path.clone(), + }) .await { tracing::warn!("failed to emit ImageStored for {stored_path}: {e}"); @@ -56,8 +59,14 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom // Refresh search index so the new poster_path is reflected immediately. // Fetch existing profile if available for a complete index document. - let profile = ctx.movie_profile_repository.get_by_movie_id(&movie_id).await.ok().flatten(); - if let Err(e) = ctx.search_command + let profile = ctx + .movie_profile_repository + .get_by_movie_id(&movie_id) + .await + .ok() + .flatten(); + if let Err(e) = ctx + .search_command .index(IndexableDocument::Movie { id: movie_id.clone(), movie: Box::new(movie), diff --git a/crates/application/src/use_cases/update_profile.rs b/crates/application/src/use_cases/update_profile.rs index 500ca3c..cf36d7f 100644 --- a/crates/application/src/use_cases/update_profile.rs +++ b/crates/application/src/use_cases/update_profile.rs @@ -1,8 +1,4 @@ -use domain::{ - errors::DomainError, - events::DomainEvent, - value_objects::UserId, -}; +use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; use crate::{commands::UpdateProfileCommand, context::AppContext}; @@ -19,14 +15,22 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), let new_avatar_path = if let Some(bytes) = cmd.avatar_bytes { let content_type = cmd.avatar_content_type.as_deref().unwrap_or(""); if !["image/jpeg", "image/png", "image/webp"].contains(&content_type) { - return Err(DomainError::ValidationError("Avatar must be jpeg, png, or webp".into())); + return Err(DomainError::ValidationError( + "Avatar must be jpeg, png, or webp".into(), + )); } if let Some(old_path) = user.avatar_path() { let _ = ctx.image_storage.delete(old_path).await; } let key = format!("avatars/{}", user_id.value()); let stored = ctx.image_storage.store(&key, &bytes).await?; - if let Err(e) = ctx.event_publisher.publish(&DomainEvent::ImageStored { key: stored.clone() }).await { + if let Err(e) = ctx + .event_publisher + .publish(&DomainEvent::ImageStored { + key: stored.clone(), + }) + .await + { tracing::warn!("failed to emit ImageStored for avatar {stored}: {e}"); } Some(stored) @@ -38,14 +42,22 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), let new_banner_path = if let Some(bytes) = cmd.banner_bytes { let content_type = cmd.banner_content_type.as_deref().unwrap_or(""); if !["image/jpeg", "image/png", "image/webp"].contains(&content_type) { - return Err(DomainError::ValidationError("Banner must be jpeg, png, or webp".into())); + return Err(DomainError::ValidationError( + "Banner must be jpeg, png, or webp".into(), + )); } if let Some(old_path) = user.banner_path() { let _ = ctx.image_storage.delete(old_path).await; } let key = format!("banners/{}", user_id.value()); let stored = ctx.image_storage.store(&key, &bytes).await?; - if let Err(e) = ctx.event_publisher.publish(&DomainEvent::ImageStored { key: stored.clone() }).await { + if let Err(e) = ctx + .event_publisher + .publish(&DomainEvent::ImageStored { + key: stored.clone(), + }) + .await + { tracing::warn!("failed to emit ImageStored for banner {stored}: {e}"); } Some(stored) @@ -54,7 +66,13 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), }; ctx.user_repository - .update_profile(&user_id, cmd.bio, new_avatar_path, new_banner_path, cmd.also_known_as) + .update_profile( + &user_id, + cmd.bio, + new_avatar_path, + new_banner_path, + cmd.also_known_as, + ) .await?; ctx.event_publisher diff --git a/crates/application/src/use_cases/update_profile_fields.rs b/crates/application/src/use_cases/update_profile_fields.rs index c11c59a..aaecb75 100644 --- a/crates/application/src/use_cases/update_profile_fields.rs +++ b/crates/application/src/use_cases/update_profile_fields.rs @@ -1,17 +1,19 @@ -use domain::{ - errors::DomainError, - events::DomainEvent, - value_objects::UserId, -}; +use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; use crate::{commands::UpdateProfileFieldsCommand, context::AppContext}; pub async fn execute(ctx: &AppContext, cmd: UpdateProfileFieldsCommand) -> Result<(), DomainError> { if cmd.fields.len() > 4 { - return Err(DomainError::ValidationError("Maximum 4 profile fields allowed".into())); + return Err(DomainError::ValidationError( + "Maximum 4 profile fields allowed".into(), + )); } let user_id = UserId::from_uuid(cmd.user_id); - ctx.profile_fields_repository.set_fields(&user_id, cmd.fields).await?; - ctx.event_publisher.publish(&DomainEvent::UserUpdated { user_id }).await?; + ctx.profile_fields_repository + .set_fields(&user_id, cmd.fields) + .await?; + ctx.event_publisher + .publish(&DomainEvent::UserUpdated { user_id }) + .await?; Ok(()) } diff --git a/crates/domain/src/models/import.rs b/crates/domain/src/models/import.rs index ad5d46b..938e553 100644 --- a/crates/domain/src/models/import.rs +++ b/crates/domain/src/models/import.rs @@ -45,7 +45,10 @@ pub struct ImportRow { #[derive(Debug, Clone)] pub enum RowResult { Valid(ImportRow), - Invalid { errors: Vec, raw: Vec<(String, String)> }, + Invalid { + errors: Vec, + raw: Vec<(String, String)>, + }, } #[derive(Debug, Clone)] diff --git a/crates/domain/src/models/import_profile.rs b/crates/domain/src/models/import_profile.rs index 5faa4ed..9c0c36a 100644 --- a/crates/domain/src/models/import_profile.rs +++ b/crates/domain/src/models/import_profile.rs @@ -1,8 +1,8 @@ -use chrono::NaiveDateTime; use crate::{ models::FieldMapping, value_objects::{ImportProfileId, UserId}, }; +use chrono::NaiveDateTime; #[derive(Debug, Clone)] pub struct ImportProfile { @@ -21,6 +21,12 @@ impl ImportProfile { field_mappings: Vec, created_at: NaiveDateTime, ) -> Self { - Self { id, user_id, name, field_mappings, created_at } + Self { + id, + user_id, + name, + field_mappings, + created_at, + } } } diff --git a/crates/domain/src/models/import_session.rs b/crates/domain/src/models/import_session.rs index 68608d1..13c464f 100644 --- a/crates/domain/src/models/import_session.rs +++ b/crates/domain/src/models/import_session.rs @@ -1,8 +1,8 @@ -use chrono::NaiveDateTime; use crate::{ models::{AnnotatedRow, FieldMapping, ParsedFile}, value_objects::{ImportSessionId, UserId}, }; +use chrono::NaiveDateTime; #[derive(Debug, Clone)] pub struct ImportSession { diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index 53232ab..bb99488 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -10,8 +10,8 @@ use crate::{ }; pub mod collections; pub mod import; -pub mod import_session; pub mod import_profile; +pub mod import_session; pub mod person; pub mod search; pub mod watchlist; @@ -20,15 +20,15 @@ pub mod remote_watchlist; pub use remote_watchlist::RemoteWatchlistEntry; pub use import::{ - AnnotatedRow, DomainField, FieldMapping, FileFormat, ImportError, - ImportRow, ParsedFile, RowResult, Transform, + AnnotatedRow, DomainField, FieldMapping, FileFormat, ImportError, ImportRow, ParsedFile, + RowResult, Transform, }; -pub use import_session::ImportSession; pub use import_profile::ImportProfile; +pub use import_session::ImportSession; pub use person::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId}; pub use search::{ - EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, - SearchFilters, SearchQuery, SearchResults, + EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchFilters, SearchQuery, + SearchResults, }; #[derive(Clone, Debug, Default)] @@ -158,7 +158,9 @@ impl Movie { pub enum ReviewSource { #[default] Local, - Remote { actor_url: String }, + Remote { + actor_url: String, + }, } #[derive(Clone, Debug)] @@ -377,7 +379,13 @@ impl User { self.password_hash = new_hash; } - pub fn update_profile(&mut self, bio: Option, avatar_path: Option, banner_path: Option, also_known_as: Option) { + pub fn update_profile( + &mut self, + bio: Option, + avatar_path: Option, + banner_path: Option, + also_known_as: Option, + ) { self.bio = bio; self.avatar_path = avatar_path; self.banner_path = banner_path; diff --git a/crates/domain/src/models/person.rs b/crates/domain/src/models/person.rs index ac409c2..0e59283 100644 --- a/crates/domain/src/models/person.rs +++ b/crates/domain/src/models/person.rs @@ -56,7 +56,13 @@ impl Person { known_for_department: Option, profile_path: Option, ) -> Self { - Self { id, external_id, name, known_for_department, profile_path } + Self { + id, + external_id, + name, + known_for_department, + profile_path, + } } pub fn id(&self) -> &PersonId { diff --git a/crates/domain/src/models/tests.rs b/crates/domain/src/models/tests.rs index cc9f97e..27cd00d 100644 --- a/crates/domain/src/models/tests.rs +++ b/crates/domain/src/models/tests.rs @@ -19,7 +19,12 @@ fn make_user() -> User { #[test] fn update_profile_sets_fields() { let mut user = make_user(); - user.update_profile(Some("My bio".to_string()), Some("avatars/abc".to_string()), None, None); + user.update_profile( + Some("My bio".to_string()), + Some("avatars/abc".to_string()), + None, + None, + ); assert_eq!(user.bio(), Some("My bio")); assert_eq!(user.avatar_path(), Some("avatars/abc")); } @@ -27,7 +32,12 @@ fn update_profile_sets_fields() { #[test] fn update_profile_clears_with_none() { let mut user = make_user(); - user.update_profile(Some("bio".to_string()), Some("path".to_string()), None, None); + user.update_profile( + Some("bio".to_string()), + Some("path".to_string()), + None, + None, + ); user.update_profile(None, None, None, None); assert_eq!(user.bio(), None); assert_eq!(user.avatar_path(), None); diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 51d4b23..e81b282 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -5,11 +5,12 @@ use crate::{ errors::DomainError, events::{DomainEvent, EventEnvelope}, models::{ - AnnotatedRow, DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping, - FileFormat, ImportError, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, - MovieStats, MovieSummary, ParsedFile, Review, ReviewHistory, User, UserStats, UserSummary, - UserTrends, WatchlistEntry, WatchlistWithMovie, RemoteWatchlistEntry, EntityType, ExternalPersonId, - IndexableDocument, Person, PersonCredits, PersonId, SearchQuery, SearchResults, + AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId, + FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession, + IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile, + Person, PersonCredits, PersonId, RemoteWatchlistEntry, Review, ReviewHistory, SearchQuery, + SearchResults, User, UserStats, UserSummary, UserTrends, WatchlistEntry, + WatchlistWithMovie, collections::{self, PageParams, Paginated}, }, value_objects::{ @@ -66,9 +67,7 @@ pub trait SocialQueryPort: Send + Sync { ) -> Result, DomainError>; /// Returns all distinct remote actors followed by any local user on this instance. - async fn list_all_followed_remote_actors( - &self, - ) -> Result, DomainError>; + async fn list_all_followed_remote_actors(&self) -> Result, DomainError>; } #[async_trait] @@ -195,8 +194,15 @@ pub trait UserRepository: Send + Sync { #[async_trait] pub trait UserProfileFieldsRepository: Send + Sync { - async fn get_fields(&self, user_id: &UserId) -> Result, DomainError>; - async fn set_fields(&self, user_id: &UserId, fields: Vec) -> Result<(), DomainError>; + async fn get_fields( + &self, + user_id: &UserId, + ) -> Result, DomainError>; + async fn set_fields( + &self, + user_id: &UserId, + fields: Vec, + ) -> Result<(), DomainError>; } #[async_trait] @@ -260,7 +266,11 @@ pub trait MovieEnrichmentClient: Send + Sync { #[async_trait] pub trait ImportSessionRepository: Send + Sync { async fn create(&self, session: &ImportSession) -> Result<(), DomainError>; - async fn get(&self, id: &ImportSessionId, user_id: &UserId) -> Result, DomainError>; + async fn get( + &self, + id: &ImportSessionId, + user_id: &UserId, + ) -> Result, DomainError>; async fn update(&self, session: &ImportSession) -> Result<(), DomainError>; async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError>; async fn delete_expired(&self) -> Result; @@ -271,7 +281,11 @@ pub trait ImportSessionRepository: Send + Sync { pub trait ImportProfileRepository: Send + Sync { async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError>; async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError>; - async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result, DomainError>; + async fn get( + &self, + id: &ImportProfileId, + user_id: &UserId, + ) -> Result, DomainError>; async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError>; } @@ -296,7 +310,10 @@ pub trait PersonCommand: Send + Sync { #[async_trait] pub trait PersonQuery: Send + Sync { async fn get_by_id(&self, id: &PersonId) -> Result, DomainError>; - async fn get_by_external_id(&self, id: &ExternalPersonId) -> Result, DomainError>; + async fn get_by_external_id( + &self, + id: &ExternalPersonId, + ) -> Result, DomainError>; /// Returns the person's full cast and crew credit history across all indexed movies. async fn get_credits(&self, id: &PersonId) -> Result; /// Returns persons who have no remaining entries in movie_cast or movie_crew. @@ -340,19 +357,21 @@ pub trait WatchlistRepository: Send + Sync { page: &collections::PageParams, ) -> Result, DomainError>; - async fn contains( - &self, - user_id: &UserId, - movie_id: &MovieId, - ) -> Result; + async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result; } #[async_trait] pub trait RemoteWatchlistRepository: Send + Sync { async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError>; async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError>; - async fn get_by_actor_url(&self, actor_url: &str) -> Result, DomainError>; + async fn get_by_actor_url( + &self, + actor_url: &str, + ) -> Result, DomainError>; async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError>; /// Find entries for a remote actor whose URL hashes (v5 UUID) to the given UUID. - async fn get_by_derived_uuid(&self, uuid: uuid::Uuid) -> Result, DomainError>; + async fn get_by_derived_uuid( + &self, + uuid: uuid::Uuid, + ) -> Result, DomainError>; } diff --git a/crates/domain/src/value_objects.rs b/crates/domain/src/value_objects.rs index e651df0..42a7e22 100644 --- a/crates/domain/src/value_objects.rs +++ b/crates/domain/src/value_objects.rs @@ -80,9 +80,10 @@ impl MovieTitle { "Movie title cannot be empty".into(), )) } else if trimmed.len() > Self::MAX_LENGTH { - Err(DomainError::ValidationError( - format!("Movie title exceeds {} characters", Self::MAX_LENGTH), - )) + Err(DomainError::ValidationError(format!( + "Movie title exceeds {} characters", + Self::MAX_LENGTH + ))) } else { Ok(Self(trimmed.to_string())) } @@ -102,9 +103,10 @@ impl Comment { pub fn new(comment: String) -> Result { let trimmed = comment.trim(); if trimmed.len() > Self::MAX_LENGTH { - Err(DomainError::ValidationError( - format!("Comment exceeds {} characters", Self::MAX_LENGTH), - )) + Err(DomainError::ValidationError(format!( + "Comment exceeds {} characters", + Self::MAX_LENGTH + ))) } else { Ok(Self(trimmed.to_string())) } @@ -186,13 +188,11 @@ impl Username { pub fn new(raw: String) -> Result { let s = raw.trim().to_lowercase(); if s.len() < Self::MIN_LENGTH || s.len() > Self::MAX_LENGTH { - return Err(DomainError::ValidationError( - format!( - "Username must be {}–{} characters", - Self::MIN_LENGTH, - Self::MAX_LENGTH - ), - )); + return Err(DomainError::ValidationError(format!( + "Username must be {}–{} characters", + Self::MIN_LENGTH, + Self::MAX_LENGTH + ))); } if !s .chars() diff --git a/crates/presentation/src/forms.rs b/crates/presentation/src/forms.rs index c32f71c..0d5ad8a 100644 --- a/crates/presentation/src/forms.rs +++ b/crates/presentation/src/forms.rs @@ -2,7 +2,10 @@ use chrono::NaiveDateTime; use serde::Deserialize; use uuid::Uuid; -use application::{commands::{LogReviewCommand, MovieInput}, queries::GetDiaryQuery}; +use application::{ + commands::{LogReviewCommand, MovieInput}, + queries::GetDiaryQuery, +}; use domain::{errors::DomainError, models::SortDirection}; use api_types::{DiaryQueryParams, LogReviewRequest}; diff --git a/crates/presentation/src/handlers/api.rs b/crates/presentation/src/handlers/api.rs index 7b4f665..4a08f4d 100644 --- a/crates/presentation/src/handlers/api.rs +++ b/crates/presentation/src/handlers/api.rs @@ -10,55 +10,57 @@ use std::str::FromStr; use application::{ commands::{ - DeleteReviewCommand, MovieInput, RegisterCommand, SyncPosterCommand, - AddToWatchlistCommand, RemoveFromWatchlistCommand, + AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand, + RemoveFromWatchlistCommand, SyncPosterCommand, }, queries::{ ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetMoviesQuery, - GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, LoginQuery, - GetWatchlistQuery, IsOnWatchlistQuery, + GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, GetWatchlistQuery, + IsOnWatchlistQuery, LoginQuery, }, use_cases::{ - delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, - get_diary, get_movie_social_page, get_movies, get_review_history, - get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc, - register as register_uc, sync_poster, update_profile, update_profile_fields, - search as search_uc, get_person, get_person_credits, - add_to_watchlist, remove_from_watchlist, get_watchlist, is_on_watchlist, + add_to_watchlist, delete_review, export_diary as export_diary_uc, + get_activity_feed as get_feed_uc, get_diary, get_movie_social_page, get_movies, get_person, + get_person_credits, get_review_history, get_user_profile as get_user_profile_uc, get_users, + get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc, + remove_from_watchlist, search as search_uc, sync_poster, update_profile, + update_profile_fields, }, }; use domain::{ errors::DomainError, - models::{DiaryEntry, ExportFormat, Movie, MovieSummary, Review, PersonId, collections::PageParams}, + models::{ + DiaryEntry, ExportFormat, Movie, MovieSummary, PersonId, Review, collections::PageParams, + }, services::review_history::Trend, value_objects::{MovieId, UserId}, }; +use crate::{ + errors::ApiError, + extractors::AuthenticatedUser, + forms::{LogReviewData, to_diary_query}, + state::AppState, +}; +use api_types::search::{ + CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits, + PersonCreditsDto, PersonDto, PersonSearchHitDto, SearchQueryParams, SearchResponse, +}; +use api_types::{ + ActivityFeedQueryParams, ActivityFeedResponse, AddToWatchlistRequest, CastMemberDto, + CrewMemberDto, DiaryEntryDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, + ExportQueryParams, FeedEntryDto, GenreDto, KeywordDto, LogReviewRequest, LoginRequest, + LoginResponse, MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto, + MovieProfileResponse, MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams, + ProfileResponse, RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, + SocialReviewDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, + UserTrendsDto, UsersResponse, WatchlistEntryDto, WatchlistResponse, WatchlistStatusResponse, +}; #[cfg(feature = "federation")] use api_types::{ ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse, BlockedDomainResponse, FollowRequest, RemoteActorDto, }; -use api_types::{ - ActivityFeedQueryParams, ActivityFeedResponse, CastMemberDto, CrewMemberDto, DiaryEntryDto, - DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, - GenreDto, KeywordDto, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, - MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieProfileResponse, MovieStatsDto, - MoviesQueryParams, MoviesResponse, PaginationQueryParams, ProfileResponse, RegisterRequest, - ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserProfileQueryParams, - UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, - WatchlistResponse, WatchlistEntryDto, WatchlistStatusResponse, AddToWatchlistRequest, -}; -use api_types::search::{ - CastCreditDto, CrewCreditDto, MovieSearchHitDto, PersonCreditsDto, PersonDto, - PersonSearchHitDto, PaginatedMovieHits, PaginatedPersonHits, SearchQueryParams, SearchResponse, -}; -use crate::{ - errors::ApiError, - extractors::AuthenticatedUser, - forms::{to_diary_query, LogReviewData}, - state::AppState, -}; #[utoipa::path( get, path = "/api/v1/diary", @@ -307,7 +309,11 @@ pub async fn get_movie_detail( let result = get_movie_social_page::execute( &state.app_ctx, - GetMovieSocialPageQuery { movie_id, limit, offset }, + GetMovieSocialPageQuery { + movie_id, + limit, + offset, + }, ) .await?; @@ -320,13 +326,18 @@ pub async fn get_movie_detail( rating_histogram: result.stats.rating_histogram, }, reviews: SocialFeedResponse { - items: result.reviews.items.iter().map(|e| SocialReviewDto { - user_display: e.user_display_name().to_string(), - rating: e.review().rating().value(), - comment: e.review().comment().map(|c| c.value().to_string()), - watched_at: e.review().watched_at().to_string(), - is_federated: e.review().is_remote(), - }).collect(), + items: result + .reviews + .items + .iter() + .map(|e| SocialReviewDto { + user_display: e.user_display_name().to_string(), + rating: e.review().rating().value(), + comment: e.review().comment().map(|c| c.value().to_string()), + watched_at: e.review().watched_at().to_string(), + is_federated: e.review().is_remote(), + }) + .collect(), total_count: result.reviews.total_count, limit: result.reviews.limit, offset: result.reviews.offset, @@ -347,7 +358,12 @@ pub async fn get_movie_profile( Path(movie_id): Path, ) -> impl IntoResponse { let id = domain::value_objects::MovieId::from_uuid(movie_id); - match state.app_ctx.movie_profile_repository.get_by_movie_id(&id).await { + match state + .app_ctx + .movie_profile_repository + .get_by_movie_id(&id) + .await + { Ok(Some(p)) => Json(MovieProfileResponse { tmdb_id: p.tmdb_id, imdb_id: p.imdb_id, @@ -360,18 +376,47 @@ pub async fn get_movie_profile( vote_count: p.vote_count, original_language: p.original_language, collection_name: p.collection_name, - genres: p.genres.into_iter().map(|g| GenreDto { tmdb_id: g.tmdb_id, name: g.name }).collect(), - keywords: p.keywords.into_iter().map(|k| KeywordDto { tmdb_id: k.tmdb_id, name: k.name }).collect(), - cast: p.cast.into_iter().map(|c| CastMemberDto { - tmdb_person_id: c.tmdb_person_id, name: c.name, character: c.character, - billing_order: c.billing_order, profile_path: c.profile_path, - }).collect(), - crew: p.crew.into_iter().map(|c| CrewMemberDto { - tmdb_person_id: c.tmdb_person_id, name: c.name, job: c.job, - department: c.department, profile_path: c.profile_path, - }).collect(), + genres: p + .genres + .into_iter() + .map(|g| GenreDto { + tmdb_id: g.tmdb_id, + name: g.name, + }) + .collect(), + keywords: p + .keywords + .into_iter() + .map(|k| KeywordDto { + tmdb_id: k.tmdb_id, + name: k.name, + }) + .collect(), + cast: p + .cast + .into_iter() + .map(|c| CastMemberDto { + tmdb_person_id: c.tmdb_person_id, + name: c.name, + character: c.character, + billing_order: c.billing_order, + profile_path: c.profile_path, + }) + .collect(), + crew: p + .crew + .into_iter() + .map(|c| CrewMemberDto { + tmdb_person_id: c.tmdb_person_id, + name: c.name, + job: c.job, + department: c.department, + profile_path: c.profile_path, + }) + .collect(), enriched_at: p.enriched_at.to_rfc3339(), - }).into_response(), + }) + .into_response(), Ok(None) => StatusCode::NOT_FOUND.into_response(), Err(e) => { tracing::error!("get_movie_profile: {:?}", e); @@ -440,7 +485,11 @@ pub async fn update_profile_handler( while let Ok(Some(field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); match name.as_str() { - "bio" => { if let Ok(text) = field.text().await { bio = Some(text); } } + "bio" => { + if let Ok(text) = field.text().await { + bio = Some(text); + } + } "also_known_as" => { if let Ok(text) = field.text().await { also_known_as = Some(text).filter(|s| !s.is_empty()); @@ -449,13 +498,19 @@ pub async fn update_profile_handler( "avatar" => { let ct = field.content_type().map(|s| s.to_string()); if let Ok(bytes) = field.bytes().await { - if !bytes.is_empty() { avatar_bytes = Some(bytes.to_vec()); avatar_content_type = ct; } + if !bytes.is_empty() { + avatar_bytes = Some(bytes.to_vec()); + avatar_content_type = ct; + } } } "banner" => { let ct = field.content_type().map(|s| s.to_string()); if let Ok(bytes) = field.bytes().await { - if !bytes.is_empty() { banner_bytes = Some(bytes.to_vec()); banner_content_type = ct; } + if !bytes.is_empty() { + banner_bytes = Some(bytes.to_vec()); + banner_content_type = ct; + } } } _ => {} @@ -613,7 +668,11 @@ pub async fn add_blocked_domain_admin( _admin: crate::extractors::AdminUser, axum::Json(body): axum::Json, ) -> impl IntoResponse { - match state.ap_service.add_blocked_domain(&body.domain, body.reason.as_deref()).await { + match state + .ap_service + .add_blocked_domain(&body.domain, body.reason.as_deref()) + .await + { Ok(()) => StatusCode::CREATED.into_response(), Err(e) => ap_err(e).into_response(), } @@ -656,7 +715,11 @@ pub async fn block_actor_api( user: AuthenticatedUser, axum::Json(body): axum::Json, ) -> impl IntoResponse { - match state.ap_service.block_actor(user.0.value(), &body.actor_url).await { + match state + .ap_service + .block_actor(user.0.value(), &body.actor_url) + .await + { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => ap_err(e).into_response(), } @@ -677,7 +740,11 @@ pub async fn unblock_actor_api( user: AuthenticatedUser, axum::Json(body): axum::Json, ) -> impl IntoResponse { - match state.ap_service.unblock_actor(user.0.value(), &body.actor_url).await { + match state + .ap_service + .unblock_actor(user.0.value(), &body.actor_url) + .await + { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => ap_err(e).into_response(), } @@ -972,9 +1039,7 @@ pub async fn get_activity_feed( get, path = "/api/v1/users", responses((status = 200, body = UsersResponse)), )] -pub async fn list_users( - State(state): State, -) -> Result, ApiError> { +pub async fn list_users(State(state): State) -> Result, ApiError> { let users = get_users::execute(&state.app_ctx, GetUsersQuery).await?; Ok(Json(UsersResponse { users: users @@ -1199,31 +1264,42 @@ pub async fn get_search( match search_uc::execute(&state.app_ctx, query).await { Ok(results) => axum::Json(SearchResponse { movies: PaginatedMovieHits { - items: results.movies.items.iter().map(|h| MovieSearchHitDto { - movie_id: h.movie_id.value(), - title: h.title.clone(), - release_year: h.release_year, - director: h.director.clone(), - poster_path: h.poster_path.clone(), - genres: h.genres.clone(), - }).collect(), + items: results + .movies + .items + .iter() + .map(|h| MovieSearchHitDto { + movie_id: h.movie_id.value(), + title: h.title.clone(), + release_year: h.release_year, + director: h.director.clone(), + poster_path: h.poster_path.clone(), + genres: h.genres.clone(), + }) + .collect(), total_count: results.movies.total_count, limit: results.movies.limit, offset: results.movies.offset, }, people: PaginatedPersonHits { - items: results.people.items.iter().map(|h| PersonSearchHitDto { - person_id: h.person_id.value(), - name: h.name.clone(), - known_for_department: h.known_for_department.clone(), - profile_path: h.profile_path.clone(), - known_for_titles: h.known_for_titles.clone(), - }).collect(), + items: results + .people + .items + .iter() + .map(|h| PersonSearchHitDto { + person_id: h.person_id.value(), + name: h.name.clone(), + known_for_department: h.known_for_department.clone(), + profile_path: h.profile_path.clone(), + known_for_titles: h.known_for_titles.clone(), + }) + .collect(), total_count: results.people.total_count, limit: results.people.limit, offset: results.people.offset, }, - }).into_response(), + }) + .into_response(), Err(e) => { tracing::error!("search failed: {e}"); StatusCode::INTERNAL_SERVER_ERROR.into_response() @@ -1251,7 +1327,8 @@ pub async fn get_person_handler( name: person.name().to_string(), known_for_department: person.known_for_department().map(str::to_string), profile_path: person.profile_path().map(str::to_string), - }).into_response(), + }) + .into_response(), Ok(None) => StatusCode::NOT_FOUND.into_response(), Err(e) => { tracing::error!("get_person failed: {e}"); @@ -1282,22 +1359,31 @@ pub async fn get_person_credits_handler( known_for_department: credits.person.known_for_department().map(str::to_string), profile_path: credits.person.profile_path().map(str::to_string), }, - cast: credits.cast.iter().map(|c| CastCreditDto { - movie_id: c.movie_id.value(), - title: c.title.clone(), - release_year: c.release_year, - character: c.character.clone(), - poster_path: c.poster_path.clone(), - }).collect(), - crew: credits.crew.iter().map(|c| CrewCreditDto { - movie_id: c.movie_id.value(), - title: c.title.clone(), - release_year: c.release_year, - job: c.job.clone(), - department: c.department.clone(), - poster_path: c.poster_path.clone(), - }).collect(), - }).into_response(), + cast: credits + .cast + .iter() + .map(|c| CastCreditDto { + movie_id: c.movie_id.value(), + title: c.title.clone(), + release_year: c.release_year, + character: c.character.clone(), + poster_path: c.poster_path.clone(), + }) + .collect(), + crew: credits + .crew + .iter() + .map(|c| CrewCreditDto { + movie_id: c.movie_id.value(), + title: c.title.clone(), + release_year: c.release_year, + job: c.job.clone(), + department: c.department.clone(), + poster_path: c.poster_path.clone(), + }) + .collect(), + }) + .into_response(), Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(), Err(e) => { tracing::error!("get_person_credits failed: {e}"); @@ -1334,11 +1420,15 @@ pub async fn get_watchlist_handler( .await?; Ok(Json(WatchlistResponse { - items: page.items.into_iter().map(|w| WatchlistEntryDto { - id: w.entry.id.value(), - movie: movie_to_dto(&w.movie), - added_at: w.entry.added_at.to_string(), - }).collect(), + items: page + .items + .into_iter() + .map(|w| WatchlistEntryDto { + id: w.entry.id.value(), + movie: movie_to_dto(&w.movie), + added_at: w.entry.added_at.to_string(), + }) + .collect(), total_count: page.total_count, limit: page.limit, offset: page.offset, @@ -1394,7 +1484,10 @@ pub async fn delete_watchlist_entry( ) -> Result { remove_from_watchlist::execute( &state.app_ctx, - RemoveFromWatchlistCommand { user_id: user.0.value(), movie_id }, + RemoveFromWatchlistCommand { + user_id: user.0.value(), + movie_id, + }, ) .await?; Ok(StatusCode::NO_CONTENT) @@ -1416,7 +1509,10 @@ pub async fn get_watchlist_status( ) -> Result, ApiError> { let on_watchlist = is_on_watchlist::execute( &state.app_ctx, - IsOnWatchlistQuery { user_id: user.0.value(), movie_id }, + IsOnWatchlistQuery { + user_id: user.0.value(), + movie_id, + }, ) .await?; Ok(Json(WatchlistStatusResponse { on_watchlist })) diff --git a/crates/presentation/src/handlers/html.rs b/crates/presentation/src/handlers/html.rs index 7e64366..107f88c 100644 --- a/crates/presentation/src/handlers/html.rs +++ b/crates/presentation/src/handlers/html.rs @@ -9,6 +9,25 @@ use axum::{ use chrono::Utc; use uuid::Uuid; +use application::{ + commands::{ + AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand, + RemoveFromWatchlistCommand, + }, + ports::{ + HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, + ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistDisplayEntry, + WatchlistPageData, + }, + queries::{ + ExportQuery, GetMovieSocialPageQuery, GetWatchlistQuery, IsOnWatchlistQuery, LoginQuery, + }, + use_cases::{ + add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page, + get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc, + remove_from_watchlist, update_profile, update_profile_fields, + }, +}; #[cfg(feature = "federation")] use application::{ ports::{ @@ -17,29 +36,17 @@ use application::{ }, use_cases::get_remote_watchlist, }; -use application::{ - commands::{AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand, RemoveFromWatchlistCommand}, - queries::{ExportQuery, GetMovieSocialPageQuery, GetWatchlistQuery, IsOnWatchlistQuery, LoginQuery}, - ports::{ - HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, - ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistDisplayEntry, - WatchlistPageData, - }, - use_cases::{ - add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page, - get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc, - remove_from_watchlist, update_profile, update_profile_fields, - }, -}; use domain::models::ExportFormat; use domain::{errors::DomainError, value_objects::UserId}; #[cfg(feature = "federation")] -use crate::forms::{ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, UnfollowForm}; +use crate::forms::{ + ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, UnfollowForm, +}; use crate::{ csrf::CsrfToken, - forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm}, extractors::{AdminUser, OptionalCookieUser, RequiredCookieUser}, + forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm}, state::AppState, }; @@ -58,7 +65,10 @@ pub(crate) async fn build_page_context( .ok() .flatten(); let email = user.as_ref().map(|u| u.email().value().to_string()); - let admin = user.as_ref().map(|u| matches!(u.role(), domain::models::UserRole::Admin)).unwrap_or(false); + let admin = user + .as_ref() + .map(|u| matches!(u.role(), domain::models::UserRole::Admin)) + .unwrap_or(false); (email, admin) } else { (None, false) @@ -219,18 +229,17 @@ pub async fn post_register( ) .await { - Ok(_) => { - match login_uc::execute(&state.app_ctx, LoginQuery { email, password }).await { - Ok(result) => { - let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); - let cookie = set_cookie_header(&result.token, max_age); - ([cookie], Redirect::to("/")).into_response() - } - Err(_) => Redirect::to("/login").into_response(), + Ok(_) => match login_uc::execute(&state.app_ctx, LoginQuery { email, password }).await { + Ok(result) => { + let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); + let cookie = set_cookie_header(&result.token, max_age); + ([cookie], Redirect::to("/")).into_response() } + Err(_) => Redirect::to("/login").into_response(), + }, + Err(_) => { + Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response() } - Err(_) => Redirect::to("/register?error=Registration+failed.+Please+try+again.") - .into_response(), } } @@ -389,10 +398,11 @@ pub async fn get_activity_feed( let mut remote_urls = Vec::new(); for url in urls { if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url)) - && let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) { - local_ids.push(parsed_id); - continue; - } + && let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) + { + local_ids.push(parsed_id); + continue; + } remote_urls.push(url); } Some(domain::ports::FollowingFilter { @@ -533,9 +543,7 @@ pub async fn get_user_profile( .get(axum::http::header::ACCEPT) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - if accept.contains("application/activity+json") - || accept.contains("application/ld+json") - { + if accept.contains("application/activity+json") || accept.contains("application/ld+json") { return match state .ap_service .actor_json(&profile_user_uuid.to_string()) @@ -667,8 +675,7 @@ pub async fn get_user_profile( .entries .as_ref() .map(|e| { - let has_more = - (e.offset as u64).saturating_add(e.limit as u64) < e.total_count; + let has_more = (e.offset as u64).saturating_add(e.limit as u64) < e.total_count; (e.offset, has_more, e.limit) }) .unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT)); @@ -730,7 +737,11 @@ pub async fn follow_remote_user( Err(e) => { tracing::error!("follow error: {:?}", e); let msg = encode_error(&e.to_string()); - let sep = if redirect_base.contains('?') { '&' } else { '?' }; + let sep = if redirect_base.contains('?') { + '&' + } else { + '?' + }; Redirect::to(&format!("{}{}error={}", redirect_base, sep, msg)).into_response() } } @@ -755,8 +766,9 @@ pub async fn unfollow_remote_user( .unfollow(user_id.value(), &form.actor_url) .await { - Ok(()) => Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)) - .into_response(), + Ok(()) => { + Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)).into_response() + } Err(e) => { let msg = encode_error(&e.to_string()); Redirect::to(&format!( @@ -945,8 +957,9 @@ pub async fn remove_follower( .remove_follower(user_id.value(), &form.actor_url) .await { - Ok(_) => Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)) - .into_response(), + Ok(_) => { + Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)).into_response() + } Err(e) => { let msg = encode_error(&e.to_string()); Redirect::to(&format!( @@ -971,7 +984,11 @@ pub async fn get_movie_detail( match get_movie_social_page::execute( &state.app_ctx, - GetMovieSocialPageQuery { movie_id, limit, offset }, + GetMovieSocialPageQuery { + movie_id, + limit, + offset, + }, ) .await { @@ -982,13 +999,22 @@ pub async fn get_movie_detail( StatusCode::INTERNAL_SERVER_ERROR.into_response() } Ok(result) => { - let histogram_max = result.stats.rating_histogram.iter().copied().max().unwrap_or(1); - let has_more = result.reviews.offset + result.reviews.limit - < result.reviews.total_count as u32; + let histogram_max = result + .stats + .rating_histogram + .iter() + .copied() + .max() + .unwrap_or(1); + let has_more = + result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32; let on_watchlist = match &user_id { Some(uid) => is_on_watchlist::execute( &state.app_ctx, - IsOnWatchlistQuery { user_id: uid.value(), movie_id }, + IsOnWatchlistQuery { + user_id: uid.value(), + movie_id, + }, ) .await .unwrap_or(false), @@ -1030,7 +1056,9 @@ pub async fn get_watchlist_page( let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false); // Try local user first - let local_user = state.app_ctx.user_repository + let local_user = state + .app_ctx + .user_repository .find_by_id(&domain::value_objects::UserId::from_uuid(owner_id)) .await .ok() @@ -1039,30 +1067,42 @@ pub async fn get_watchlist_page( let (display_entries, has_more, current_offset, page_limit) = if local_user.is_some() { match get_watchlist::execute( &state.app_ctx, - GetWatchlistQuery { user_id: owner_id, limit: Some(limit), offset: Some(offset) }, - ).await { + GetWatchlistQuery { + user_id: owner_id, + limit: Some(limit), + offset: Some(offset), + }, + ) + .await + { Err(e) => { tracing::error!("watchlist error: {:?}", e); return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } Ok(entries) => { let has_more = entries.offset + entries.limit < entries.total_count as u32; - let display: Vec = entries.items.iter().map(|w| { - let remove_url = if is_owner { - Some(format!("/watchlist/{}/remove", w.movie.id().value())) - } else { - None - }; - WatchlistDisplayEntry { - poster_url: w.movie.poster_path() - .map(|p| format!("/images/{}", p.value())), - movie_title: w.movie.title().value().to_string(), - release_year: w.movie.release_year().value(), - movie_url: Some(format!("/movies/{}", w.movie.id().value())), - added_at: w.entry.added_at.format("%b %-d, %Y").to_string(), - remove_url, - } - }).collect(); + let display: Vec = entries + .items + .iter() + .map(|w| { + let remove_url = if is_owner { + Some(format!("/watchlist/{}/remove", w.movie.id().value())) + } else { + None + }; + WatchlistDisplayEntry { + poster_url: w + .movie + .poster_path() + .map(|p| format!("/images/{}", p.value())), + movie_title: w.movie.title().value().to_string(), + release_year: w.movie.release_year().value(), + movie_url: Some(format!("/movies/{}", w.movie.id().value())), + added_at: w.entry.added_at.format("%b %-d, %Y").to_string(), + remove_url, + } + }) + .collect(); (display, has_more, entries.offset, entries.limit) } } @@ -1072,16 +1112,17 @@ pub async fn get_watchlist_page( let remote_entries = get_remote_watchlist::execute(&state.app_ctx, owner_id) .await .unwrap_or_default(); - let display: Vec = remote_entries.into_iter().map(|e| { - WatchlistDisplayEntry { + let display: Vec = remote_entries + .into_iter() + .map(|e| WatchlistDisplayEntry { poster_url: e.poster_url, movie_title: e.movie_title, release_year: e.release_year, movie_url: None, added_at: e.added_at.format("%b %-d, %Y").to_string(), remove_url: None, - } - }).collect(); + }) + .collect(); let len = display.len() as u32; (display, false, 0u32, len) } @@ -1172,7 +1213,11 @@ pub async fn post_watchlist_add( Ok(()) => Redirect::to(&redirect_base).into_response(), Err(DomainError::NotFound(_)) => Redirect::to(&redirect_base).into_response(), Err(DomainError::ValidationError(msg)) => { - let sep = if redirect_base.contains('?') { '&' } else { '?' }; + let sep = if redirect_base.contains('?') { + '&' + } else { + '?' + }; let url = format!("{}{}error={}", redirect_base, sep, encode_error(&msg)); Redirect::to(&url).into_response() } @@ -1231,12 +1276,7 @@ pub async fn get_profile_settings( ctx.page_title = "Profile Settings — Movies Diary".to_string(); ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url); - let user = match state - .app_ctx - .user_repository - .find_by_id(&user_id) - .await - { + let user = match state.app_ctx.user_repository.find_by_id(&user_id).await { Ok(Some(u)) => u, Ok(None) => return StatusCode::NOT_FOUND.into_response(), Err(e) => { @@ -1253,7 +1293,9 @@ pub async fn get_profile_settings( .banner_path() .map(|path| format!("{}/images/{}", base_url, path)); - let profile_fields = state.app_ctx.profile_fields_repository + let profile_fields = state + .app_ctx + .profile_fields_repository .get_fields(&user_id) .await .unwrap_or_default() @@ -1319,7 +1361,11 @@ pub async fn get_blocked_domains_page( } Err(e) => { tracing::error!("get_blocked_domains error: {:?}", e); - (StatusCode::INTERNAL_SERVER_ERROR, "Failed to load blocked domains").into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to load blocked domains", + ) + .into_response() } } } @@ -1335,7 +1381,11 @@ pub async fn post_blocked_domain( return StatusCode::FORBIDDEN.into_response(); } let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty()); - match state.ap_service.add_blocked_domain(&form.domain, reason).await { + match state + .ap_service + .add_blocked_domain(&form.domain, reason) + .await + { Ok(()) => Redirect::to("/admin/blocked-domains").into_response(), Err(e) => { tracing::error!("add_blocked_domain error: {:?}", e); @@ -1393,7 +1443,11 @@ pub async fn get_blocked_actors_page( } Err(e) => { tracing::error!("get_blocked_actors error: {:?}", e); - (StatusCode::INTERNAL_SERVER_ERROR, "Failed to load blocked users").into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to load blocked users", + ) + .into_response() } } } @@ -1408,7 +1462,11 @@ pub async fn post_block_actor_html( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - match state.ap_service.block_actor(user_id.value(), &form.actor_url).await { + match state + .ap_service + .block_actor(user_id.value(), &form.actor_url) + .await + { Ok(()) => Redirect::to("/social/blocked").into_response(), Err(e) => { tracing::error!("block_actor html error: {:?}", e); @@ -1427,7 +1485,11 @@ pub async fn post_unblock_actor( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - match state.ap_service.unblock_actor(user_id.value(), &form.actor_url).await { + match state + .ap_service + .unblock_actor(user_id.value(), &form.actor_url) + .await + { Ok(()) => Redirect::to("/social/blocked").into_response(), Err(e) => { tracing::error!("unblock_actor error: {:?}", e); @@ -1447,13 +1509,19 @@ pub async fn post_profile_settings( let mut banner_bytes: Option> = None; let mut banner_content_type: Option = None; let mut also_known_as: Option = None; - let mut field_names: std::collections::HashMap = std::collections::HashMap::new(); - let mut field_values: std::collections::HashMap = std::collections::HashMap::new(); + let mut field_names: std::collections::HashMap = + std::collections::HashMap::new(); + let mut field_values: std::collections::HashMap = + std::collections::HashMap::new(); while let Ok(Some(field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); match name.as_str() { - "bio" => { if let Ok(text) = field.text().await { bio = Some(text); } } + "bio" => { + if let Ok(text) = field.text().await { + bio = Some(text); + } + } "also_known_as" => { if let Ok(text) = field.text().await { also_known_as = Some(text).filter(|s| !s.is_empty()); @@ -1462,26 +1530,36 @@ pub async fn post_profile_settings( "avatar" => { let ct = field.content_type().map(|s| s.to_string()); if let Ok(bytes) = field.bytes().await { - if !bytes.is_empty() { avatar_bytes = Some(bytes.to_vec()); avatar_content_type = ct; } + if !bytes.is_empty() { + avatar_bytes = Some(bytes.to_vec()); + avatar_content_type = ct; + } } } "banner" => { let ct = field.content_type().map(|s| s.to_string()); if let Ok(bytes) = field.bytes().await { - if !bytes.is_empty() { banner_bytes = Some(bytes.to_vec()); banner_content_type = ct; } + if !bytes.is_empty() { + banner_bytes = Some(bytes.to_vec()); + banner_content_type = ct; + } } } n if n.starts_with("field_name_") => { if let Ok(idx) = n["field_name_".len()..].parse::() { if let Ok(text) = field.text().await { - if !text.is_empty() { field_names.insert(idx, text); } + if !text.is_empty() { + field_names.insert(idx, text); + } } } } n if n.starts_with("field_value_") => { if let Ok(idx) = n["field_value_".len()..].parse::() { if let Ok(text) = field.text().await { - if !text.is_empty() { field_values.insert(idx, text); } + if !text.is_empty() { + field_values.insert(idx, text); + } } } } @@ -1502,10 +1580,12 @@ pub async fn post_profile_settings( let fields: Vec = (0..4) .filter_map(|i| { - field_names.get(&i).map(|name| domain::models::ProfileField { - name: name.clone(), - value: field_values.get(&i).cloned().unwrap_or_default(), - }) + field_names + .get(&i) + .map(|name| domain::models::ProfileField { + name: name.clone(), + value: field_values.get(&i).cloned().unwrap_or_default(), + }) }) .collect(); diff --git a/crates/presentation/src/handlers/import.rs b/crates/presentation/src/handlers/import.rs index a27abad..7ebfbdd 100644 --- a/crates/presentation/src/handlers/import.rs +++ b/crates/presentation/src/handlers/import.rs @@ -1,13 +1,13 @@ +use api_types::{ + ApplyMappingRequest, ConfirmRequest, SaveProfileRequest, SessionCreatedResponse, + SessionStateResponse, +}; use axum::{ Extension, Form, extract::{Multipart, Path, State}, http::StatusCode, response::{Html, IntoResponse, Redirect}, }; -use api_types::{ - ApplyMappingRequest, ConfirmRequest, SaveProfileRequest, SessionCreatedResponse, - SessionStateResponse, -}; use serde::Deserialize; use std::collections::HashMap; @@ -25,7 +25,10 @@ use application::{ list_import_profiles, save_import_profile, }, }; -use domain::models::{AnnotatedRow, FieldMapping, FileFormat, import::{DomainField, RowResult, Transform}}; +use domain::models::{ + AnnotatedRow, FieldMapping, FileFormat, + import::{DomainField, RowResult, Transform}, +}; use domain::value_objects::ImportSessionId; use crate::{ @@ -196,11 +199,10 @@ pub async fn post_upload( ) .await { - Ok(r) => { - Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response() + Ok(r) => Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response(), + Err(e) => { + Redirect::to(&format!("/import?error={}", encode_error(&e.to_string()))).into_response() } - Err(e) => Redirect::to(&format!("/import?error={}", encode_error(&e.to_string()))) - .into_response(), } } @@ -408,8 +410,9 @@ pub async fn post_confirm( summary.failed.len() )) .into_response(), - Err(e) => Redirect::to(&format!("/import?error={}", encode_error(&e.to_string()))) - .into_response(), + Err(e) => { + Redirect::to(&format!("/import?error={}", encode_error(&e.to_string()))).into_response() + } } } diff --git a/crates/presentation/src/handlers/mod.rs b/crates/presentation/src/handlers/mod.rs index 4cb18e5..d6b9ff5 100644 --- a/crates/presentation/src/handlers/mod.rs +++ b/crates/presentation/src/handlers/mod.rs @@ -1,8 +1,8 @@ +pub mod api; pub mod html; pub mod images; -pub mod rss; -pub mod api; pub mod import; +pub mod rss; const DEFAULT_PAGE_LIMIT: u32 = 5; const RSS_FEED_LIMIT: u32 = 50; diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs index 9397be6..66d3bc4 100644 --- a/crates/presentation/src/lib.rs +++ b/crates/presentation/src/lib.rs @@ -1,7 +1,7 @@ pub mod csrf; -pub mod forms; pub mod errors; pub mod extractors; +pub mod forms; pub mod handlers; pub mod openapi; pub mod ports; diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 7ff216e..af78811 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -13,13 +13,17 @@ use template_askama::AskamaHtmlRenderer; use presentation::{openapi, routes, state::AppState}; -use domain::ports::{DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository}; +use domain::ports::{ + DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository, +}; #[cfg(feature = "postgres")] use postgres_search; #[cfg(not(any(feature = "sqlite", feature = "postgres")))] -compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres"); +compile_error!( + "At least one database backend must be enabled. Use --features sqlite or --features postgres" +); #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -37,7 +41,11 @@ async fn main() -> anyhow::Result<()> { let addr = format!("{}:{}", host, port); let listener = TcpListener::bind(&addr).await?; tracing::info!("Listening on {}", addr); - axum::serve(listener, app.into_make_service_with_connect_info::()).await?; + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await?; Ok(()) } @@ -52,25 +60,71 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let poster_fetcher = poster_fetcher::create()?; let image_storage = image_storage::create()?; - let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, movie_profile_repository, watchlist_repository, person_command, person_query, search_command, search_port, db_pool) = - match backend.as_str() { - #[cfg(feature = "postgres")] - "postgres" => { - let (pool, m, r, d, s, u, is, ip, mp, wl) = postgres::wire(&database_url).await?; - let (pc, pq) = postgres::create_person_adapter(pool.clone()); - let (sc, sp) = postgres_search::create_search_adapter(pool.clone()); - (m, r, d, s, u, is, ip, mp, wl, pc, pq, sc, sp, DbPool::Postgres(pool)) - } - #[cfg(feature = "sqlite")] - _ => { - let (pool, m, r, d, s, u, is, ip, mp, wl) = sqlite::wire(&database_url).await?; - let (pc, pq) = sqlite::create_person_adapter(pool.clone()); - let (sc, sp) = sqlite_search::create_search_adapter(pool.clone()); - (m, r, d, s, u, is, ip, mp, wl, pc, pq, sc, sp, DbPool::Sqlite(pool)) - } - #[cfg(not(feature = "sqlite"))] - _ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"), - }; + let ( + movie_repository, + review_repository, + diary_repository, + stats_repository, + user_repository, + import_session_repository, + import_profile_repository, + movie_profile_repository, + watchlist_repository, + person_command, + person_query, + search_command, + search_port, + db_pool, + ) = match backend.as_str() { + #[cfg(feature = "postgres")] + "postgres" => { + let (pool, m, r, d, s, u, is, ip, mp, wl) = postgres::wire(&database_url).await?; + let (pc, pq) = postgres::create_person_adapter(pool.clone()); + let (sc, sp) = postgres_search::create_search_adapter(pool.clone()); + ( + m, + r, + d, + s, + u, + is, + ip, + mp, + wl, + pc, + pq, + sc, + sp, + DbPool::Postgres(pool), + ) + } + #[cfg(feature = "sqlite")] + _ => { + let (pool, m, r, d, s, u, is, ip, mp, wl) = sqlite::wire(&database_url).await?; + let (pc, pq) = sqlite::create_person_adapter(pool.clone()); + let (sc, sp) = sqlite_search::create_search_adapter(pool.clone()); + ( + m, + r, + d, + s, + u, + is, + ip, + mp, + wl, + pc, + pq, + sc, + sp, + DbPool::Sqlite(pool), + ) + } + #[cfg(not(feature = "sqlite"))] + _ => anyhow::bail!( + "DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)" + ), + }; let profile_fields_repo = match &db_pool { #[cfg(feature = "postgres")] @@ -86,27 +140,31 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { #[cfg(feature = "federation")] let (event_publisher_arc, ap_router, ap_service, social_query, remote_watchlist_repo) = { - let (federation_repo, social_query_arc, review_store, remote_watchlist_repo) = match &db_pool { - #[cfg(feature = "postgres-federation")] - DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), - #[cfg(feature = "sqlite-federation")] - DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()), - #[cfg(not(feature = "sqlite-federation"))] - _ => anyhow::bail!("DATABASE_BACKEND={backend} federation is not supported by this build"), - }; + let (federation_repo, social_query_arc, review_store, remote_watchlist_repo) = + match &db_pool { + #[cfg(feature = "postgres-federation")] + DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), + #[cfg(feature = "sqlite-federation")] + DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()), + #[cfg(not(feature = "sqlite-federation"))] + _ => anyhow::bail!( + "DATABASE_BACKEND={backend} federation is not supported by this build" + ), + }; let ep: Arc = match event_bus { EventBusBackend::Db => { tracing::info!("event bus: DB queue"); match &db_pool { #[cfg(feature = "postgres")] - DbPool::Postgres(pool) => postgres_event_queue::PostgresEventQueue::create_publisher( - pool.clone() - ).await?, + DbPool::Postgres(pool) => { + postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()) + .await? + } #[cfg(feature = "sqlite")] - DbPool::Sqlite(pool) => sqlite_event_queue::SqliteEventQueue::create_publisher( - pool.clone() - ).await?, + DbPool::Sqlite(pool) => { + sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await? + } } } #[cfg(feature = "nats")] @@ -129,11 +187,18 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { app_config.base_url.clone(), app_config.allow_registration, Arc::clone(&ep), - ).await?; + ) + .await?; let ap_router = ap.router; let ap_service_arc = ap.service; - (ep, ap_router, ap_service_arc, social_query_arc, remote_watchlist_repo) + ( + ep, + ap_router, + ap_service_arc, + social_query_arc, + remote_watchlist_repo, + ) }; #[cfg(not(feature = "federation"))] @@ -142,15 +207,17 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { tracing::info!("event bus: DB queue"); match &db_pool { #[cfg(feature = "postgres")] - DbPool::Postgres(pool) => postgres_event_queue::PostgresEventQueue::create_publisher( - pool.clone() - ).await?, + DbPool::Postgres(pool) => { + postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await? + } #[cfg(feature = "sqlite")] - DbPool::Sqlite(pool) => sqlite_event_queue::SqliteEventQueue::create_publisher( - pool.clone() - ).await?, + DbPool::Sqlite(pool) => { + sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await? + } #[cfg(not(feature = "sqlite"))] - _ => anyhow::bail!("EVENT_BUS_BACKEND=db has no adapter for DATABASE_BACKEND={backend}; enable the sqlite or postgres feature"), + _ => anyhow::bail!( + "EVENT_BUS_BACKEND=db has no adapter for DATABASE_BACKEND={backend}; enable the sqlite or postgres feature" + ), } } #[cfg(feature = "nats")] @@ -213,7 +280,6 @@ enum DbPool { Postgres(sqlx::PgPool), } - #[derive(Clone, Copy)] enum EventBusBackend { Db, @@ -231,7 +297,9 @@ impl EventBusBackend { #[cfg(feature = "nats")] "nats" => Ok(Self::Nats), #[cfg(not(feature = "nats"))] - "nats" => anyhow::bail!("EVENT_BUS_BACKEND=nats requires the nats feature to be compiled in"), + "nats" => { + anyhow::bail!("EVENT_BUS_BACKEND=nats requires the nats feature to be compiled in") + } other => anyhow::bail!("unknown EVENT_BUS_BACKEND={other}, expected 'db' or 'nats'"), } } diff --git a/crates/presentation/src/openapi/auth.rs b/crates/presentation/src/openapi/auth.rs index 253892f..689b09a 100644 --- a/crates/presentation/src/openapi/auth.rs +++ b/crates/presentation/src/openapi/auth.rs @@ -3,10 +3,7 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( - paths( - crate::handlers::api::login, - crate::handlers::api::register, - ), - components(schemas(LoginRequest, LoginResponse, RegisterRequest)), + paths(crate::handlers::api::login, crate::handlers::api::register,), + components(schemas(LoginRequest, LoginResponse, RegisterRequest)) )] pub struct AuthDoc; diff --git a/crates/presentation/src/openapi/diary.rs b/crates/presentation/src/openapi/diary.rs index cc1ab29..a1a6035 100644 --- a/crates/presentation/src/openapi/diary.rs +++ b/crates/presentation/src/openapi/diary.rs @@ -1,4 +1,6 @@ -use api_types::{ActivityFeedResponse, DiaryEntryDto, DiaryResponse, FeedEntryDto, LogReviewRequest, ReviewDto}; +use api_types::{ + ActivityFeedResponse, DiaryEntryDto, DiaryResponse, FeedEntryDto, LogReviewRequest, ReviewDto, +}; use utoipa::OpenApi; #[derive(OpenApi)] @@ -17,6 +19,6 @@ use utoipa::OpenApi; LogReviewRequest, ActivityFeedResponse, FeedEntryDto, - )), + )) )] pub struct DiaryDoc; diff --git a/crates/presentation/src/openapi/import.rs b/crates/presentation/src/openapi/import.rs index a6a03ff..c14ffc3 100644 --- a/crates/presentation/src/openapi/import.rs +++ b/crates/presentation/src/openapi/import.rs @@ -22,6 +22,6 @@ use utoipa::OpenApi; ApplyMappingRequest, ConfirmRequest, SaveProfileRequest, - )), + )) )] pub struct ImportDoc; diff --git a/crates/presentation/src/openapi/social.rs b/crates/presentation/src/openapi/social.rs index 04f8d50..df52231 100644 --- a/crates/presentation/src/openapi/social.rs +++ b/crates/presentation/src/openapi/social.rs @@ -33,6 +33,6 @@ use utoipa::OpenApi; BlockedDomainResponse, AddBlockedDomainRequest, BlockedActorResponse, - )), + )) )] pub struct SocialDoc; diff --git a/crates/presentation/src/openapi/users.rs b/crates/presentation/src/openapi/users.rs index cc1d2ef..fcf9191 100644 --- a/crates/presentation/src/openapi/users.rs +++ b/crates/presentation/src/openapi/users.rs @@ -1,4 +1,6 @@ -use api_types::{ProfileResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UsersResponse}; +use api_types::{ + ProfileResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UsersResponse, +}; use utoipa::OpenApi; #[derive(OpenApi)] @@ -15,6 +17,6 @@ use utoipa::OpenApi; UserProfileResponse, UserStatsDto, ProfileResponse, - )), + )) )] pub struct UsersDoc; diff --git a/crates/presentation/src/openapi/watchlist.rs b/crates/presentation/src/openapi/watchlist.rs index ba6bd8d..724cd72 100644 --- a/crates/presentation/src/openapi/watchlist.rs +++ b/crates/presentation/src/openapi/watchlist.rs @@ -1,4 +1,6 @@ -use api_types::{AddToWatchlistRequest, WatchlistEntryDto, WatchlistResponse, WatchlistStatusResponse}; +use api_types::{ + AddToWatchlistRequest, WatchlistEntryDto, WatchlistResponse, WatchlistStatusResponse, +}; use utoipa::OpenApi; #[derive(OpenApi)] diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 594d082..5b40c5a 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -60,7 +60,10 @@ fn html_routes(rate_limit: u64) -> Router { let base = Router::new() .route("/", routing::get(handlers::html::get_activity_feed)) .route("/users", routing::get(handlers::html::get_users_list)) - .route("/u/{username}", routing::get(handlers::html::get_user_by_username)) + .route( + "/u/{username}", + routing::get(handlers::html::get_user_by_username), + ) .route( "/users/{id}", routing::get(handlers::html::get_user_profile), @@ -79,24 +82,41 @@ fn html_routes(rate_limit: u64) -> Router { "/reviews/{id}/delete", routing::post(handlers::html::post_delete_review), ) - .route( - "/images/{*key}", - routing::get(handlers::images::get_image), - ) + .route("/images/{*key}", routing::get(handlers::images::get_image)) .route( "/posters/{path}", - routing::get(|axum::extract::Path(p): axum::extract::Path| async move { - axum::response::Redirect::permanent(&format!("/images/{}", p)) - }), + routing::get( + |axum::extract::Path(p): axum::extract::Path| async move { + axum::response::Redirect::permanent(&format!("/images/{}", p)) + }, + ), ) .route("/diary/export", routing::get(handlers::html::get_export)) .route("/import", routing::get(handlers::import::get_import_page)) - .route("/import/upload", routing::post(handlers::import::post_upload)) - .route("/import/{id}/mapping", routing::get(handlers::import::get_mapping_page).post(handlers::import::post_mapping)) - .route("/import/{id}/preview", routing::get(handlers::import::get_preview_page)) - .route("/import/{id}/confirm", routing::post(handlers::import::post_confirm)) - .route("/import/done", routing::get(handlers::import::get_import_done)) - .route("/import/profiles/{profile_id}/delete", routing::post(handlers::import::post_delete_profile)) + .route( + "/import/upload", + routing::post(handlers::import::post_upload), + ) + .route( + "/import/{id}/mapping", + routing::get(handlers::import::get_mapping_page).post(handlers::import::post_mapping), + ) + .route( + "/import/{id}/preview", + routing::get(handlers::import::get_preview_page), + ) + .route( + "/import/{id}/confirm", + routing::post(handlers::import::post_confirm), + ) + .route( + "/import/done", + routing::get(handlers::import::get_import_done), + ) + .route( + "/import/profiles/{profile_id}/delete", + routing::post(handlers::import::post_delete_profile), + ) .route("/feed.rss", routing::get(handlers::rss::get_feed)) .route( "/users/{id}/feed.rss", @@ -171,8 +191,14 @@ fn federation_html_routes() -> Router { "/social/blocked", routing::get(handlers::html::get_blocked_actors_page), ) - .route("/social/block", routing::post(handlers::html::post_block_actor_html)) - .route("/social/unblock", routing::post(handlers::html::post_unblock_actor)) + .route( + "/social/block", + routing::post(handlers::html::post_block_actor_html), + ) + .route( + "/social/unblock", + routing::post(handlers::html::post_unblock_actor), + ) } fn api_routes(rate_limit: u64) -> Router { @@ -216,17 +242,48 @@ fn api_routes(rate_limit: u64) -> Router { ) .route("/users", routing::get(handlers::api::list_users)) .route("/users/{id}", routing::get(handlers::api::get_user_profile)) - .route("/import/sessions", routing::post(handlers::import::api_post_session)) - .route("/import/sessions/{id}", routing::get(handlers::import::api_get_session)) - .route("/import/sessions/{id}/mapping", routing::put(handlers::import::api_put_mapping)) - .route("/import/sessions/{id}/confirm", routing::post(handlers::import::api_post_confirm)) - .route("/import/profiles", routing::get(handlers::import::api_get_profiles).post(handlers::import::api_post_profile)) - .route("/import/profiles/{id}", routing::delete(handlers::import::api_delete_profile)) - .route("/profile", routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler)) - .route("/profile/fields", routing::put(handlers::api::update_profile_fields_handler)) + .route( + "/import/sessions", + routing::post(handlers::import::api_post_session), + ) + .route( + "/import/sessions/{id}", + routing::get(handlers::import::api_get_session), + ) + .route( + "/import/sessions/{id}/mapping", + routing::put(handlers::import::api_put_mapping), + ) + .route( + "/import/sessions/{id}/confirm", + routing::post(handlers::import::api_post_confirm), + ) + .route( + "/import/profiles", + routing::get(handlers::import::api_get_profiles) + .post(handlers::import::api_post_profile), + ) + .route( + "/import/profiles/{id}", + routing::delete(handlers::import::api_delete_profile), + ) + .route( + "/profile", + routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler), + ) + .route( + "/profile/fields", + routing::put(handlers::api::update_profile_fields_handler), + ) .route("/search", routing::get(handlers::api::get_search)) - .route("/people/{id}", routing::get(handlers::api::get_person_handler)) - .route("/people/{id}/credits", routing::get(handlers::api::get_person_credits_handler)) + .route( + "/people/{id}", + routing::get(handlers::api::get_person_handler), + ) + .route( + "/people/{id}/credits", + routing::get(handlers::api::get_person_credits_handler), + ) .route( "/watchlist", routing::get(handlers::api::get_watchlist_handler) @@ -282,7 +339,16 @@ fn federation_api_routes() -> Router { "/admin/blocked-domains/{domain}", routing::delete(handlers::api::remove_blocked_domain_admin), ) - .route("/social/block", routing::post(handlers::api::block_actor_api)) - .route("/social/unblock", routing::post(handlers::api::unblock_actor_api)) - .route("/social/blocked", routing::get(handlers::api::get_blocked_actors_api)) + .route( + "/social/block", + routing::post(handlers::api::block_actor_api), + ) + .route( + "/social/unblock", + routing::post(handlers::api::unblock_actor_api), + ) + .route( + "/social/blocked", + routing::get(handlers::api::get_blocked_actors_api), + ) } diff --git a/crates/presentation/src/tests/api_handlers.rs b/crates/presentation/src/tests/api_handlers.rs index b662e3f..552bc76 100644 --- a/crates/presentation/src/tests/api_handlers.rs +++ b/crates/presentation/src/tests/api_handlers.rs @@ -1,4 +1,4 @@ -use super::extractors::{make_test_state, Panic}; +use super::extractors::{Panic, make_test_state}; use axum::{ Router, body::Body, @@ -14,7 +14,10 @@ use uuid::Uuid; struct SearchPortStub; #[async_trait::async_trait] impl domain::ports::SearchPort for SearchPortStub { - async fn search(&self, _: &domain::models::SearchQuery) -> Result { + async fn search( + &self, + _: &domain::models::SearchQuery, + ) -> Result { Ok(domain::models::SearchResults { movies: domain::models::collections::Paginated { items: vec![], @@ -36,13 +39,22 @@ impl domain::ports::SearchPort for SearchPortStub { struct PersonQueryStub; #[async_trait::async_trait] impl domain::ports::PersonQuery for PersonQueryStub { - async fn get_by_id(&self, _: &domain::models::PersonId) -> Result, DomainError> { - Ok(None) // Return None to trigger 404 + async fn get_by_id( + &self, + _: &domain::models::PersonId, + ) -> Result, DomainError> { + Ok(None) // Return None to trigger 404 } - async fn get_by_external_id(&self, _: &domain::models::ExternalPersonId) -> Result, DomainError> { + async fn get_by_external_id( + &self, + _: &domain::models::ExternalPersonId, + ) -> Result, DomainError> { Ok(None) } - async fn get_credits(&self, _: &domain::models::PersonId) -> Result { + async fn get_credits( + &self, + _: &domain::models::PersonId, + ) -> Result { Err(DomainError::NotFound("Person not found".into())) } async fn list_orphaned_persons(&self) -> Result, DomainError> { @@ -104,7 +116,10 @@ async fn person_endpoint_returns_404_for_unknown_id() { // Override the person_query with our stub state.app_ctx.person_query = Arc::new(PersonQueryStub); let app = Router::new() - .route("/api/v1/people/{id}", get(crate::handlers::api::get_person_handler)) + .route( + "/api/v1/people/{id}", + get(crate::handlers::api::get_person_handler), + ) .with_state(state); let unknown_id = Uuid::new_v4(); @@ -127,7 +142,10 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() { // Override the person_query with our stub state.app_ctx.person_query = Arc::new(PersonQueryStub); let app = Router::new() - .route("/api/v1/people/{id}/credits", get(crate::handlers::api::get_person_credits_handler)) + .route( + "/api/v1/people/{id}/credits", + get(crate::handlers::api::get_person_credits_handler), + ) .with_state(state); let unknown_id = Uuid::new_v4(); @@ -150,7 +168,10 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() { async fn get_watchlist_requires_auth() { let state = make_test_state(Arc::new(Panic)); let app = Router::new() - .route("/api/v1/watchlist", get(crate::handlers::api::get_watchlist_handler)) + .route( + "/api/v1/watchlist", + get(crate::handlers::api::get_watchlist_handler), + ) .with_state(state); let resp = app diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index 1c82326..d8fcc98 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -10,21 +10,20 @@ use domain::{ errors::DomainError, events::DomainEvent, models::{ - DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats, + DiaryEntry, DiaryFilter, EntityType, FeedEntry, IndexableDocument, Movie, Person, + PersonCredits, PersonId, Review, ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends, collections::{PageParams, Paginated}, - PersonId, EntityType, IndexableDocument, Person, PersonCredits, - SearchQuery, SearchResults, }, ports::{ - AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage, - MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository, - StatsRepository, UserRepository, WatchlistRepository, - PersonCommand, PersonQuery, SearchPort, SearchCommand, + AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage, MetadataClient, + MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, + ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository, + WatchlistRepository, }, value_objects::{ - Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, - ReleaseYear, ReviewId, UserId, + Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, ReleaseYear, + ReviewId, UserId, }, }; use std::sync::Arc; @@ -58,7 +57,12 @@ impl MovieRepository for Panic { async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!() } - async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result, DomainError> { + async fn list_movies( + &self, + _: &domain::models::collections::PageParams, + _: &domain::models::MovieFilter, + ) -> Result, DomainError> + { panic!() } } @@ -123,10 +127,7 @@ impl DiaryRepository for Panic { #[cfg(feature = "federation")] #[async_trait::async_trait] impl domain::ports::SocialQueryPort for Panic { - async fn get_accepted_following_urls( - &self, - _: uuid::Uuid, - ) -> Result, DomainError> { + async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result, DomainError> { panic!() } async fn list_all_followed_remote_actors( @@ -167,9 +168,15 @@ impl PosterFetcherClient for Panic { } #[async_trait::async_trait] impl ImageStorage for Panic { - async fn store(&self, _: &str, _: &[u8]) -> Result { panic!() } - async fn get(&self, _: &str) -> Result, DomainError> { panic!() } - async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() } + async fn store(&self, _: &str, _: &[u8]) -> Result { + panic!() + } + async fn get(&self, _: &str) -> Result, DomainError> { + panic!() + } + async fn delete(&self, _: &str) -> Result<(), DomainError> { + panic!() + } } #[async_trait::async_trait] impl AuthService for Panic { @@ -191,19 +198,13 @@ impl PasswordHasher for Panic { } #[async_trait::async_trait] impl UserRepository for Panic { - async fn find_by_email( - &self, - _: &Email, - ) -> Result, DomainError> { + async fn find_by_email(&self, _: &Email) -> Result, DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> { panic!() } - async fn find_by_id( - &self, - _: &UserId, - ) -> Result, DomainError> { + async fn find_by_id(&self, _: &UserId) -> Result, DomainError> { panic!() } async fn find_by_username( @@ -215,14 +216,32 @@ impl UserRepository for Panic { async fn list_with_stats(&self) -> Result, DomainError> { panic!() } - async fn update_profile(&self, _: &UserId, _: Option, _: Option, _: Option, _: Option) -> Result<(), DomainError> { + async fn update_profile( + &self, + _: &UserId, + _: Option, + _: Option, + _: Option, + _: Option, + ) -> Result<(), DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::UserProfileFieldsRepository for Panic { - async fn get_fields(&self, _: &UserId) -> Result, DomainError> { panic!() } - async fn set_fields(&self, _: &UserId, _: Vec) -> Result<(), DomainError> { panic!() } + async fn get_fields( + &self, + _: &UserId, + ) -> Result, DomainError> { + panic!() + } + async fn set_fields( + &self, + _: &UserId, + _: Vec, + ) -> Result<(), DomainError> { + panic!() + } } #[async_trait::async_trait] impl EventPublisher for Panic { @@ -232,33 +251,104 @@ impl EventPublisher for Panic { } #[async_trait::async_trait] impl domain::ports::ImportSessionRepository for Panic { - async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } - async fn get(&self, _: &domain::value_objects::ImportSessionId, _: &UserId) -> Result, DomainError> { panic!() } - async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } - async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> { panic!() } - async fn delete_expired(&self) -> Result { panic!() } - async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { panic!() } + async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { + panic!() + } + async fn get( + &self, + _: &domain::value_objects::ImportSessionId, + _: &UserId, + ) -> Result, DomainError> { + panic!() + } + async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { + panic!() + } + async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> { + panic!() + } + async fn delete_expired(&self) -> Result { + panic!() + } + async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { + panic!() + } } #[async_trait::async_trait] impl domain::ports::ImportProfileRepository for Panic { - async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { panic!() } - async fn list_for_user(&self, _: &UserId) -> Result, DomainError> { panic!() } - async fn get(&self, _: &domain::value_objects::ImportProfileId, _: &UserId) -> Result, DomainError> { panic!() } - async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() } + async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { + panic!() + } + async fn list_for_user( + &self, + _: &UserId, + ) -> Result, DomainError> { + panic!() + } + async fn get( + &self, + _: &domain::value_objects::ImportProfileId, + _: &UserId, + ) -> Result, DomainError> { + panic!() + } + async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { + panic!() + } } #[async_trait::async_trait] impl WatchlistRepository for Panic { - async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> { panic!() } - async fn remove(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<(), DomainError> { panic!() } - async fn remove_if_present(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result { Ok(false) } - async fn get_for_user(&self, _: &domain::value_objects::UserId, _: &domain::models::collections::PageParams) -> Result, DomainError> { panic!() } - async fn contains(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result { Ok(false) } + async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> { + panic!() + } + async fn remove( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::MovieId, + ) -> Result<(), DomainError> { + panic!() + } + async fn remove_if_present( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::MovieId, + ) -> Result { + Ok(false) + } + async fn get_for_user( + &self, + _: &domain::value_objects::UserId, + _: &domain::models::collections::PageParams, + ) -> Result< + domain::models::collections::Paginated, + DomainError, + > { + panic!() + } + async fn contains( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::MovieId, + ) -> Result { + Ok(false) + } } #[async_trait::async_trait] impl domain::ports::MovieProfileRepository for Panic { - async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { panic!() } - async fn get_by_movie_id(&self, _: &domain::value_objects::MovieId) -> Result, DomainError> { Ok(None) } - async fn list_stale(&self) -> Result, DomainError> { Ok(vec![]) } + async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { + panic!() + } + async fn get_by_movie_id( + &self, + _: &domain::value_objects::MovieId, + ) -> Result, DomainError> { + Ok(None) + } + async fn list_stale( + &self, + ) -> Result, DomainError> { + Ok(vec![]) + } } #[async_trait::async_trait] impl domain::ports::DiaryExporter for Panic { @@ -272,10 +362,18 @@ impl domain::ports::DiaryExporter for Panic { } impl domain::ports::DocumentParser for Panic { - fn parse(&self, _: &[u8], _: domain::models::FileFormat) -> Result { + fn parse( + &self, + _: &[u8], + _: domain::models::FileFormat, + ) -> Result { panic!() } - fn apply_mapping(&self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping]) -> Vec { + fn apply_mapping( + &self, + _: &domain::models::ParsedFile, + _: &[domain::models::FieldMapping], + ) -> Vec { panic!() } } @@ -312,10 +410,7 @@ impl crate::ports::HtmlRenderer for Panic { ) -> Result { panic!() } - fn render_users_page( - &self, - _: application::ports::UsersPageData, - ) -> Result { + fn render_users_page(&self, _: application::ports::UsersPageData) -> Result { panic!() } fn render_profile_page( @@ -342,13 +437,48 @@ impl crate::ports::HtmlRenderer for Panic { ) -> Result { panic!() } - fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result { panic!() } - fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result { panic!() } - fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result { panic!() } - fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result { panic!() } - fn render_blocked_domains_page(&self, _: application::ports::BlockedDomainsPageData) -> Result { panic!() } - fn render_blocked_actors_page(&self, _: application::ports::BlockedActorsPageData) -> Result { panic!() } - fn render_watchlist_page(&self, _: application::ports::WatchlistPageData) -> Result { panic!() } + fn render_import_upload_page( + &self, + _: application::ports::ImportUploadPageData, + ) -> Result { + panic!() + } + fn render_import_mapping_page( + &self, + _: application::ports::ImportMappingPageData, + ) -> Result { + panic!() + } + fn render_import_preview_page( + &self, + _: application::ports::ImportPreviewPageData, + ) -> Result { + panic!() + } + fn render_profile_settings_page( + &self, + _: application::ports::ProfileSettingsPageData, + ) -> Result { + panic!() + } + fn render_blocked_domains_page( + &self, + _: application::ports::BlockedDomainsPageData, + ) -> Result { + panic!() + } + fn render_blocked_actors_page( + &self, + _: application::ports::BlockedActorsPageData, + ) -> Result { + panic!() + } + fn render_watchlist_page( + &self, + _: application::ports::WatchlistPageData, + ) -> Result { + panic!() + } } impl crate::ports::RssFeedRenderer for Panic { fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result { @@ -369,32 +499,67 @@ impl AuthService for RejectingAuth { #[async_trait::async_trait] impl PersonCommand for Panic { - async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { panic!() } + async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { + panic!() + } } #[async_trait::async_trait] impl PersonQuery for Panic { - async fn get_by_id(&self, _: &PersonId) -> Result, DomainError> { panic!() } - async fn get_by_external_id(&self, _: &domain::models::ExternalPersonId) -> Result, DomainError> { panic!() } - async fn get_credits(&self, _: &PersonId) -> Result { panic!() } - async fn list_orphaned_persons(&self) -> Result, DomainError> { panic!() } + async fn get_by_id(&self, _: &PersonId) -> Result, DomainError> { + panic!() + } + async fn get_by_external_id( + &self, + _: &domain::models::ExternalPersonId, + ) -> Result, DomainError> { + panic!() + } + async fn get_credits(&self, _: &PersonId) -> Result { + panic!() + } + async fn list_orphaned_persons(&self) -> Result, DomainError> { + panic!() + } } #[async_trait::async_trait] impl SearchPort for Panic { - async fn search(&self, _: &SearchQuery) -> Result { panic!() } + async fn search(&self, _: &SearchQuery) -> Result { + panic!() + } } #[async_trait::async_trait] impl SearchCommand for Panic { - async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { panic!() } - async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { panic!() } + async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { + panic!() + } + async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { + panic!() + } } #[cfg(feature = "federation")] #[async_trait::async_trait] impl domain::ports::RemoteWatchlistRepository for Panic { - async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> { Ok(()) } - async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { Ok(()) } - async fn get_by_actor_url(&self, _: &str) -> Result, DomainError> { Ok(vec![]) } - async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { Ok(()) } - async fn get_by_derived_uuid(&self, _: uuid::Uuid) -> Result, DomainError> { Ok(vec![]) } + async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> { + Ok(()) + } + async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn get_by_actor_url( + &self, + _: &str, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn get_by_derived_uuid( + &self, + _: uuid::Uuid, + ) -> Result, DomainError> { + Ok(vec![]) + } } // --- Single state factory — only auth_service varies --- diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index c1afa59..db7c63a 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -10,15 +10,16 @@ use axum::{ use domain::{ errors::DomainError, events::DomainEvent, - models::{Movie, User, PersonId, Person, PersonCredits, EntityType, IndexableDocument, SearchQuery, SearchResults, ExternalPersonId}, + models::{ + EntityType, ExternalPersonId, IndexableDocument, Movie, Person, PersonCredits, PersonId, + SearchQuery, SearchResults, User, + }, ports::{ - AuthService, EventPublisher, GeneratedToken, ImageStorage, MetadataClient, MetadataSearchCriteria, - PasswordHasher, PosterFetcherClient, UserRepository, - PersonCommand, PersonQuery, SearchPort, SearchCommand, - }, - value_objects::{ - Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId, + AuthService, EventPublisher, GeneratedToken, ImageStorage, MetadataClient, + MetadataSearchCriteria, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, + SearchCommand, SearchPort, UserRepository, }, + value_objects::{Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId}, }; use http_body_util::BodyExt; use presentation::{routes, state::AppState}; @@ -61,9 +62,15 @@ impl PosterFetcherClient for PanicFetcher { struct PanicImageStorage; #[async_trait] impl ImageStorage for PanicImageStorage { - async fn store(&self, _: &str, _: &[u8]) -> Result { panic!() } - async fn get(&self, _: &str) -> Result, DomainError> { panic!() } - async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() } + async fn store(&self, _: &str, _: &[u8]) -> Result { + panic!() + } + async fn get(&self, _: &str) -> Result, DomainError> { + panic!() + } + async fn delete(&self, _: &str) -> Result<(), DomainError> { + panic!() + } } struct PanicHasher; @@ -109,7 +116,14 @@ impl UserRepository for NobodyUserRepo { async fn list_with_stats(&self) -> Result, DomainError> { panic!() } - async fn update_profile(&self, _: &UserId, _: Option, _: Option, _: Option, _: Option) -> Result<(), DomainError> { + async fn update_profile( + &self, + _: &UserId, + _: Option, + _: Option, + _: Option, + _: Option, + ) -> Result<(), DomainError> { Ok(()) } } @@ -117,8 +131,19 @@ impl UserRepository for NobodyUserRepo { struct PanicProfileFields; #[async_trait] impl domain::ports::UserProfileFieldsRepository for PanicProfileFields { - async fn get_fields(&self, _: &UserId) -> Result, DomainError> { Ok(vec![]) } - async fn set_fields(&self, _: &UserId, _: Vec) -> Result<(), DomainError> { panic!() } + async fn get_fields( + &self, + _: &UserId, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn set_fields( + &self, + _: &UserId, + _: Vec, + ) -> Result<(), DomainError> { + panic!() + } } struct PanicExporter; @@ -136,20 +161,44 @@ impl domain::ports::DiaryExporter for PanicExporter { struct PanicImportSession; #[async_trait] impl domain::ports::ImportSessionRepository for PanicImportSession { - async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } - async fn get(&self, _: &domain::value_objects::ImportSessionId, _: &UserId) -> Result, DomainError> { panic!() } - async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } - async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> { panic!() } - async fn delete_expired(&self) -> Result { panic!() } - async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { panic!() } + async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { + panic!() + } + async fn get( + &self, + _: &domain::value_objects::ImportSessionId, + _: &UserId, + ) -> Result, DomainError> { + panic!() + } + async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { + panic!() + } + async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> { + panic!() + } + async fn delete_expired(&self) -> Result { + panic!() + } + async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { + panic!() + } } struct PanicDocumentParser; impl domain::ports::DocumentParser for PanicDocumentParser { - fn parse(&self, _: &[u8], _: domain::models::FileFormat) -> Result { + fn parse( + &self, + _: &[u8], + _: domain::models::FileFormat, + ) -> Result { panic!("DocumentParser not wired in tests") } - fn apply_mapping(&self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping]) -> Vec { + fn apply_mapping( + &self, + _: &domain::models::ParsedFile, + _: &[domain::models::FieldMapping], + ) -> Vec { panic!("DocumentParser not wired in tests") } } @@ -159,54 +208,128 @@ struct PanicImportProfile; struct PanicMovieProfile; #[async_trait] impl domain::ports::MovieProfileRepository for PanicMovieProfile { - async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { panic!() } - async fn get_by_movie_id(&self, _: &domain::value_objects::MovieId) -> Result, DomainError> { Ok(None) } - async fn list_stale(&self) -> Result, DomainError> { Ok(vec![]) } + async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { + panic!() + } + async fn get_by_movie_id( + &self, + _: &domain::value_objects::MovieId, + ) -> Result, DomainError> { + Ok(None) + } + async fn list_stale( + &self, + ) -> Result, DomainError> { + Ok(vec![]) + } } #[async_trait] impl domain::ports::ImportProfileRepository for PanicImportProfile { - async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { panic!() } - async fn list_for_user(&self, _: &UserId) -> Result, DomainError> { panic!() } - async fn get(&self, _: &domain::value_objects::ImportProfileId, _: &UserId) -> Result, DomainError> { panic!() } - async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() } + async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { + panic!() + } + async fn list_for_user( + &self, + _: &UserId, + ) -> Result, DomainError> { + panic!() + } + async fn get( + &self, + _: &domain::value_objects::ImportProfileId, + _: &UserId, + ) -> Result, DomainError> { + panic!() + } + async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { + panic!() + } } struct PanicWatchlist; #[async_trait] impl domain::ports::WatchlistRepository for PanicWatchlist { - async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> { panic!() } - async fn remove(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<(), DomainError> { panic!() } - async fn remove_if_present(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result { Ok(false) } - async fn get_for_user(&self, _: &domain::value_objects::UserId, _: &domain::models::collections::PageParams) -> Result, DomainError> { panic!() } - async fn contains(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result { Ok(false) } + async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> { + panic!() + } + async fn remove( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::MovieId, + ) -> Result<(), DomainError> { + panic!() + } + async fn remove_if_present( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::MovieId, + ) -> Result { + Ok(false) + } + async fn get_for_user( + &self, + _: &domain::value_objects::UserId, + _: &domain::models::collections::PageParams, + ) -> Result< + domain::models::collections::Paginated, + DomainError, + > { + panic!() + } + async fn contains( + &self, + _: &domain::value_objects::UserId, + _: &domain::value_objects::MovieId, + ) -> Result { + Ok(false) + } } struct PanicPersonCommand; #[async_trait] impl PersonCommand for PanicPersonCommand { - async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { panic!() } + async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { + panic!() + } } struct PanicPersonQuery; #[async_trait] impl PersonQuery for PanicPersonQuery { - async fn get_by_id(&self, _: &PersonId) -> Result, DomainError> { panic!() } - async fn get_by_external_id(&self, _: &ExternalPersonId) -> Result, DomainError> { panic!() } - async fn get_credits(&self, _: &PersonId) -> Result { panic!() } - async fn list_orphaned_persons(&self) -> Result, DomainError> { panic!() } + async fn get_by_id(&self, _: &PersonId) -> Result, DomainError> { + panic!() + } + async fn get_by_external_id( + &self, + _: &ExternalPersonId, + ) -> Result, DomainError> { + panic!() + } + async fn get_credits(&self, _: &PersonId) -> Result { + panic!() + } + async fn list_orphaned_persons(&self) -> Result, DomainError> { + panic!() + } } struct PanicSearchPort; #[async_trait] impl SearchPort for PanicSearchPort { - async fn search(&self, _: &SearchQuery) -> Result { panic!() } + async fn search(&self, _: &SearchQuery) -> Result { + panic!() + } } struct PanicSearchCommand; #[async_trait] impl SearchCommand for PanicSearchCommand { - async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { panic!() } - async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { panic!() } + async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { + panic!() + } + async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { + panic!() + } } #[cfg(feature = "federation")] @@ -217,19 +340,32 @@ struct PanicRemoteWatchlist; #[cfg(feature = "federation")] #[async_trait::async_trait] impl domain::ports::RemoteWatchlistRepository for PanicRemoteWatchlist { - async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> { Ok(()) } - async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { Ok(()) } - async fn get_by_actor_url(&self, _: &str) -> Result, DomainError> { Ok(vec![]) } - async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { Ok(()) } - async fn get_by_derived_uuid(&self, _: uuid::Uuid) -> Result, DomainError> { Ok(vec![]) } + async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> { + Ok(()) + } + async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn get_by_actor_url( + &self, + _: &str, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn get_by_derived_uuid( + &self, + _: uuid::Uuid, + ) -> Result, DomainError> { + Ok(vec![]) + } } #[cfg(feature = "federation")] #[async_trait::async_trait] impl domain::ports::SocialQueryPort for PanicSocialQuery { - async fn get_accepted_following_urls( - &self, - _: uuid::Uuid, - ) -> Result, DomainError> { + async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result, DomainError> { panic!() } async fn list_all_followed_remote_actors( @@ -406,7 +542,10 @@ async fn tags_other_redirects_to_search() { .await .unwrap(); assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); - assert_eq!(response.headers().get("location").unwrap(), "/?search=batman"); + assert_eq!( + response.headers().get("location").unwrap(), + "/?search=batman" + ); } #[tokio::test] diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index a6aeb5d..d3fee26 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1,5 +1,5 @@ -use api_types::{DiaryEntryDto, LogReviewRequest, ReviewHistoryResponse}; use crate::config::Config; +use api_types::{DiaryEntryDto, LogReviewRequest, ReviewHistoryResponse}; use uuid::Uuid; // ── Screens ─────────────────────────────────────────────────────────────────── @@ -352,9 +352,17 @@ pub fn parse_csv(content: &str) -> Vec { }, manual_title: if title.is_empty() { None } else { Some(title) }, manual_release_year, - manual_director: if director.is_empty() { None } else { Some(director) }, + manual_director: if director.is_empty() { + None + } else { + Some(director) + }, rating, - comment: if comment.is_empty() { None } else { Some(comment) }, + comment: if comment.is_empty() { + None + } else { + Some(comment) + }, watched_at, }), }); @@ -645,20 +653,22 @@ pub fn update(app: &mut App, action: Action) -> Vec { Action::ScrollUp => { if let Screen::Main(m) = &mut app.screen - && m.diary.selected > 0 { - m.diary.selected -= 1; - m.diary.history = None; - } + && m.diary.selected > 0 + { + m.diary.selected -= 1; + m.diary.history = None; + } vec![] } Action::OpenHistory => { if let Screen::Main(m) = &mut app.screen - && let Some(entry) = m.diary.entries.get(m.diary.selected) { - let movie_id = entry.movie.id; - app.loading = true; - return vec![Command::LoadHistory { movie_id }]; - } + && let Some(entry) = m.diary.entries.get(m.diary.selected) + { + let movie_id = entry.movie.id; + app.loading = true; + return vec![Command::LoadHistory { movie_id }]; + } vec![] } @@ -675,11 +685,12 @@ pub fn update(app: &mut App, action: Action) -> Vec { Action::LoadPrev => { if let Screen::Main(m) = &mut app.screen - && m.diary.offset > 0 { - let prev = m.diary.offset.saturating_sub(20); - m.diary.offset = prev; - return vec![Command::LoadDiary { offset: prev }]; - } + && m.diary.offset > 0 + { + let prev = m.diary.offset.saturating_sub(20); + m.diary.offset = prev; + return vec![Command::LoadDiary { offset: prev }]; + } vec![] } @@ -730,17 +741,19 @@ pub fn update(app: &mut App, action: Action) -> Vec { Action::DeleteInit => { if let Screen::Main(m) = &mut app.screen - && let Some(entry) = m.diary.entries.get(m.diary.selected) { - m.diary.delete_pending = Some(entry.review.id); - } + && let Some(entry) = m.diary.entries.get(m.diary.selected) + { + m.diary.delete_pending = Some(entry.review.id); + } vec![] } Action::DeleteConfirm => { if let Screen::Main(m) = &mut app.screen - && let Some(review_id) = m.diary.delete_pending.take() { - return vec![Command::DeleteReview(review_id)]; - } + && let Some(review_id) = m.diary.delete_pending.take() + { + return vec![Command::DeleteReview(review_id)]; + } vec![] } @@ -778,72 +791,75 @@ pub fn update(app: &mut App, action: Action) -> Vec { // ── Add Review ──────────────────────────────────────────────────────── Action::RatingUp => { if let Screen::Main(m) = &mut app.screen - && m.add_review.rating < 5 { - m.add_review.rating += 1; - } + && m.add_review.rating < 5 + { + m.add_review.rating += 1; + } vec![] } Action::RatingDown => { if let Screen::Main(m) = &mut app.screen - && m.add_review.rating > 0 { - m.add_review.rating -= 1; - } + && m.add_review.rating > 0 + { + m.add_review.rating -= 1; + } vec![] } Action::ReviewSubmit => { if let Screen::Main(m) = &app.screen - && m.tab == Tab::AddReview { - let f = &m.add_review; - let has_ext = !f.external_id.is_empty(); - let has_title = !f.title.is_empty(); - let has_watched = !f.watched_at.is_empty(); - let ext_id = if has_ext { - Some(f.external_id.clone()) - } else { - None - }; - let title = if has_title { - Some(f.title.clone()) - } else { - None - }; - let year: Option = f.year.parse().ok(); - let rating = f.rating; - let comment = if f.comment.is_empty() { - None - } else { - Some(f.comment.clone()) - }; - let watched_at = f.watched_at.clone(); + && m.tab == Tab::AddReview + { + let f = &m.add_review; + let has_ext = !f.external_id.is_empty(); + let has_title = !f.title.is_empty(); + let has_watched = !f.watched_at.is_empty(); + let ext_id = if has_ext { + Some(f.external_id.clone()) + } else { + None + }; + let title = if has_title { + Some(f.title.clone()) + } else { + None + }; + let year: Option = f.year.parse().ok(); + let rating = f.rating; + let comment = if f.comment.is_empty() { + None + } else { + Some(f.comment.clone()) + }; + let watched_at = f.watched_at.clone(); - if !has_ext && !has_title { - app.status = Some(StatusMsg { - text: "Title or external ID required".into(), - is_error: true, - }); - return vec![]; - } - if !has_watched { - app.status = Some(StatusMsg { - text: "Watched-at date required".into(), - is_error: true, - }); - return vec![]; - } - let req = LogReviewRequest { - external_metadata_id: ext_id, - manual_title: title, - manual_release_year: year, - manual_director: None, - rating, - comment, - watched_at, - }; - app.loading = true; - return vec![Command::CreateReview(req)]; + if !has_ext && !has_title { + app.status = Some(StatusMsg { + text: "Title or external ID required".into(), + is_error: true, + }); + return vec![]; } + if !has_watched { + app.status = Some(StatusMsg { + text: "Watched-at date required".into(), + is_error: true, + }); + return vec![]; + } + let req = LogReviewRequest { + external_metadata_id: ext_id, + manual_title: title, + manual_release_year: year, + manual_director: None, + rating, + comment, + watched_at, + }; + app.loading = true; + return vec![Command::CreateReview(req)]; + } vec![] } @@ -871,45 +887,49 @@ pub fn update(app: &mut App, action: Action) -> Vec { // ── Bulk Import ─────────────────────────────────────────────────────── Action::BulkParseFile => { if let Screen::Main(m) = &mut app.screen - && m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::EnterPath { - let path = m.bulk_import.file_path.trim().to_string(); - match std::fs::read_to_string(&path) { - Ok(content) => { - m.bulk_import.parsed = parse_csv(&content); - m.bulk_import.stage = BulkImportStage::Preview; - } - Err(e) => { - app.status = Some(StatusMsg { - text: format!("Cannot read file: {e}"), - is_error: true, - }); - } + && m.tab == Tab::BulkImport + && m.bulk_import.stage == BulkImportStage::EnterPath + { + let path = m.bulk_import.file_path.trim().to_string(); + match std::fs::read_to_string(&path) { + Ok(content) => { + m.bulk_import.parsed = parse_csv(&content); + m.bulk_import.stage = BulkImportStage::Preview; + } + Err(e) => { + app.status = Some(StatusMsg { + text: format!("Cannot read file: {e}"), + is_error: true, + }); } } + } vec![] } Action::BulkImportAll => { if let Screen::Main(m) = &mut app.screen - && m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::Preview { - let valid: Vec = m - .bulk_import - .parsed - .iter() - .filter_map(|r| r.result.as_ref().ok().cloned()) - .collect(); - if valid.is_empty() { - app.status = Some(StatusMsg { - text: "No valid rows to import".into(), - is_error: true, - }); - return vec![]; - } - m.bulk_import.results = vec![None; valid.len()]; - m.bulk_import.valid_requests = valid; - m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; - return vec![Command::ImportNext(0)]; + && m.tab == Tab::BulkImport + && m.bulk_import.stage == BulkImportStage::Preview + { + let valid: Vec = m + .bulk_import + .parsed + .iter() + .filter_map(|r| r.result.as_ref().ok().cloned()) + .collect(); + if valid.is_empty() { + app.status = Some(StatusMsg { + text: "No valid rows to import".into(), + is_error: true, + }); + return vec![]; } + m.bulk_import.results = vec![None; valid.len()]; + m.bulk_import.valid_requests = valid; + m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; + return vec![Command::ImportNext(0)]; + } vec![] } diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 140f845..2c01924 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -97,11 +97,7 @@ impl ApiClient { Ok(check_status(resp).await?.json().await?) } - pub async fn export_diary( - &self, - token: &str, - format: &str, - ) -> Result, ApiError> { + pub async fn export_diary(&self, token: &str, format: &str) -> Result, ApiError> { let resp = self .http .get(self.api("/diary/export")) @@ -178,7 +174,9 @@ impl ApiClient { .http .post(self.api("/social/follow")) .bearer_auth(token) - .json(&FollowRequest { handle: handle.into() }) + .json(&FollowRequest { + handle: handle.into(), + }) .send() .await?; check_status(resp).await?; @@ -190,7 +188,9 @@ impl ApiClient { .http .post(self.api("/social/unfollow")) .bearer_auth(token) - .json(&ActorUrlRequest { actor_url: actor_url.into() }) + .json(&ActorUrlRequest { + actor_url: actor_url.into(), + }) .send() .await?; check_status(resp).await?; @@ -202,7 +202,9 @@ impl ApiClient { .http .post(self.api("/social/followers/accept")) .bearer_auth(token) - .json(&ActorUrlRequest { actor_url: actor_url.into() }) + .json(&ActorUrlRequest { + actor_url: actor_url.into(), + }) .send() .await?; check_status(resp).await?; @@ -214,7 +216,9 @@ impl ApiClient { .http .post(self.api("/social/followers/reject")) .bearer_auth(token) - .json(&ActorUrlRequest { actor_url: actor_url.into() }) + .json(&ActorUrlRequest { + actor_url: actor_url.into(), + }) .send() .await?; check_status(resp).await?; @@ -226,7 +230,9 @@ impl ApiClient { .http .post(self.api("/social/followers/remove")) .bearer_auth(token) - .json(&ActorUrlRequest { actor_url: actor_url.into() }) + .json(&ActorUrlRequest { + actor_url: actor_url.into(), + }) .send() .await?; check_status(resp).await?; diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index d18afbb..d63eec6 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -42,21 +42,22 @@ async fn run() -> anyhow::Result<()> { // If we start directly in Main (saved token), trigger an initial diary load if matches!(app.screen, Screen::Main(_)) - && let Some(token) = &saved_token { - let c = client.clone(); - let t = token.clone(); - let tx2 = tx.clone(); - tokio::spawn(async move { - let action = match c.get_diary(&t, 0, 20).await { - Ok(r) => Action::DiaryLoaded { - entries: r.items, - total: r.total_count, - }, - Err(e) => Action::DiaryLoadFailed(e.to_string()), - }; - let _ = tx2.send(action).await; - }); - } + && let Some(token) = &saved_token + { + let c = client.clone(); + let t = token.clone(); + let tx2 = tx.clone(); + tokio::spawn(async move { + let action = match c.get_diary(&t, 0, 20).await { + Ok(r) => Action::DiaryLoaded { + entries: r.items, + total: r.total_count, + }, + Err(e) => Action::DiaryLoadFailed(e.to_string()), + }; + let _ = tx2.send(action).await; + }); + } let result = async { loop { @@ -64,20 +65,21 @@ async fn run() -> anyhow::Result<()> { // Poll keyboard — non-blocking with short timeout if event::poll(Duration::from_millis(50))? - && let Event::Key(key) = event::read()? { - if key.kind != ratatui::crossterm::event::KeyEventKind::Press { - continue; + && let Event::Key(key) = event::read()? + { + if key.kind != ratatui::crossterm::event::KeyEventKind::Press { + continue; + } + if let Some(action) = key_to_action(&app, key) { + if matches!(action, Action::Quit) { + break; } - if let Some(action) = key_to_action(&app, key) { - if matches!(action, Action::Quit) { - break; - } - let cmds = app::update(&mut app, action); - for cmd in cmds { - handle_command(cmd, &app, &client, &tx); - } + let cmds = app::update(&mut app, action); + for cmd in cmds { + handle_command(cmd, &app, &client, &tx); } } + } // Drain async results while let Ok(action) = rx.try_recv() { diff --git a/crates/tui/src/tests/client.rs b/crates/tui/src/tests/client.rs index 02a7918..8da1d49 100644 --- a/crates/tui/src/tests/client.rs +++ b/crates/tui/src/tests/client.rs @@ -51,8 +51,14 @@ fn log_review_request_includes_director_when_set() { fn api_client_builds_versioned_urls() { let client = ApiClient::new("http://localhost:3000"); assert_eq!(client.api("/diary"), "http://localhost:3000/api/v1/diary"); - assert_eq!(client.api("/auth/login"), "http://localhost:3000/api/v1/auth/login"); - assert_eq!(client.api("/social/follow"), "http://localhost:3000/api/v1/social/follow"); + assert_eq!( + client.api("/auth/login"), + "http://localhost:3000/api/v1/auth/login" + ); + assert_eq!( + client.api("/social/follow"), + "http://localhost:3000/api/v1/social/follow" + ); } #[test] diff --git a/crates/worker/src/db.rs b/crates/worker/src/db.rs index a165791..db9f32d 100644 --- a/crates/worker/src/db.rs +++ b/crates/worker/src/db.rs @@ -26,45 +26,78 @@ pub struct Repos { pub movie_profile: Arc, pub watchlist: Arc, pub image_ref_command: Arc, - pub image_ref_query: Arc, - pub person_command: Arc, - pub person_query: Arc, - pub search_command: Arc, - pub search_port: Arc, - pub profile_fields: Arc, + pub image_ref_query: Arc, + pub person_command: Arc, + pub person_query: Arc, + pub search_command: Arc, + pub search_port: Arc, + pub profile_fields: Arc, } pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<(Repos, DbPool)> { match backend { #[cfg(feature = "postgres")] "postgres" => { - let (pool, m, r, d, s, u, is, ip, mp, wl) = - postgres::wire(database_url).await.context("PostgreSQL connection failed")?; + let (pool, m, r, d, s, u, is, ip, mp, wl) = postgres::wire(database_url) + .await + .context("PostgreSQL connection failed")?; let (image_ref_command, image_ref_query) = postgres::create_image_ref(pool.clone()); let (person_command, person_query) = postgres::create_person_adapter(pool.clone()); - let (search_command, search_port) = postgres_search::create_search_adapter(pool.clone()); + let (search_command, search_port) = + postgres_search::create_search_adapter(pool.clone()); let pf = postgres::create_profile_fields_repo(pool.clone()); - Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u, - import_session: is, import_profile: ip, movie_profile: mp, watchlist: wl, - image_ref_command, image_ref_query, - person_command, person_query, search_command, search_port, - profile_fields: pf }, - DbPool::Postgres(pool))) + Ok(( + Repos { + movie: m, + review: r, + diary: d, + stats: s, + user: u, + import_session: is, + import_profile: ip, + movie_profile: mp, + watchlist: wl, + image_ref_command, + image_ref_query, + person_command, + person_query, + search_command, + search_port, + profile_fields: pf, + }, + DbPool::Postgres(pool), + )) } #[cfg(feature = "sqlite")] _ => { - let (pool, m, r, d, s, u, is, ip, mp, wl) = - sqlite::wire(database_url).await.context("SQLite connection failed")?; + let (pool, m, r, d, s, u, is, ip, mp, wl) = sqlite::wire(database_url) + .await + .context("SQLite connection failed")?; let (image_ref_command, image_ref_query) = sqlite::create_image_ref(pool.clone()); let (person_command, person_query) = sqlite::create_person_adapter(pool.clone()); - let (search_command, search_port) = sqlite_search::create_search_adapter(pool.clone()); + let (search_command, search_port) = sqlite_search::create_search_adapter(pool.clone()); let pf = sqlite::create_profile_fields_repo(pool.clone()); - Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u, - import_session: is, import_profile: ip, movie_profile: mp, watchlist: wl, - image_ref_command, image_ref_query, - person_command, person_query, search_command, search_port, - profile_fields: pf }, - DbPool::Sqlite(pool))) + Ok(( + Repos { + movie: m, + review: r, + diary: d, + stats: s, + user: u, + import_session: is, + import_profile: ip, + movie_profile: mp, + watchlist: wl, + image_ref_command, + image_ref_query, + person_command, + person_query, + search_command, + search_port, + profile_fields: pf, + }, + DbPool::Sqlite(pool), + )) } #[cfg(not(feature = "sqlite"))] _ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build"), diff --git a/crates/worker/src/event_bus.rs b/crates/worker/src/event_bus.rs index 09d9ce6..6c94bb8 100644 --- a/crates/worker/src/event_bus.rs +++ b/crates/worker/src/event_bus.rs @@ -23,9 +23,9 @@ impl EventBusBackend { #[cfg(feature = "nats")] "nats" => Ok(Self::Nats), #[cfg(not(feature = "nats"))] - "nats" => anyhow::bail!( - "EVENT_BUS_BACKEND=nats requires the nats feature to be compiled in" - ), + "nats" => { + anyhow::bail!("EVENT_BUS_BACKEND=nats requires the nats feature to be compiled in") + } other => anyhow::bail!("unknown EVENT_BUS_BACKEND={other}, expected 'db' or 'nats'"), } } @@ -39,9 +39,9 @@ pub async fn create( tracing::info!("event bus: DB queue"); match db_pool { #[cfg(feature = "postgres")] - DbPool::Postgres(pool) => { - Ok(postgres_event_queue::PostgresEventQueue::create_channel(pool.clone()).await?) - } + DbPool::Postgres(pool) => Ok( + postgres_event_queue::PostgresEventQueue::create_channel(pool.clone()).await?, + ), #[cfg(feature = "sqlite")] DbPool::Sqlite(pool) => { Ok(sqlite_event_queue::SqliteEventQueue::create_channel(pool.clone()).await?) diff --git a/crates/worker/src/follow_backfill_handler.rs b/crates/worker/src/follow_backfill_handler.rs index 5c99613..18eff5d 100644 --- a/crates/worker/src/follow_backfill_handler.rs +++ b/crates/worker/src/follow_backfill_handler.rs @@ -10,7 +10,12 @@ pub struct FollowBackfillHandler { #[async_trait] impl EventHandler for FollowBackfillHandler { async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { - let DomainEvent::FollowAccepted { remote_actor_url, outbox_url, .. } = event else { + let DomainEvent::FollowAccepted { + remote_actor_url, + outbox_url, + .. + } = event + else { return Ok(()); }; tracing::info!(actor = %remote_actor_url, outbox = %outbox_url, "starting outbox backfill"); diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index c9bddcf..45a60be 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -5,7 +5,10 @@ mod follow_backfill_handler; use std::sync::Arc; use anyhow::Context; -use application::{config::AppConfig, context::AppContext, worker::WorkerService, MovieDiscoveryIndexer, SearchCleanupHandler}; +use application::{ + MovieDiscoveryIndexer, SearchCleanupHandler, config::AppConfig, context::AppContext, + worker::WorkerService, +}; use export::ExportAdapter; use importer::ImporterDocumentParser; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -13,7 +16,9 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use domain::ports::{DiaryExporter, DocumentParser, EventHandler, PeriodicJob}; #[cfg(not(any(feature = "sqlite", feature = "postgres")))] -compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres"); +compile_error!( + "At least one database backend must be enabled. Use --features sqlite or --features postgres" +); #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -32,17 +37,24 @@ async fn main() -> anyhow::Result<()> { let (repos, db_pool) = db::connect(&database_url, &backend).await?; let (event_publisher_arc, consumer_arc) = event_bus::create(&db_pool).await?; - let image_ref_command = Arc::clone(&repos.image_ref_command); - let image_ref_query = Arc::clone(&repos.image_ref_query); - let person_command = Arc::clone(&repos.person_command); - let person_query = Arc::clone(&repos.person_query); - let search_command = Arc::clone(&repos.search_command); - let search_port = Arc::clone(&repos.search_port); + let image_ref_command = Arc::clone(&repos.image_ref_command); + let image_ref_query = Arc::clone(&repos.image_ref_query); + let person_command = Arc::clone(&repos.person_command); + let person_query = Arc::clone(&repos.person_query); + let search_command = Arc::clone(&repos.search_command); + let search_port = Arc::clone(&repos.search_port); let profile_fields_repo = Arc::clone(&repos.profile_fields); // Clone refs federation handler needs before ctx consumes them. #[cfg(feature = "federation")] - let (fed_movie_repo, fed_review_repo, fed_diary_repo, fed_user_repo, base_url, allow_registration) = ( + let ( + fed_movie_repo, + fed_review_repo, + fed_diary_repo, + fed_user_repo, + base_url, + allow_registration, + ) = ( Arc::clone(&repos.movie), Arc::clone(&repos.review), Arc::clone(&repos.diary), @@ -52,38 +64,39 @@ async fn main() -> anyhow::Result<()> { ); // Wire federation repos early to get remote_watchlist_repo for AppContext. #[cfg(feature = "federation")] - let (fed_federation_repo, _fed_social_query, fed_review_store, fed_remote_watchlist_repo) = match &db_pool { - #[cfg(feature = "sqlite-federation")] - db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()), - #[cfg(feature = "postgres-federation")] - db::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), - }; + let (fed_federation_repo, _fed_social_query, fed_review_store, fed_remote_watchlist_repo) = + match &db_pool { + #[cfg(feature = "sqlite-federation")] + db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()), + #[cfg(feature = "postgres-federation")] + db::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), + }; let ctx = AppContext { - movie_repository: repos.movie, - review_repository: repos.review, - diary_repository: repos.diary, - diary_exporter: Arc::new(ExportAdapter) as Arc, - document_parser: Arc::new(ImporterDocumentParser) as Arc, - stats_repository: repos.stats, + movie_repository: repos.movie, + review_repository: repos.review, + diary_repository: repos.diary, + diary_exporter: Arc::new(ExportAdapter) as Arc, + document_parser: Arc::new(ImporterDocumentParser) as Arc, + stats_repository: repos.stats, metadata_client, poster_fetcher, image_storage, - event_publisher: event_publisher_arc, + event_publisher: event_publisher_arc, auth_service, password_hasher, - user_repository: repos.user, + user_repository: repos.user, import_session_repository: repos.import_session, import_profile_repository: repos.import_profile, - movie_profile_repository: repos.movie_profile, - watchlist_repository: repos.watchlist, + movie_profile_repository: repos.movie_profile, + watchlist_repository: repos.watchlist, profile_fields_repository: Arc::clone(&profile_fields_repo), #[cfg(feature = "federation")] remote_watchlist_repository: fed_remote_watchlist_repo.clone(), - person_command: Arc::clone(&person_command), - person_query: Arc::clone(&person_query), - search_port: Arc::clone(&search_port), - search_command: Arc::clone(&search_command), + person_command: Arc::clone(&person_command), + person_query: Arc::clone(&person_query), + search_port: Arc::clone(&search_port), + search_command: Arc::clone(&search_command), config: app_config, }; @@ -91,26 +104,28 @@ async fn main() -> anyhow::Result<()> { // Both the event handler and the staleness job are gated on TMDB_API_KEY. // Without a key, no MovieEnrichmentRequested events are produced or handled. - let (enrichment_handler, enrichment_job): (Option>, Option>) = - match tmdb_enrichment::TmdbEnrichmentClient::from_env() { - Ok(client) => { - tracing::info!("TMDb enrichment enabled"); - let handler = Arc::new(tmdb_enrichment::EnrichmentHandler { - enrichment_client: Arc::new(client), - movie_repository: Arc::clone(&ctx.movie_repository), - profile_repo: Arc::clone(&ctx.movie_profile_repository), - person_command: Arc::clone(&ctx.person_command), - search_command: Arc::clone(&ctx.search_command), - }) as Arc; - let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone())) - as Arc; - (Some(handler), Some(job)) - } - Err(e) => { - tracing::warn!("TMDb enrichment disabled: {e}"); - (None, None) - } - }; + let (enrichment_handler, enrichment_job): ( + Option>, + Option>, + ) = match tmdb_enrichment::TmdbEnrichmentClient::from_env() { + Ok(client) => { + tracing::info!("TMDb enrichment enabled"); + let handler = Arc::new(tmdb_enrichment::EnrichmentHandler { + enrichment_client: Arc::new(client), + movie_repository: Arc::clone(&ctx.movie_repository), + profile_repo: Arc::clone(&ctx.movie_profile_repository), + person_command: Arc::clone(&ctx.person_command), + search_command: Arc::clone(&ctx.search_command), + }) as Arc; + let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone())) + as Arc; + (Some(handler), Some(job)) + } + Err(e) => { + tracing::warn!("TMDb enrichment disabled: {e}"); + (None, None) + } + }; // ── Image conversion ────────────────────────────────────────────────────── @@ -123,11 +138,15 @@ async fn main() -> anyhow::Result<()> { // ── Periodic jobs ───────────────────────────────────────────────────────── - let mut periodic_jobs: Vec> = vec![ - Arc::new(application::jobs::ImportSessionCleanupJob::new(ctx.clone())), - ]; - if let Some(job) = enrichment_job { periodic_jobs.push(job); } - if let Some((_, ref conv_job)) = conversion { periodic_jobs.push(Arc::clone(conv_job)); } + let mut periodic_jobs: Vec> = vec![Arc::new( + application::jobs::ImportSessionCleanupJob::new(ctx.clone()), + )]; + if let Some(job) = enrichment_job { + periodic_jobs.push(job); + } + if let Some((_, ref conv_job)) = conversion { + periodic_jobs.push(Arc::clone(conv_job)); + } for job in periodic_jobs { tokio::spawn(async move { @@ -153,17 +172,27 @@ async fn main() -> anyhow::Result<()> { 3, )) as Arc; - let cleanup = Arc::new(image_storage::ImageCleanupHandler::new( - Arc::clone(&ctx.image_storage), - )) as Arc; + let cleanup = Arc::new(image_storage::ImageCleanupHandler::new(Arc::clone( + &ctx.image_storage, + ))) as Arc; #[cfg(not(feature = "federation"))] { - let search_cleanup = Arc::new(SearchCleanupHandler::new(Arc::clone(&ctx.search_command), Arc::clone(&ctx.person_query))) as Arc; - let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new(Arc::clone(&ctx.movie_repository), Arc::clone(&ctx.search_command))) as Arc; + let search_cleanup = Arc::new(SearchCleanupHandler::new( + Arc::clone(&ctx.search_command), + Arc::clone(&ctx.person_query), + )) as Arc; + let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new( + Arc::clone(&ctx.movie_repository), + Arc::clone(&ctx.search_command), + )) as Arc; let mut h = vec![poster, cleanup, search_cleanup, discovery_indexer]; - if let Some(e) = enrichment_handler { h.push(e); } - if let Some((ref conv_handler, _)) = conversion { h.push(Arc::clone(conv_handler)); } + if let Some(e) = enrichment_handler { + h.push(e); + } + if let Some((ref conv_handler, _)) = conversion { + h.push(Arc::clone(conv_handler)); + } h } @@ -180,19 +209,37 @@ async fn main() -> anyhow::Result<()> { base_url, allow_registration, Arc::clone(&ctx.event_publisher), - ).await?; + ) + .await?; let ap_event_handler = ap_wire.event_handler; let backfill = Arc::new(follow_backfill_handler::FollowBackfillHandler { ap_service: ap_wire.service, }) as Arc; - let search_cleanup = Arc::new(SearchCleanupHandler::new(Arc::clone(&ctx.search_command), Arc::clone(&ctx.person_query))) as Arc; - let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new(Arc::clone(&ctx.movie_repository), Arc::clone(&ctx.search_command))) as Arc; + let search_cleanup = Arc::new(SearchCleanupHandler::new( + Arc::clone(&ctx.search_command), + Arc::clone(&ctx.person_query), + )) as Arc; + let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new( + Arc::clone(&ctx.movie_repository), + Arc::clone(&ctx.search_command), + )) as Arc; tracing::info!("federation event handler registered"); - let mut h = vec![poster, cleanup, ap_event_handler, backfill, search_cleanup, discovery_indexer]; - if let Some(e) = enrichment_handler { h.push(e); } - if let Some((ref conv_handler, _)) = conversion { h.push(Arc::clone(conv_handler)); } + let mut h = vec![ + poster, + cleanup, + ap_event_handler, + backfill, + search_cleanup, + discovery_indexer, + ]; + if let Some(e) = enrichment_handler { + h.push(e); + } + if let Some((ref conv_handler, _)) = conversion { + h.push(Arc::clone(conv_handler)); + } h } };