diff --git a/Cargo.lock b/Cargo.lock index c4a600f..6621e65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1439,6 +1439,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "export" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "domain", + "serde_json", + "tokio", + "uuid", +] + [[package]] name = "fancy-regex" version = "0.11.0" @@ -3055,6 +3067,7 @@ dependencies = [ "domain", "dotenvy", "event-publisher", + "export", "http-body-util", "infer", "metadata", diff --git a/Cargo.toml b/Cargo.toml index 4e64bcd..495e12b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/adapters/template-askama", "crates/adapters/activitypub", "crates/adapters/activitypub-base", + "crates/adapters/export", "crates/application", "crates/domain", "crates/presentation", @@ -48,6 +49,7 @@ poster-fetcher = { path = "crates/adapters/poster-fetcher" } poster-storage = { path = "crates/adapters/poster-storage" } event-publisher = { path = "crates/adapters/event-publisher" } rss = { path = "crates/adapters/rss" } +export = { path = "crates/adapters/export" } sqlite = { path = "crates/adapters/sqlite" } sqlite-federation = { path = "crates/adapters/sqlite-federation" } template-askama = { path = "crates/adapters/template-askama" } diff --git a/crates/adapters/activitypub-base/src/activities.rs b/crates/adapters/activitypub-base/src/activities.rs index fa90f6e..ed56f3d 100644 --- a/crates/adapters/activitypub-base/src/activities.rs +++ b/crates/adapters/activitypub-base/src/activities.rs @@ -1,7 +1,9 @@ use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, - kinds::activity::{AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType}, + kinds::activity::{ + AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType, + }, traits::Activity, }; use serde::{Deserialize, Serialize}; @@ -42,10 +44,16 @@ impl Activity for FollowActivity { let target_domain = match (target_url.host_str(), target_url.port()) { (Some(host), Some(port)) => format!("{}:{}", host, port), (Some(host), None) => host.to_string(), - _ => return Err(Error::bad_request(anyhow::anyhow!("invalid follow target URL"))), + _ => { + return Err(Error::bad_request(anyhow::anyhow!( + "invalid follow target URL" + ))); + } }; if target_domain != data.domain { - return Err(Error::bad_request(anyhow::anyhow!("follow target is not a local actor"))); + return Err(Error::bad_request(anyhow::anyhow!( + "follow target is not a local actor" + ))); } Ok(()) } @@ -105,7 +113,11 @@ impl Activity for AcceptActivity { let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner()) .ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?; data.federation_repo - .update_following_status(local_user_id, self.actor.inner().as_str(), FollowingStatus::Accepted) + .update_following_status( + local_user_id, + self.actor.inner().as_str(), + FollowingStatus::Accepted, + ) .await?; tracing::info!(remote_actor = %self.actor.inner(), "follow accepted by remote"); Ok(()) diff --git a/crates/adapters/activitypub-base/src/actor_handler.rs b/crates/adapters/activitypub-base/src/actor_handler.rs index a702590..7082126 100644 --- a/crates/adapters/activitypub-base/src/actor_handler.rs +++ b/crates/adapters/activitypub-base/src/actor_handler.rs @@ -3,7 +3,7 @@ use activitypub_federation::{ }; use axum::extract::Path; -use crate::actors::{get_local_actor, Person}; +use crate::actors::{Person, get_local_actor}; use crate::data::FederationData; use crate::error::Error; diff --git a/crates/adapters/activitypub-base/src/actors.rs b/crates/adapters/activitypub-base/src/actors.rs index 0d292b2..917ac5a 100644 --- a/crates/adapters/activitypub-base/src/actors.rs +++ b/crates/adapters/activitypub-base/src/actors.rs @@ -63,11 +63,7 @@ pub async fn get_local_actor( None => { let kp = generate_actor_keypair()?; data.federation_repo - .save_local_actor_keypair( - user_id, - kp.public_key.clone(), - kp.private_key.clone(), - ) + .save_local_actor_keypair(user_id, kp.public_key.clone(), kp.private_key.clone()) .await?; (kp.public_key, kp.private_key) } @@ -179,10 +175,7 @@ impl Object for DbActor { Ok(()) } - async fn from_json( - json: Self::Kind, - data: &Data, - ) -> Result { + async fn from_json(json: Self::Kind, data: &Data) -> Result { let actor = RemoteActor { url: json.id.inner().to_string(), handle: json.preferred_username.clone(), diff --git a/crates/adapters/activitypub-base/src/inbox.rs b/crates/adapters/activitypub-base/src/inbox.rs index acee339..2f2d063 100644 --- a/crates/adapters/activitypub-base/src/inbox.rs +++ b/crates/adapters/activitypub-base/src/inbox.rs @@ -1,5 +1,5 @@ use activitypub_federation::{ - axum::inbox::{receive_activity, ActivityData}, + axum::inbox::{ActivityData, receive_activity}, config::Data, protocol::context::WithContext, }; @@ -13,8 +13,6 @@ pub async fn inbox_handler( data: Data, activity_data: ActivityData, ) -> Result<(), Error> { - receive_activity::, DbActor, FederationData>( - activity_data, &data, - ) - .await + receive_activity::, DbActor, FederationData>(activity_data, &data) + .await } diff --git a/crates/adapters/activitypub-base/src/lib.rs b/crates/adapters/activitypub-base/src/lib.rs index 816c94e..3c741e3 100644 --- a/crates/adapters/activitypub-base/src/lib.rs +++ b/crates/adapters/activitypub-base/src/lib.rs @@ -10,14 +10,16 @@ pub mod inbox; pub mod outbox; pub mod repository; pub mod service; +pub(crate) mod urls; pub mod user; pub mod webfinger; -pub(crate) mod urls; pub use content::ApObjectHandler; pub use data::FederationData; pub use error::Error; pub use federation::ApFederationConfig; -pub use repository::{FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor}; +pub use repository::{ + FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, +}; pub use service::ActivityPubService; pub use user::{ApUser, ApUserRepository}; diff --git a/crates/adapters/activitypub-base/src/repository.rs b/crates/adapters/activitypub-base/src/repository.rs index 02f4263..f0cabfc 100644 --- a/crates/adapters/activitypub-base/src/repository.rs +++ b/crates/adapters/activitypub-base/src/repository.rs @@ -31,20 +31,61 @@ pub struct Follower { #[async_trait] pub trait FederationRepository: Send + Sync { - async fn add_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str, status: FollowerStatus, follow_activity_id: &str) -> Result<()>; - async fn get_follower_follow_activity_id(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result>; - async fn remove_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<()>; + async fn add_follower( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + status: FollowerStatus, + follow_activity_id: &str, + ) -> Result<()>; + async fn get_follower_follow_activity_id( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> Result>; + async fn remove_follower( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> Result<()>; async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result>; - async fn update_follower_status(&self, local_user_id: uuid::Uuid, remote_actor_url: &str, status: FollowerStatus) -> Result<()>; - async fn add_following(&self, local_user_id: uuid::Uuid, actor: RemoteActor, follow_activity_id: &str) -> Result<()>; - async fn get_follow_activity_id(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result>; + async fn update_follower_status( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + status: FollowerStatus, + ) -> Result<()>; + async fn add_following( + &self, + local_user_id: uuid::Uuid, + actor: RemoteActor, + follow_activity_id: &str, + ) -> Result<()>; + async fn get_follow_activity_id( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> Result>; async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>; async fn get_following(&self, local_user_id: uuid::Uuid) -> Result>; async fn count_following(&self, local_user_id: uuid::Uuid) -> Result; async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>; async fn get_remote_actor(&self, actor_url: &str) -> Result>; - async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result>; - async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, public_key: String, private_key: String) -> Result<()>; + async fn get_local_actor_keypair( + &self, + user_id: uuid::Uuid, + ) -> Result>; + async fn save_local_actor_keypair( + &self, + user_id: uuid::Uuid, + public_key: String, + private_key: String, + ) -> Result<()>; async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result>; - async fn update_following_status(&self, local_user_id: uuid::Uuid, remote_actor_url: &str, status: FollowingStatus) -> Result<()>; + async fn update_following_status( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + status: FollowingStatus, + ) -> Result<()>; } diff --git a/crates/adapters/activitypub-base/src/service.rs b/crates/adapters/activitypub-base/src/service.rs index 4d3f98c..bd4a18a 100644 --- a/crates/adapters/activitypub-base/src/service.rs +++ b/crates/adapters/activitypub-base/src/service.rs @@ -6,12 +6,12 @@ use activitypub_federation::{ protocol::context::WithContext, traits::Actor, }; -use axum::{routing::get, routing::post, Router}; +use axum::{Router, routing::get, routing::post}; use url::Url; use crate::{ activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity}, - actors::{get_local_actor, DbActor}, + actors::{DbActor, get_local_actor}, content::ApObjectHandler, data::FederationData, federation::ApFederationConfig, @@ -19,8 +19,8 @@ use crate::{ inbox::inbox_handler, outbox::outbox_handler, repository::{FederationRepository, FollowerStatus, FollowingStatus, RemoteActor}, - user::ApUserRepository, urls::activity_url, + user::ApUserRepository, webfinger::webfinger_handler, }; @@ -64,7 +64,10 @@ impl ActivityPubService { ) -> anyhow::Result { let data = FederationData::new(repo, user_repo, object_handler, base_url.clone()); let federation_config = ApFederationConfig::new(data, debug).await?; - Ok(Self { federation_config, base_url }) + Ok(Self { + federation_config, + base_url, + }) } pub fn federation_config(&self) -> &ApFederationConfig { @@ -82,7 +85,9 @@ impl ActivityPubService { let actor = get_local_actor(uuid, &data) .await .map_err(|e| anyhow::anyhow!("{e}"))?; - let person = actor.into_json(&data).await + let person = actor + .into_json(&data) + .await .map_err(|e| anyhow::anyhow!("{e}"))?; Ok(serde_json::to_string(&WithContext::new_default(person))?) } @@ -133,7 +138,10 @@ impl ActivityPubService { .await?; let failures = send_with_retry(sends, &data).await; if !failures.is_empty() { - tracing::warn!(count = failures.len(), "some activity deliveries failed permanently"); + tracing::warn!( + count = failures.len(), + "some activity deliveries failed permanently" + ); } let remote = RemoteActor { @@ -150,11 +158,17 @@ impl ActivityPubService { Ok(()) } - pub async fn unfollow(&self, local_user_id: uuid::Uuid, actor_url_str: &str) -> anyhow::Result<()> { + pub async fn unfollow( + &self, + local_user_id: uuid::Uuid, + actor_url_str: &str, + ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); if actor_url_str.starts_with(&self.base_url) { - return self.unfollow_local(local_user_id, actor_url_str, &data).await; + return self + .unfollow_local(local_user_id, actor_url_str, &data) + .await; } let remote = data @@ -202,7 +216,10 @@ impl ActivityPubService { .await?; let failures = send_with_retry(sends, &data).await; if !failures.is_empty() { - tracing::warn!(count = failures.len(), "some activity deliveries failed permanently"); + tracing::warn!( + count = failures.len(), + "some activity deliveries failed permanently" + ); } data.federation_repo @@ -236,7 +253,9 @@ impl ActivityPubService { .federation_repo .get_follower_follow_activity_id(local_user_id, remote_actor_url) .await? - .ok_or_else(|| anyhow::anyhow!("follow activity id not found for {}", remote_actor_url))?; + .ok_or_else(|| { + anyhow::anyhow!("follow activity id not found for {}", remote_actor_url) + })?; let follow_id = Url::parse(&follow_id_str)?; let follow = FollowActivity { id: follow_id, @@ -265,7 +284,9 @@ impl ActivityPubService { .await?; let failures = send_with_retry(sends, &data).await; if !failures.is_empty() { - tracing::warn!("failed to deliver Accept activity, but follower is marked accepted locally"); + tracing::warn!( + "failed to deliver Accept activity, but follower is marked accepted locally" + ); } self.spawn_backfill(local_user_id, remote_actor.inbox_url.clone()); @@ -313,7 +334,10 @@ impl ActivityPubService { .await?; let failures = send_with_retry(sends, &data).await; if !failures.is_empty() { - tracing::warn!(count = failures.len(), "some activity deliveries failed permanently"); + tracing::warn!( + count = failures.len(), + "some activity deliveries failed permanently" + ); } data.federation_repo @@ -323,12 +347,20 @@ impl ActivityPubService { Ok(()) } - pub async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> anyhow::Result> { + pub async fn get_pending_followers( + &self, + local_user_id: uuid::Uuid, + ) -> anyhow::Result> { let data = self.federation_config.to_request_data(); - data.federation_repo.get_pending_followers(local_user_id).await + data.federation_repo + .get_pending_followers(local_user_id) + .await } - pub async fn get_accepted_followers(&self, local_user_id: uuid::Uuid) -> anyhow::Result> { + pub async fn get_accepted_followers( + &self, + local_user_id: uuid::Uuid, + ) -> anyhow::Result> { let data = self.federation_config.to_request_data(); let followers = data.federation_repo.get_followers(local_user_id).await?; Ok(followers @@ -338,13 +370,22 @@ impl ActivityPubService { .collect()) } - pub async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> anyhow::Result { + pub async fn count_accepted_followers( + &self, + local_user_id: uuid::Uuid, + ) -> anyhow::Result { let data = self.federation_config.to_request_data(); let followers = data.federation_repo.get_followers(local_user_id).await?; - Ok(followers.into_iter().filter(|f| f.status == FollowerStatus::Accepted).count()) + Ok(followers + .into_iter() + .filter(|f| f.status == FollowerStatus::Accepted) + .count()) } - pub async fn get_following(&self, local_user_id: uuid::Uuid) -> anyhow::Result> { + pub async fn get_following( + &self, + local_user_id: uuid::Uuid, + ) -> anyhow::Result> { let data = self.federation_config.to_request_data(); data.federation_repo.get_following(local_user_id).await } @@ -354,9 +395,15 @@ impl ActivityPubService { data.federation_repo.count_following(local_user_id).await } - pub async fn remove_follower(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> { + pub async fn remove_follower( + &self, + local_user_id: uuid::Uuid, + actor_url: &str, + ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); - data.federation_repo.remove_follower(local_user_id, actor_url).await + data.federation_repo + .remove_follower(local_user_id, actor_url) + .await } /// Broadcast a single object to all accepted followers as a Create activity. @@ -395,10 +442,14 @@ impl ActivityPubService { .filter_map(|f| Url::parse(&f.actor.inbox_url).ok()) .collect(); - let sends = SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?; + let sends = + SendActivityTask::prepare(&create_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 activity deliveries failed permanently"); + tracing::warn!( + count = failures.len(), + "some activity deliveries failed permanently" + ); } Ok(()) @@ -423,10 +474,17 @@ impl ActivityPubService { let follower_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string(); let target_actor_url = crate::urls::actor_url(&self.base_url, target.id); let target_inbox_url = format!("{}/inbox", target_actor_url); - let follow_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?.to_string(); + let follow_id = activity_url(&self.base_url) + .map_err(|e| anyhow::anyhow!("{e}"))? + .to_string(); data.federation_repo - .add_follower(target.id, &follower_actor_url, FollowerStatus::Accepted, &follow_id) + .add_follower( + target.id, + &follower_actor_url, + FollowerStatus::Accepted, + &follow_id, + ) .await?; let target_as_remote = RemoteActor { @@ -441,7 +499,11 @@ impl ActivityPubService { .await?; data.federation_repo - .update_following_status(local_user_id, &target_actor_url.to_string(), FollowingStatus::Accepted) + .update_following_status( + local_user_id, + &target_actor_url.to_string(), + FollowingStatus::Accepted, + ) .await?; tracing::info!(follower = %local_user_id, followee = %target.id, "local follow"); @@ -460,8 +522,12 @@ impl ActivityPubService { let local_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string(); - data.federation_repo.remove_follower(target_user_id, &local_actor_url).await?; - data.federation_repo.remove_following(local_user_id, target_actor_url).await?; + data.federation_repo + .remove_follower(target_user_id, &local_actor_url) + .await?; + data.federation_repo + .remove_following(local_user_id, target_actor_url) + .await?; tracing::info!(follower = %local_user_id, followee = %target_user_id, "local unfollow"); Ok(()) @@ -471,7 +537,14 @@ impl ActivityPubService { let config = self.federation_config.clone(); let base_url = self.base_url.clone(); tokio::spawn(async move { - if let Err(e) = ActivityPubService::run_backfill(config, base_url, owner_user_id, follower_inbox_url).await { + if let Err(e) = ActivityPubService::run_backfill( + config, + base_url, + owner_user_id, + follower_inbox_url, + ) + .await + { tracing::warn!(error = %e, "backfill: task failed"); } }); @@ -491,7 +564,10 @@ impl ActivityPubService { .map_err(|e| anyhow::anyhow!("{e}"))?; let inbox = Url::parse(&follower_inbox_url)?; - let mut objects = data.object_handler.get_local_objects_for_user(owner_user_id).await?; + let mut objects = data + .object_handler + .get_local_objects_for_user(owner_user_id) + .await?; objects.reverse(); // oldest first → chronological feed let total = objects.len(); @@ -501,7 +577,9 @@ impl ActivityPubService { for chunk in objects.chunks(BATCH_SIZE) { for (ap_id, object_json) in chunk { // Use a stable Create activity ID derived from the object's ap_id - let create_id = Url::parse(&format!("{}/activities/create/{}", base_url, + let create_id = Url::parse(&format!( + "{}/activities/create/{}", + base_url, uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, ap_id.as_str().as_bytes()) ))?; @@ -517,7 +595,8 @@ impl ActivityPubService { &local_actor, vec![inbox.clone()], &data, - ).await?; + ) + .await?; let failures = send_with_retry(sends, &data).await; if failures.is_empty() { success_count += 1; diff --git a/crates/adapters/activitypub-base/src/webfinger.rs b/crates/adapters/activitypub-base/src/webfinger.rs index 3a5c012..8754287 100644 --- a/crates/adapters/activitypub-base/src/webfinger.rs +++ b/crates/adapters/activitypub-base/src/webfinger.rs @@ -1,6 +1,6 @@ use activitypub_federation::{ config::Data, - fetch::webfinger::{build_webfinger_response, extract_webfinger_name, Webfinger}, + fetch::webfinger::{Webfinger, build_webfinger_response, extract_webfinger_name}, }; use axum::{ extract::Query, @@ -33,10 +33,6 @@ pub async fn webfinger_handler( let ap_id = crate::urls::actor_url(&data.base_url, user.id); let wf: Webfinger = build_webfinger_response(query.resource, ap_id); - let body = serde_json::to_string(&wf) - .map_err(|e| Error::from(anyhow::anyhow!(e)))?; - Ok(( - [(header::CONTENT_TYPE, "application/jrd+json")], - body, - ).into_response()) + let body = serde_json::to_string(&wf).map_err(|e| Error::from(anyhow::anyhow!(e)))?; + Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response()) } diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index 915a56f..72b053e 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -1,11 +1,11 @@ use async_trait::async_trait; +use domain::ports::EventHandler; use domain::{ errors::DomainError, events::DomainEvent, ports::{MovieRepository, ReviewRepository}, value_objects::{ReviewId, UserId}, }; -use domain::ports::EventHandler; use std::sync::Arc; use activitypub_base::ActivityPubService; @@ -27,7 +27,12 @@ impl ActivityPubEventHandler { review_repository: Arc, base_url: String, ) -> Self { - Self { ap_service, movie_repository, review_repository, base_url } + Self { + ap_service, + movie_repository, + review_repository, + base_url, + } } } @@ -35,7 +40,9 @@ 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())), @@ -45,11 +52,7 @@ impl EventHandler for ActivityPubEventHandler { } impl ActivityPubEventHandler { - async fn on_review_logged( - &self, - user_id: &UserId, - review_id: &ReviewId, - ) -> anyhow::Result<()> { + async fn on_review_logged(&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(()), @@ -58,16 +61,33 @@ impl ActivityPubEventHandler { let ap_id = review_url(&self.base_url, review_id); let actor = actor_url(&self.base_url, user_id.value()); - let movie = self.movie_repository.get_movie_by_id(review.movie_id()).await.ok().flatten(); - let movie_title = movie.as_ref() + let movie = self + .movie_repository + .get_movie_by_id(review.movie_id()) + .await + .ok() + .flatten(); + let movie_title = movie + .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 poster_url = movie.as_ref() + 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()) .map(|p| format!("{}/posters/{}", self.base_url, p.value())); - let obj = review_to_ap_object(&review, ap_id.clone(), actor, movie_title, release_year, poster_url); + let obj = review_to_ap_object( + &review, + ap_id.clone(), + actor, + movie_title, + release_year, + poster_url, + ); let json = serde_json::to_value(obj)?; self.ap_service diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index c33a413..0139a3e 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -3,8 +3,8 @@ pub mod objects; pub mod port; pub mod remote_review_repository; pub mod review_handler; -pub mod user_adapter; pub(crate) mod urls; +pub mod user_adapter; // Re-export the generic base types that callers need pub use activitypub_base::{ diff --git a/crates/adapters/activitypub/src/objects.rs b/crates/adapters/activitypub/src/objects.rs index f974b9e..4ba6bb5 100644 --- a/crates/adapters/activitypub/src/objects.rs +++ b/crates/adapters/activitypub/src/objects.rs @@ -36,10 +36,17 @@ pub fn review_to_ap_object( ) -> ReviewObject { let stars: String = "\u{2B50}".repeat(review.rating().value() as usize); let comment_text = review.comment().map(|c| c.value().to_string()); - let year_str = if release_year > 0 { format!(" ({})", release_year) } else { String::new() }; + let year_str = if release_year > 0 { + format!(" ({})", release_year) + } else { + String::new() + }; let watched_str = format!("Watched: {}", review.watched_at().format("%b %-d, %Y")); let content = match &comment_text { - Some(c) => format!("{} {}{}\n{}\n{}", stars, movie_title, year_str, c, watched_str), + Some(c) => format!( + "{} {}{}\n{}\n{}", + stars, movie_title, year_str, c, watched_str + ), None => format!("{} {}{}\n{}", stars, movie_title, year_str, watched_str), }; diff --git a/crates/adapters/activitypub/src/port.rs b/crates/adapters/activitypub/src/port.rs index 7e9484d..65c1521 100644 --- a/crates/adapters/activitypub/src/port.rs +++ b/crates/adapters/activitypub/src/port.rs @@ -11,10 +11,19 @@ pub trait ActivityPubPort: Send + Sync { async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result>; async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()>; async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>; - async fn accept_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()>; - async fn reject_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()>; + async fn accept_follower( + &self, + local_user_id: Uuid, + remote_actor_url: &str, + ) -> anyhow::Result<()>; + async fn reject_follower( + &self, + local_user_id: Uuid, + remote_actor_url: &str, + ) -> anyhow::Result<()>; async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result>; - async fn get_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result>; + async fn get_accepted_followers(&self, local_user_id: Uuid) + -> anyhow::Result>; async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>; } @@ -38,16 +47,27 @@ impl ActivityPubPort for ActivityPubService { async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> { self.unfollow(local_user_id, actor_url).await } - async fn accept_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()> { + async fn accept_follower( + &self, + local_user_id: Uuid, + remote_actor_url: &str, + ) -> anyhow::Result<()> { self.accept_follower(local_user_id, remote_actor_url).await } - async fn reject_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()> { + async fn reject_follower( + &self, + local_user_id: Uuid, + remote_actor_url: &str, + ) -> anyhow::Result<()> { self.reject_follower(local_user_id, remote_actor_url).await } async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result> { self.get_following(local_user_id).await } - async fn get_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result> { + async fn get_accepted_followers( + &self, + local_user_id: Uuid, + ) -> anyhow::Result> { self.get_accepted_followers(local_user_id).await } async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> { @@ -59,15 +79,37 @@ pub struct NoopActivityPubService; #[async_trait] impl ActivityPubPort for NoopActivityPubService { - async fn actor_json(&self, _: &str) -> anyhow::Result { Ok(String::new()) } - async fn count_following(&self, _: Uuid) -> anyhow::Result { Ok(0) } - async fn count_accepted_followers(&self, _: Uuid) -> anyhow::Result { Ok(0) } - async fn get_pending_followers(&self, _: Uuid) -> anyhow::Result> { Ok(vec![]) } - async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) } - async fn unfollow(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) } - async fn accept_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) } - async fn reject_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) } - async fn get_following(&self, _: Uuid) -> anyhow::Result> { Ok(vec![]) } - async fn get_accepted_followers(&self, _: Uuid) -> anyhow::Result> { Ok(vec![]) } - async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) } + async fn actor_json(&self, _: &str) -> anyhow::Result { + Ok(String::new()) + } + async fn count_following(&self, _: Uuid) -> anyhow::Result { + Ok(0) + } + async fn count_accepted_followers(&self, _: Uuid) -> anyhow::Result { + Ok(0) + } + async fn get_pending_followers(&self, _: Uuid) -> anyhow::Result> { + Ok(vec![]) + } + async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> { + Ok(()) + } + async fn unfollow(&self, _: Uuid, _: &str) -> anyhow::Result<()> { + Ok(()) + } + async fn accept_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { + Ok(()) + } + async fn reject_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { + Ok(()) + } + async fn get_following(&self, _: Uuid) -> anyhow::Result> { + Ok(vec![]) + } + async fn get_accepted_followers(&self, _: Uuid) -> anyhow::Result> { + Ok(vec![]) + } + async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { + Ok(()) + } } diff --git a/crates/adapters/activitypub/src/review_handler.rs b/crates/adapters/activitypub/src/review_handler.rs index 47f5bb1..629327b 100644 --- a/crates/adapters/activitypub/src/review_handler.rs +++ b/crates/adapters/activitypub/src/review_handler.rs @@ -9,7 +9,7 @@ use domain::{ }; use url::Url; -use crate::objects::{review_to_ap_object, ReviewObject}; +use crate::objects::{ReviewObject, review_to_ap_object}; use crate::remote_review_repository::RemoteReviewRepository; use crate::urls::{actor_url, review_url}; @@ -27,7 +27,10 @@ impl ApObjectHandler for ReviewObjectHandler { user_id: uuid::Uuid, ) -> anyhow::Result> { let domain_user_id = UserId::from_uuid(user_id); - let history = self.diary_repository.get_user_history(&domain_user_id).await?; + let history = self + .diary_repository + .get_user_history(&domain_user_id) + .await?; let mut results = Vec::new(); for entry in history { @@ -39,18 +42,33 @@ impl ApObjectHandler for ReviewObjectHandler { let ap_id = review_url(&self.base_url, review.id()); let actor_url = actor_url(&self.base_url, user_id); - let movie = self.movie_repository.get_movie_by_id(review.movie_id()).await.ok().flatten(); - let movie_title = movie.as_ref() + let movie = self + .movie_repository + .get_movie_by_id(review.movie_id()) + .await + .ok() + .flatten(); + let movie_title = movie + .as_ref() .map(|m| m.title().value().to_string()) .unwrap_or_else(|| "Unknown".to_string()); - let release_year = movie.as_ref() + let release_year = movie + .as_ref() .map(|m| m.release_year().value()) .unwrap_or(0); - let poster_url = movie.as_ref() + let poster_url = movie + .as_ref() .and_then(|m| m.poster_path()) .map(|p| format!("{}/posters/{}", self.base_url, p.value())); - let obj = review_to_ap_object(review, ap_id.clone(), actor_url, movie_title, release_year, poster_url); + let obj = review_to_ap_object( + review, + ap_id.clone(), + actor_url, + movie_title, + release_year, + poster_url, + ); let json = serde_json::to_value(obj)?; results.push((ap_id, json)); } @@ -73,8 +91,14 @@ impl ApObjectHandler for ReviewObjectHandler { let actor_url_str = obj.attributed_to.to_string(); let review_id = ReviewId::generate(); - let movie_id = MovieId::from_uuid(uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, obj.movie_title.as_bytes())); - let user_id = UserId::from_uuid(uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, actor_url_str.as_bytes())); + let movie_id = MovieId::from_uuid(uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_URL, + obj.movie_title.as_bytes(), + )); + let user_id = UserId::from_uuid(uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_URL, + actor_url_str.as_bytes(), + )); let rating = Rating::new(obj.rating.min(5))?; let comment = obj.comment.map(Comment::new).transpose()?; @@ -86,11 +110,19 @@ impl ApObjectHandler for ReviewObjectHandler { comment, obj.watched_at.naive_utc(), obj.published.naive_utc(), - ReviewSource::Remote { actor_url: actor_url_str }, + ReviewSource::Remote { + actor_url: actor_url_str, + }, ); self.review_store - .save_remote_review(&review, obj.id.as_str(), &obj.movie_title, obj.release_year, obj.poster_url.as_deref()) + .save_remote_review( + &review, + obj.id.as_str(), + &obj.movie_title, + obj.release_year, + obj.poster_url.as_deref(), + ) .await?; Ok(()) diff --git a/crates/adapters/activitypub/src/urls.rs b/crates/adapters/activitypub/src/urls.rs index ce47ac4..abde28f 100644 --- a/crates/adapters/activitypub/src/urls.rs +++ b/crates/adapters/activitypub/src/urls.rs @@ -1,5 +1,5 @@ -use url::Url; use domain::value_objects::ReviewId; +use url::Url; /// Builds the canonical actor URL: `{base_url}/users/{user_id}` pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url { diff --git a/crates/adapters/activitypub/src/user_adapter.rs b/crates/adapters/activitypub/src/user_adapter.rs index bd4557c..a4e9431 100644 --- a/crates/adapters/activitypub/src/user_adapter.rs +++ b/crates/adapters/activitypub/src/user_adapter.rs @@ -18,8 +18,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()))?; Ok(self.0.find_by_username(&uname).await?.map(|u| ApUser { id: u.id().value(), username: u.username().value().to_string(), diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs index 10e7dbb..1805254 100644 --- a/crates/adapters/auth/src/lib.rs +++ b/crates/adapters/auth/src/lib.rs @@ -1,8 +1,8 @@ -use async_trait::async_trait; use argon2::{ Argon2, password_hash::{PasswordHasher as _, PasswordVerifier, SaltString}, }; +use async_trait::async_trait; use chrono::{Duration, Utc}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; use rand_core::OsRng; @@ -31,7 +31,10 @@ impl AuthConfig { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(86400u64); - Ok(Self { secret, ttl_seconds }) + Ok(Self { + secret, + ttl_seconds, + }) } } diff --git a/crates/adapters/event-publisher/src/lib.rs b/crates/adapters/event-publisher/src/lib.rs index 3ed2c2c..1b2b5b3 100644 --- a/crates/adapters/event-publisher/src/lib.rs +++ b/crates/adapters/event-publisher/src/lib.rs @@ -120,12 +120,12 @@ pub fn create_event_channel( #[cfg(test)] mod tests { use super::*; - use std::sync::{Arc, Mutex}; use domain::{ errors::DomainError, events::DomainEvent, value_objects::{ExternalMetadataId, MovieId}, }; + use std::sync::{Arc, Mutex}; struct RecordingHandler { calls: Arc>>, @@ -147,7 +147,9 @@ mod tests { #[tokio::test] async fn single_handler_receives_event() { let calls = Arc::new(Mutex::new(vec![])); - let handler = RecordingHandler { calls: Arc::clone(&calls) }; + let handler = RecordingHandler { + calls: Arc::clone(&calls), + }; let config = EventPublisherConfig { channel_buffer: 8 }; let (publisher, worker) = create_event_channel(config, vec![Box::new(handler)]); @@ -168,13 +170,15 @@ mod tests { async fn multiple_handlers_all_receive_event() { let calls1 = Arc::new(Mutex::new(vec![])); let calls2 = Arc::new(Mutex::new(vec![])); - let handler1 = RecordingHandler { calls: Arc::clone(&calls1) }; - let handler2 = RecordingHandler { calls: Arc::clone(&calls2) }; + let handler1 = RecordingHandler { + calls: Arc::clone(&calls1), + }; + let handler2 = RecordingHandler { + calls: Arc::clone(&calls2), + }; let config = EventPublisherConfig { channel_buffer: 8 }; - let (publisher, worker) = create_event_channel( - config, - vec![Box::new(handler1), Box::new(handler2)], - ); + let (publisher, worker) = + create_event_channel(config, vec![Box::new(handler1), Box::new(handler2)]); let handle = tokio::spawn(worker.run()); @@ -201,12 +205,12 @@ mod tests { } let calls = Arc::new(Mutex::new(vec![])); - let good = RecordingHandler { calls: Arc::clone(&calls) }; + let good = RecordingHandler { + calls: Arc::clone(&calls), + }; let config = EventPublisherConfig { channel_buffer: 8 }; - let (publisher, worker) = create_event_channel( - config, - vec![Box::new(FailingHandler), Box::new(good)], - ); + let (publisher, worker) = + create_event_channel(config, vec![Box::new(FailingHandler), Box::new(good)]); let handle = tokio::spawn(worker.run()); diff --git a/crates/adapters/export/Cargo.toml b/crates/adapters/export/Cargo.toml new file mode 100644 index 0000000..85b3a80 --- /dev/null +++ b/crates/adapters/export/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "export" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } + +[dev-dependencies] +uuid = { workspace = true } +tokio = { workspace = true } diff --git a/crates/adapters/export/src/lib.rs b/crates/adapters/export/src/lib.rs new file mode 100644 index 0000000..d712ac6 --- /dev/null +++ b/crates/adapters/export/src/lib.rs @@ -0,0 +1,225 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{DiaryEntry, ExportFormat}, + ports::DiaryExporter, +}; + +pub struct ExportAdapter; + +#[async_trait] +impl DiaryExporter for ExportAdapter { + async fn serialize_entries( + &self, + entries: &[DiaryEntry], + format: ExportFormat, + ) -> Result, DomainError> { + match format { + ExportFormat::Csv => serialize_csv(entries), + ExportFormat::Json => serialize_json(entries), + } + } +} + +fn serialize_csv(entries: &[DiaryEntry]) -> Result, DomainError> { + let mut out = + String::from("title,year,director,rating,comment,watched_at,external_metadata_id\n"); + for e in entries { + let title = csv_escape(e.movie().title().value()); + let year = e.movie().release_year().value(); + let director = e.movie().director().map(csv_escape).unwrap_or_default(); + let rating = e.review().rating().value(); + let comment = e + .review() + .comment() + .map(|c| csv_escape(c.value())) + .unwrap_or_default(); + let watched_at = e.review().watched_at().format("%Y-%m-%d"); + let ext_id = e + .movie() + .external_metadata_id() + .map(|id| id.value().to_string()) + .unwrap_or_default(); + out.push_str(&format!( + "{},{},{},{},{},{},{}\n", + title, year, director, rating, comment, watched_at, ext_id + )); + } + Ok(out.into_bytes()) +} + +fn csv_escape(s: &str) -> String { + if s.contains(',') || s.contains('"') || s.contains('\n') { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } +} + +fn serialize_json(entries: &[DiaryEntry]) -> Result, DomainError> { + let arr: Vec = entries + .iter() + .map(|e| { + serde_json::json!({ + "title": e.movie().title().value(), + "year": e.movie().release_year().value(), + "director": e.movie().director(), + "rating": e.review().rating().value(), + "comment": e.review().comment().map(|c| c.value()), + "watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(), + "external_metadata_id": e.movie().external_metadata_id().map(|id| id.value()), + }) + }) + .collect(); + serde_json::to_vec_pretty(&arr).map_err(|e| DomainError::InfrastructureError(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::ExportAdapter; + use domain::{ + models::{DiaryEntry, ExportFormat, Movie, Review}, + ports::DiaryExporter, + value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear}, + }; + + fn make_entry( + title: &str, + year: u16, + director: Option<&str>, + rating: u8, + comment: Option<&str>, + ) -> DiaryEntry { + make_entry_full(title, year, director, rating, comment, None) + } + + fn make_entry_full( + title: &str, + year: u16, + director: Option<&str>, + rating: u8, + comment: Option<&str>, + external_id: Option<&str>, + ) -> DiaryEntry { + let movie = Movie::new( + external_id.map(|id| ExternalMetadataId::new(id.to_string()).unwrap()), + MovieTitle::new(title.to_string()).unwrap(), + ReleaseYear::new(year).unwrap(), + director.map(str::to_string), + None, + ); + let user_id = domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4()); + let review = Review::new( + movie.id().clone(), + user_id, + Rating::new(rating).unwrap(), + comment.map(|c| domain::value_objects::Comment::new(c.to_string()).unwrap()), + chrono::NaiveDate::from_ymd_opt(2024, 3, 15) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(), + ) + .unwrap(); + DiaryEntry::new(movie, review) + } + + #[tokio::test] + async fn csv_has_header_and_one_row() { + let adapter = ExportAdapter; + let entry = make_entry( + "Inception", + 2010, + Some("Christopher Nolan"), + 5, + Some("great"), + ); + let bytes = adapter + .serialize_entries(&[entry], ExportFormat::Csv) + .await + .unwrap(); + let text = String::from_utf8(bytes).unwrap(); + assert!( + text.starts_with( + "title,year,director,rating,comment,watched_at,external_metadata_id\n" + ) + ); + assert!(text.contains("Inception")); + assert!(text.contains("2010")); + assert!(text.contains("Christopher Nolan")); + assert!(text.contains("5")); + assert!(text.contains("great")); + assert!(text.contains("2024-03-15")); + } + + #[tokio::test] + async fn csv_escapes_commas_in_title() { + let adapter = ExportAdapter; + let entry = make_entry("Tár, A Film", 2022, None, 4, None); + let bytes = adapter + .serialize_entries(&[entry], ExportFormat::Csv) + .await + .unwrap(); + let text = String::from_utf8(bytes).unwrap(); + assert!(text.contains("\"Tár, A Film\"")); + } + + #[tokio::test] + async fn json_is_valid_array() { + let adapter = ExportAdapter; + let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None); + let bytes = adapter + .serialize_entries(&[entry], ExportFormat::Json) + .await + .unwrap(); + let arr: Vec = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["title"], "Dune"); + assert_eq!(arr[0]["year"], 2021); + assert_eq!(arr[0]["rating"], 5); + assert_eq!(arr[0]["comment"], serde_json::Value::Null); + assert_eq!(arr[0]["external_metadata_id"], serde_json::Value::Null); + } + + #[tokio::test] + async fn external_metadata_id_included_when_present() { + let adapter = ExportAdapter; + let entry = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748")); + let bytes = adapter + .serialize_entries(&[entry], ExportFormat::Json) + .await + .unwrap(); + let arr: Vec = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(arr[0]["external_metadata_id"], "tt0078748"); + + let bytes = adapter + .serialize_entries( + &[make_entry_full( + "Alien", + 1979, + None, + 5, + None, + Some("tt0078748"), + )], + ExportFormat::Csv, + ) + .await + .unwrap(); + let text = String::from_utf8(bytes).unwrap(); + assert!(text.contains("tt0078748")); + } + + #[tokio::test] + async fn empty_entries_returns_csv_header_only() { + let adapter = ExportAdapter; + let bytes = adapter + .serialize_entries(&[], ExportFormat::Csv) + .await + .unwrap(); + let text = String::from_utf8(bytes).unwrap(); + assert_eq!( + text, + "title,year,director,rating,comment,watched_at,external_metadata_id\n" + ); + } +} diff --git a/crates/adapters/metadata/src/lib.rs b/crates/adapters/metadata/src/lib.rs index cad0ec6..92d5d5c 100644 --- a/crates/adapters/metadata/src/lib.rs +++ b/crates/adapters/metadata/src/lib.rs @@ -40,7 +40,13 @@ impl MetadataClient for MetadataClientImpl { criteria: &MetadataSearchCriteria, ) -> Result { let pm = self.provider.fetch(criteria).await?; - Ok(Movie::new(Some(pm.imdb_id), pm.title, pm.release_year, pm.director, None)) + Ok(Movie::new( + Some(pm.imdb_id), + pm.title, + pm.release_year, + pm.director, + None, + )) } async fn get_poster_url( diff --git a/crates/adapters/metadata/src/omdb.rs b/crates/adapters/metadata/src/omdb.rs index 74f530a..9d5d039 100644 --- a/crates/adapters/metadata/src/omdb.rs +++ b/crates/adapters/metadata/src/omdb.rs @@ -101,8 +101,8 @@ impl MetadataProvider for OmdbProvider { .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; let title = MovieTitle::new(resp.title) .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let release_year = ReleaseYear::new(year) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + let release_year = + ReleaseYear::new(year).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; let director = match resp.director.as_str() { "N/A" | "" => None, @@ -114,6 +114,12 @@ impl MetadataProvider for OmdbProvider { url => PosterUrl::new(url.to_string()).ok(), }; - Ok(ProviderMovie { imdb_id, title, release_year, director, poster_url }) + Ok(ProviderMovie { + imdb_id, + title, + release_year, + director, + poster_url, + }) } } diff --git a/crates/adapters/poster-storage/src/config.rs b/crates/adapters/poster-storage/src/config.rs index e78cff7..5721eaf 100644 --- a/crates/adapters/poster-storage/src/config.rs +++ b/crates/adapters/poster-storage/src/config.rs @@ -22,9 +22,9 @@ impl StorageConfig { &std::env::var("POSTER_STORAGE_PATH") .context("POSTER_STORAGE_PATH required when POSTER_STORAGE_BACKEND=local")?, )?, - other => anyhow::bail!( - "Unknown POSTER_STORAGE_BACKEND: {other:?}. Valid values: s3, local" - ), + other => { + anyhow::bail!("Unknown POSTER_STORAGE_BACKEND: {other:?}. Valid values: s3, local") + } }; Ok(Self(store)) @@ -55,8 +55,7 @@ fn build_s3_store( } fn build_local_store(path: &str) -> anyhow::Result> { - std::fs::create_dir_all(path) - .context("Failed to create poster storage directory")?; + std::fs::create_dir_all(path).context("Failed to create poster storage directory")?; let store = LocalFileSystem::new_with_prefix(path) .context("Failed to initialise local file system store")?; Ok(Arc::new(store)) @@ -68,8 +67,7 @@ mod tests { #[test] fn local_store_creates_dir_and_succeeds() { - let dir = std::env::temp_dir() - .join(format!("poster_test_{}", uuid::Uuid::new_v4())); + let dir = std::env::temp_dir().join(format!("poster_test_{}", uuid::Uuid::new_v4())); let result = build_local_store(dir.to_str().unwrap()); assert!(result.is_ok(), "expected Ok, got: {:?}", result.err()); assert!(dir.exists(), "directory should have been created"); @@ -77,8 +75,7 @@ mod tests { #[test] fn local_store_succeeds_if_dir_already_exists() { - let dir = std::env::temp_dir() - .join(format!("poster_test_{}", uuid::Uuid::new_v4())); + let dir = std::env::temp_dir().join(format!("poster_test_{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); let result = build_local_store(dir.to_str().unwrap()); assert!(result.is_ok()); diff --git a/crates/adapters/poster-storage/src/lib.rs b/crates/adapters/poster-storage/src/lib.rs index a0b5896..6910c27 100644 --- a/crates/adapters/poster-storage/src/lib.rs +++ b/crates/adapters/poster-storage/src/lib.rs @@ -7,7 +7,7 @@ use domain::{ ports::PosterStorage, value_objects::{MovieId, PosterPath}, }; -use object_store::{Attribute, Attributes, PutOptions, path::Path, ObjectStore}; +use object_store::{Attribute, Attributes, ObjectStore, PutOptions, path::Path}; use std::sync::Arc; fn detect_mime(bytes: &[u8]) -> &'static str { @@ -41,7 +41,10 @@ impl PosterStorage for PosterStorageAdapter { let mime = detect_mime(image_bytes); let mut attributes = Attributes::new(); attributes.insert(Attribute::ContentType, mime.into()); - let opts = PutOptions { attributes, ..Default::default() }; + let opts = PutOptions { + attributes, + ..Default::default() + }; self.store .put_opts(&path, image_bytes.to_vec().into(), opts) .await @@ -52,7 +55,9 @@ impl PosterStorage for PosterStorageAdapter { async fn get_poster(&self, poster_path: &PosterPath) -> Result, DomainError> { let path = Path::from(poster_path.value().to_string()); let result = self.store.get(&path).await.map_err(|e| match e { - object_store::Error::NotFound { .. } => DomainError::NotFound("Poster not found".into()), + object_store::Error::NotFound { .. } => { + DomainError::NotFound("Poster not found".into()) + } _ => DomainError::InfrastructureError(e.to_string()), })?; result diff --git a/crates/adapters/sqlite-federation/src/lib.rs b/crates/adapters/sqlite-federation/src/lib.rs index a5d0f49..7f8d45d 100644 --- a/crates/adapters/sqlite-federation/src/lib.rs +++ b/crates/adapters/sqlite-federation/src/lib.rs @@ -1,10 +1,12 @@ -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use async_trait::async_trait; use chrono::{NaiveDateTime, Utc}; use sqlx::{Row, SqlitePool}; -use activitypub_base::{FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor}; use activitypub::RemoteReviewRepository; +use activitypub_base::{ + FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, +}; use domain::models::{Review, ReviewSource}; fn datetime_to_str(dt: &NaiveDateTime) -> String { @@ -85,7 +87,11 @@ impl FederationRepository for SqliteFederationRepository { Ok(row.flatten()) } - async fn remove_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<()> { + async fn remove_follower( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> Result<()> { let uid = local_user_id.to_string(); sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?") .bind(&uid) @@ -116,11 +122,18 @@ 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(); Follower { - actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name }, + actor: RemoteActor { + url, + handle, + inbox_url, + shared_inbox_url, + display_name, + }, status: str_to_status(&status_str), } }) @@ -154,7 +167,12 @@ impl FederationRepository for SqliteFederationRepository { Ok(()) } - async fn add_following(&self, local_user_id: uuid::Uuid, actor: RemoteActor, follow_activity_id: &str) -> Result<()> { + async fn add_following( + &self, + local_user_id: uuid::Uuid, + actor: RemoteActor, + follow_activity_id: &str, + ) -> Result<()> { let uid = local_user_id.to_string(); let now = Utc::now().naive_utc(); let created_at = datetime_to_str(&now); @@ -175,7 +193,11 @@ impl FederationRepository for SqliteFederationRepository { Ok(()) } - async fn get_follow_activity_id(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result> { + async fn get_follow_activity_id( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> Result> { let uid = local_user_id.to_string(); let row: Option> = sqlx::query_scalar( "SELECT follow_activity_id FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?", @@ -210,13 +232,16 @@ impl FederationRepository for SqliteFederationRepository { .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(), - }).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(), + }) + .collect()) } async fn count_following(&self, local_user_id: uuid::Uuid) -> Result { @@ -274,16 +299,25 @@ impl FederationRepository for SqliteFederationRepository { })) } - 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 = ?") - .bind(&uid) - .fetch_optional(&self.pool) - .await?; + let row = + sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?") + .bind(&uid) + .fetch_optional(&self.pool) + .await?; Ok(row.map(|r| (r.get("public_key"), r.get("private_key")))) } - async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, public_key: String, private_key: String) -> Result<()> { + async fn save_local_actor_keypair( + &self, + user_id: uuid::Uuid, + public_key: String, + private_key: String, + ) -> Result<()> { let uid = user_id.to_string(); let now = Utc::now().naive_utc(); let created_at = datetime_to_str(&now); @@ -319,13 +353,16 @@ impl FederationRepository for SqliteFederationRepository { .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(), - }).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(), + }) + .collect()) } async fn update_following_status( diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 22ca88d..4a83445 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -3,8 +3,8 @@ use domain::{ errors::DomainError, events::DomainEvent, models::{ - DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, Movie, MonthlyRating, - Review, ReviewHistory, ReviewSource, SortDirection, UserStats, UserTrends, + DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, MonthlyRating, Movie, Review, + ReviewHistory, ReviewSource, SortDirection, UserStats, UserTrends, collections::{PageParams, Paginated}, }, ports::{DiaryRepository, MovieRepository, ReviewRepository, StatsRepository}, @@ -17,20 +17,31 @@ mod models; mod users; use models::{ - DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, ReviewRow, - UserTotalsRow, datetime_to_str, + DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, ReviewRow, UserTotalsRow, + datetime_to_str, }; pub use users::SqliteUserRepository; fn format_year_month(ym: &str) -> String { let parts: Vec<&str> = ym.splitn(2, '-').collect(); - if parts.len() != 2 { return ym.to_string(); } + if parts.len() != 2 { + return ym.to_string(); + } let year = parts[0].get(2..).unwrap_or(parts[0]); let month = match parts[1] { - "01" => "Jan", "02" => "Feb", "03" => "Mar", "04" => "Apr", - "05" => "May", "06" => "Jun", "07" => "Jul", "08" => "Aug", - "09" => "Sep", "10" => "Oct", "11" => "Nov", "12" => "Dec", + "01" => "Jan", + "02" => "Feb", + "03" => "Mar", + "04" => "Apr", + "05" => "May", + "06" => "Jun", + "07" => "Jul", + "08" => "Aug", + "09" => "Sep", + "10" => "Oct", + "11" => "Nov", + "12" => "Dec", _ => parts[1], }; format!("{} '{}", month, year) @@ -60,12 +71,10 @@ impl SqliteMovieRepository { .fetch_one(&self.pool) .await .map_err(Self::map_err), - Some(id) => { - sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE movie_id = ?", id) - .fetch_one(&self.pool) - .await - .map_err(Self::map_err) - } + Some(id) => sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE movie_id = ?", id) + .fetch_one(&self.pool) + .await + .map_err(Self::map_err), } } @@ -155,13 +164,10 @@ impl SqliteMovieRepository { } async fn count_user_diary_entries(&self, user_id: &str) -> Result { - sqlx::query_scalar!( - "SELECT COUNT(*) FROM reviews WHERE user_id = ?", - user_id - ) - .fetch_one(&self.pool) - .await - .map_err(Self::map_err) + sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE user_id = ?", user_id) + .fetch_one(&self.pool) + .await + .map_err(Self::map_err) } async fn fetch_user_diary_rows_by_watched( @@ -215,11 +221,7 @@ impl SqliteMovieRepository { .map_err(Self::map_err) } - async fn fetch_feed_rows( - &self, - limit: i64, - offset: i64, - ) -> Result, DomainError> { + async fn fetch_feed_rows(&self, limit: i64, offset: i64) -> Result, DomainError> { sqlx::query_as!( FeedRow, r#"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, @@ -451,11 +453,21 @@ impl ReviewRepository for SqliteMovieRepository { .map_err(Self::map_err)?; Ok(()) } + + async fn get_all_reviews_for_user( + &self, + _user_id: &UserId, + ) -> Result, DomainError> { + todo!() + } } #[async_trait] impl DiaryRepository for SqliteMovieRepository { - async fn query_diary(&self, filter: &DiaryFilter) -> Result, DomainError> { + async fn query_diary( + &self, + filter: &DiaryFilter, + ) -> Result, DomainError> { let limit = filter.page.limit as i64; let offset = filter.page.offset as i64; @@ -647,9 +659,16 @@ impl StatsRepository for SqliteMovieRepository { let top_directors = director_rows .into_iter() - .map(|d| DirectorStat { director: d.director, count: d.count }) + .map(|d| DirectorStat { + director: d.director, + count: d.count, + }) .collect(); - Ok(UserTrends { monthly_ratings, top_directors, max_director_count }) + Ok(UserTrends { + monthly_ratings, + top_directors, + max_director_count, + }) } } diff --git a/crates/adapters/sqlite/src/users.rs b/crates/adapters/sqlite/src/users.rs index fdd03d6..7018852 100644 --- a/crates/adapters/sqlite/src/users.rs +++ b/crates/adapters/sqlite/src/users.rs @@ -2,20 +2,22 @@ use async_trait::async_trait; use chrono::Utc; use sqlx::SqlitePool; +use super::models::UserSummaryRow; use domain::{ errors::DomainError, models::User, ports::UserRepository, value_objects::{Email, PasswordHash, UserId, Username}, }; -use super::models::UserSummaryRow; pub struct SqliteUserRepository { pool: SqlitePool, } impl SqliteUserRepository { - 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!("Database error: {:?}", e); @@ -30,13 +32,18 @@ impl SqliteUserRepository { ) -> 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) .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(User::from_persistence(UserId::from_uuid(id), email, username, hash)) + Ok(User::from_persistence( + UserId::from_uuid(id), + email, + username, + hash, + )) } } @@ -52,8 +59,15 @@ impl UserRepository for SqliteUserRepository { .await .map_err(Self::map_err)?; - row.map(|r| Self::row_to_user(r.id.unwrap_or_default(), r.email, r.username, r.password_hash)) - .transpose() + row.map(|r| { + Self::row_to_user( + r.id.unwrap_or_default(), + r.email, + r.username, + r.password_hash, + ) + }) + .transpose() } async fn find_by_username(&self, username: &Username) -> Result, DomainError> { @@ -66,18 +80,29 @@ impl UserRepository for SqliteUserRepository { .await .map_err(Self::map_err)?; - row.map(|r| Self::row_to_user(r.id.unwrap_or_default(), r.email, r.username, r.password_hash)) - .transpose() + row.map(|r| { + Self::row_to_user( + r.id.unwrap_or_default(), + r.email, + r.username, + r.password_hash, + ) + }) + .transpose() } async fn save(&self, user: &User) -> Result<(), DomainError> { // Check email uniqueness first (clearer error than INSERT OR IGNORE) if self.find_by_email(user.email()).await?.is_some() { - return Err(DomainError::ValidationError("Email already registered".into())); + return Err(DomainError::ValidationError( + "Email already registered".into(), + )); } // Check username uniqueness if self.find_by_username(user.username()).await?.is_some() { - return Err(DomainError::ValidationError("Username already taken".into())); + return Err(DomainError::ValidationError( + "Username already taken".into(), + )); } let id = user.id().value().to_string(); @@ -107,8 +132,15 @@ impl UserRepository for SqliteUserRepository { .await .map_err(Self::map_err)?; - row.map(|r| Self::row_to_user(r.id.unwrap_or_default(), r.email, r.username, r.password_hash)) - .transpose() + row.map(|r| { + Self::row_to_user( + r.id.unwrap_or_default(), + r.email, + r.username, + r.password_hash, + ) + }) + .transpose() } async fn list_with_stats(&self) -> Result, DomainError> { @@ -175,10 +207,7 @@ mod tests { .await .unwrap(); - let result = repo - .find_by_id(&UserId::from_uuid(id)) - .await - .unwrap(); + let result = repo.find_by_id(&UserId::from_uuid(id)).await.unwrap(); assert!(result.is_some()); assert_eq!(result.unwrap().email().value(), "test@example.com"); } diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index 2249a88..60a0f98 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -1,12 +1,12 @@ -use askama::Template; -use chrono::Datelike; use application::ports::{ ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, LoginPageData, NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData, }; +use askama::Template; +use chrono::Datelike; use domain::models::{ - DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, - UserTrends, collections::Paginated, + DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends, + collections::Paginated, }; struct PageItem { @@ -31,9 +31,17 @@ fn build_page_items(total_pages: u32, current_page: u32) -> Vec { let mut items = Vec::new(); for (i, &p) in pages.iter().enumerate() { if i > 0 && p > pages[i - 1] + 1 { - items.push(PageItem { number: 0, is_current: false, is_ellipsis: true }); + items.push(PageItem { + number: 0, + is_current: false, + is_ellipsis: true, + }); } - items.push(PageItem { number: p, is_current: p == current_page, is_ellipsis: false }); + items.push(PageItem { + number: p, + is_current: p == current_page, + is_ellipsis: false, + }); } items } @@ -162,40 +170,71 @@ struct HeatmapCell { fn relative_time(dt: chrono::NaiveDateTime) -> String { let now = chrono::Utc::now().naive_utc(); let diff = now.signed_duration_since(dt); - if diff.num_seconds() <= 0 { return "just now".to_string(); } + if diff.num_seconds() <= 0 { + return "just now".to_string(); + } let minutes = diff.num_minutes(); let hours = diff.num_hours(); let days = diff.num_days(); - if minutes < 1 { return "just now".to_string(); } - if minutes < 60 { return format!("{} min ago", minutes); } - if hours < 24 { return format!("{} h ago", hours); } - if days == 1 { return "yesterday".to_string(); } - if days < 30 { return format!("{} days ago", days); } + if minutes < 1 { + return "just now".to_string(); + } + if minutes < 60 { + return format!("{} min ago", minutes); + } + if hours < 24 { + return format!("{} h ago", hours); + } + if days == 1 { + return "yesterday".to_string(); + } + if days < 30 { + return format!("{} days ago", days); + } dt.format("%b %-d, %Y").to_string() } fn build_heatmap(history: &[MonthActivity]) -> Vec { let current_year = chrono::Utc::now().year(); let count_for = |m: &str| -> i64 { - history.iter().find(|a| a.year_month == format!("{}-{}", current_year, m)) + history + .iter() + .find(|a| a.year_month == format!("{}-{}", current_year, m)) .map(|a| a.count) .unwrap_or(0) }; let months = [ - ("01", "Jan"), ("02", "Feb"), ("03", "Mar"), ("04", "Apr"), - ("05", "May"), ("06", "Jun"), ("07", "Jul"), ("08", "Aug"), - ("09", "Sep"), ("10", "Oct"), ("11", "Nov"), ("12", "Dec"), + ("01", "Jan"), + ("02", "Feb"), + ("03", "Mar"), + ("04", "Apr"), + ("05", "May"), + ("06", "Jun"), + ("07", "Jul"), + ("08", "Aug"), + ("09", "Sep"), + ("10", "Oct"), + ("11", "Nov"), + ("12", "Dec"), ]; let counts: Vec = months.iter().map(|(m, _)| count_for(m)).collect(); let max = counts.iter().copied().max().unwrap_or(0).max(1); - months.iter().zip(counts.iter()).map(|((_, label), &count)| { - let alpha = if count == 0 { 0.05 } else { 0.15 + 0.75 * (count as f64 / max as f64) }; - HeatmapCell { - month_label: label.to_string(), - count, - alpha, - } - }).collect() + months + .iter() + .zip(counts.iter()) + .map(|((_, label), &count)| { + let alpha = if count == 0 { + 0.05 + } else { + 0.15 + 0.75 * (count as f64 / max as f64) + }; + HeatmapCell { + month_label: label.to_string(), + count, + alpha, + } + }) + .collect() } fn bar_height_px(avg_rating: f64) -> i64 { @@ -211,7 +250,11 @@ impl AskamaHtmlRenderer { } impl HtmlRenderer for AskamaHtmlRenderer { - fn render_diary_page(&self, data: &Paginated, ctx: HtmlPageContext) -> Result { + fn render_diary_page( + &self, + data: &Paginated, + ctx: HtmlPageContext, + ) -> Result { let has_more = (data.offset + data.limit) < data.total_count as u32; let (total_pages, current_page) = if data.limit > 0 { let tp = ((data.total_count + data.limit as u64 - 1) / data.limit as u64) as u32; @@ -262,8 +305,14 @@ impl HtmlRenderer for AskamaHtmlRenderer { let limit = data.limit; let total_pages = if limit > 0 { ((data.entries.total_count + limit as u64 - 1) / limit as u64) as u32 - } else { 0 }; - let current_page = if limit > 0 { data.current_offset / limit } else { 0 }; + } else { + 0 + }; + let current_page = if limit > 0 { + data.current_offset / limit + } else { + 0 + }; ActivityFeedTemplate { entries: &data.entries.items, current_offset: data.current_offset, @@ -277,21 +326,30 @@ impl HtmlRenderer for AskamaHtmlRenderer { } fn render_users_page(&self, data: UsersPageData) -> Result { - let users: Vec = data.users.iter().map(|u| { - let email = u.email(); - let display_name = email.split('@').next().unwrap_or(email).to_string(); - let initial = display_name.chars().next().unwrap_or('?').to_ascii_uppercase(); - let avg_rating_display = u.avg_rating - .map(|r| format!("{:.1}", r)) - .unwrap_or_else(|| "—".to_string()); - UserSummaryView { - user_id: u.user_id.value(), - display_name, - initial, - avg_rating_display, - total_movies: u.total_movies, - } - }).collect(); + let users: Vec = data + .users + .iter() + .map(|u| { + let email = u.email(); + let display_name = email.split('@').next().unwrap_or(email).to_string(); + let initial = display_name + .chars() + .next() + .unwrap_or('?') + .to_ascii_uppercase(); + let avg_rating_display = u + .avg_rating + .map(|r| format!("{:.1}", r)) + .unwrap_or_else(|| "—".to_string()); + UserSummaryView { + user_id: u.user_id.value(), + display_name, + initial, + avg_rating_display, + total_movies: u.total_movies, + } + }) + .collect(); UsersTemplate { users, ctx: &data.ctx, @@ -301,29 +359,60 @@ impl HtmlRenderer for AskamaHtmlRenderer { } fn render_profile_page(&self, data: ProfilePageData) -> Result { - let heatmap = data.history.as_deref() + let heatmap = data + .history + .as_deref() .map(|h| build_heatmap(h)) .unwrap_or_default(); - let profile_display_name = data.profile_user_email - .split('@').next().unwrap_or(&data.profile_user_email).to_string(); - let monthly_rating_rows: Vec> = data.trends.as_ref() - .map(|t| t.monthly_ratings.iter().map(|r| MonthlyRatingRow { - bar_height_px: bar_height_px(r.avg_rating), - rating: r, - }).collect()) + let profile_display_name = data + .profile_user_email + .split('@') + .next() + .unwrap_or(&data.profile_user_email) + .to_string(); + let monthly_rating_rows: Vec> = data + .trends + .as_ref() + .map(|t| { + t.monthly_ratings + .iter() + .map(|r| MonthlyRatingRow { + bar_height_px: bar_height_px(r.avg_rating), + rating: r, + }) + .collect() + }) .unwrap_or_default(); - let total_pages = data.entries.as_ref() - .map(|e| if e.limit > 0 { ((e.total_count + e.limit as u64 - 1) / e.limit as u64) as u32 } else { 0 }) + let total_pages = data + .entries + .as_ref() + .map(|e| { + if e.limit > 0 { + ((e.total_count + e.limit as u64 - 1) / e.limit as u64) as u32 + } else { + 0 + } + }) .unwrap_or(0); - let current_page = if data.limit > 0 { data.current_offset / data.limit } else { 0 }; - let avg_rating_display = data.stats.avg_rating + let current_page = if data.limit > 0 { + data.current_offset / data.limit + } else { + 0 + }; + let avg_rating_display = data + .stats + .avg_rating .map(|r| format!("{:.1}", r)) .unwrap_or_else(|| "—".to_string()); - let favorite_director_display = data.stats.favorite_director + let favorite_director_display = data + .stats + .favorite_director .as_deref() .unwrap_or("—") .to_string(); - let most_active_month_display = data.stats.most_active_month + let most_active_month_display = data + .stats + .most_active_month .as_deref() .unwrap_or("—") .to_string(); @@ -349,11 +438,15 @@ impl HtmlRenderer for AskamaHtmlRenderer { error: data.error, following_count: data.following_count, followers_count: data.followers_count, - pending_followers: data.pending_followers.into_iter().map(|a| RemoteActorData { - handle: a.handle, - url: a.url, - display_name: a.display_name, - }).collect(), + pending_followers: data + .pending_followers + .into_iter() + .map(|a| RemoteActorData { + handle: a.handle, + url: a.url, + display_name: a.display_name, + }) + .collect(), } .render() .map_err(|e| e.to_string()) @@ -363,11 +456,15 @@ impl HtmlRenderer for AskamaHtmlRenderer { FollowingTemplate { ctx: data.ctx, user_id: data.user_id, - actors: data.actors.into_iter().map(|a| RemoteActorData { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }).collect(), + actors: data + .actors + .into_iter() + .map(|a| RemoteActorData { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(), error: data.error, } .render() @@ -378,11 +475,15 @@ impl HtmlRenderer for AskamaHtmlRenderer { FollowersTemplate { ctx: data.ctx, user_id: data.user_id, - actors: data.actors.into_iter().map(|a| RemoteActorData { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }).collect(), + actors: data + .actors + .into_iter() + .map(|a| RemoteActorData { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(), error: data.error, } .render() diff --git a/crates/adapters/template-askama/templates/profile.html b/crates/adapters/template-askama/templates/profile.html index 0c6befb..7100a07 100644 --- a/crates/adapters/template-askama/templates/profile.html +++ b/crates/adapters/template-askama/templates/profile.html @@ -58,6 +58,11 @@ {% endif %} +
+

Export diary

+ Download CSV + Download JSON +
{% endif %}
diff --git a/crates/application/src/config.rs b/crates/application/src/config.rs index 72143a7..c97b906 100644 --- a/crates/application/src/config.rs +++ b/crates/application/src/config.rs @@ -10,12 +10,16 @@ impl AppConfig { let allow_registration = std::env::var("ALLOW_REGISTRATION") .map(|v| v == "true" || v == "1") .unwrap_or(false); - let base_url = std::env::var("BASE_URL") - .unwrap_or_else(|_| "http://localhost:3000".to_string()); + let base_url = + std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); let rate_limit = std::env::var("RATE_LIMIT") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(20); - Self { allow_registration, base_url, rate_limit } + Self { + allow_registration, + base_url, + rate_limit, + } } } diff --git a/crates/application/src/context.rs b/crates/application/src/context.rs index 1093217..cab6dcf 100644 --- a/crates/application/src/context.rs +++ b/crates/application/src/context.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use domain::ports::{ - AuthService, DiaryRepository, EventPublisher, MetadataClient, MovieRepository, + AuthService, DiaryExporter, DiaryRepository, EventPublisher, MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository, UserRepository, }; @@ -13,6 +13,7 @@ pub struct AppContext { pub movie_repository: Arc, pub review_repository: Arc, pub diary_repository: Arc, + pub diary_exporter: Arc, pub stats_repository: Arc, pub metadata_client: Arc, pub poster_fetcher: Arc, diff --git a/crates/application/src/movie_resolver.rs b/crates/application/src/movie_resolver.rs index 2a78ab4..6b2649a 100644 --- a/crates/application/src/movie_resolver.rs +++ b/crates/application/src/movie_resolver.rs @@ -207,29 +207,80 @@ mod tests { #[async_trait] impl MovieRepository for RepoWithExternalMovie { - async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result, DomainError> { Ok(Some(self.0.clone())) } - async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { panic!("unexpected") } - async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> 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 get_movie_by_external_id( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + Ok(Some(self.0.clone())) + } + async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { + panic!("unexpected") + } + async fn get_movies_by_title_and_year( + &self, + _: &MovieTitle, + _: &ReleaseYear, + ) -> 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_trait] impl MovieRepository for RepoEmpty { - async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result, DomainError> { Ok(None) } - async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { panic!("unexpected") } - async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> 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 get_movie_by_external_id( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + Ok(None) + } + async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { + panic!("unexpected") + } + async fn get_movies_by_title_and_year( + &self, + _: &MovieTitle, + _: &ReleaseYear, + ) -> 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_trait] impl MovieRepository for RepoWithTitleMatch { - async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result, DomainError> { panic!("unexpected") } - async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { panic!("unexpected") } - async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> 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 get_movie_by_external_id( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + panic!("unexpected") + } + async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { + panic!("unexpected") + } + async fn get_movies_by_title_and_year( + &self, + _: &MovieTitle, + _: &ReleaseYear, + ) -> 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") + } } struct MetaReturnsMovie(Movie); @@ -257,7 +308,9 @@ mod tests { &self, _: &MetadataSearchCriteria, ) -> Result { - Err(DomainError::InfrastructureError("metadata unavailable".into())) + Err(DomainError::InfrastructureError( + "metadata unavailable".into(), + )) } async fn get_poster_url( &self, diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index 2bdc938..3ec7329 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -1,6 +1,9 @@ use uuid::Uuid; -use domain::models::{DiaryEntry, FeedEntry, MonthActivity, UserStats, UserSummary, UserTrends, collections::Paginated}; +use domain::models::{ + DiaryEntry, FeedEntry, MonthActivity, UserStats, UserSummary, UserTrends, + collections::Paginated, +}; pub struct RemoteActorView { pub handle: String, @@ -85,7 +88,11 @@ pub struct FollowersPageData { } pub trait HtmlRenderer: Send + Sync { - fn render_diary_page(&self, data: &Paginated, ctx: HtmlPageContext) -> Result; + fn render_diary_page( + &self, + data: &Paginated, + ctx: HtmlPageContext, + ) -> Result; fn render_login_page(&self, data: LoginPageData<'_>) -> Result; fn render_register_page(&self, data: RegisterPageData<'_>) -> Result; fn render_new_review_page(&self, data: NewReviewPageData<'_>) -> Result; diff --git a/crates/application/src/use_cases/delete_review.rs b/crates/application/src/use_cases/delete_review.rs index 3cc887a..e55a194 100644 --- a/crates/application/src/use_cases/delete_review.rs +++ b/crates/application/src/use_cases/delete_review.rs @@ -1,5 +1,8 @@ -use domain::{errors::DomainError, value_objects::{ReviewId, UserId}}; use crate::{commands::DeleteReviewCommand, context::AppContext}; +use domain::{ + errors::DomainError, + value_objects::{ReviewId, UserId}, +}; pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), DomainError> { let review_id = ReviewId::from_uuid(cmd.review_id); diff --git a/crates/application/src/use_cases/export_diary.rs b/crates/application/src/use_cases/export_diary.rs index 58a40f7..7d3a4a7 100644 --- a/crates/application/src/use_cases/export_diary.rs +++ b/crates/application/src/use_cases/export_diary.rs @@ -1,23 +1,13 @@ -use std::sync::Arc; +use domain::{errors::DomainError, value_objects::UserId}; -use domain::{ - errors::DomainError, - ports::{DiaryExporter, DiaryRepository}, -}; +use crate::{commands::ExportCommand, context::AppContext}; -use crate::commands::ExportCommand; - -pub struct ExportDiary { - repository: Arc, - exporter: Arc, -} - -impl ExportDiary { - pub async fn execute(&self, req: ExportCommand) -> Result, DomainError> { - // 1. fetch all diary entries for the user - // 2. delegate serialization to the port (exporter) - - // Return bytes of the exported diary, which can be written to a file or returned in an HTTP response - Ok(vec![]) - } +pub async fn execute(ctx: &AppContext, cmd: ExportCommand) -> Result, DomainError> { + let entries = ctx + .diary_repository + .get_user_history(&UserId::from_uuid(cmd.user_id)) + .await?; + ctx.diary_exporter + .serialize_entries(&entries, cmd.format) + .await } diff --git a/crates/application/src/use_cases/get_activity_feed.rs b/crates/application/src/use_cases/get_activity_feed.rs index bf7cb1e..1a9abd1 100644 --- a/crates/application/src/use_cases/get_activity_feed.rs +++ b/crates/application/src/use_cases/get_activity_feed.rs @@ -1,8 +1,11 @@ +use crate::{context::AppContext, queries::GetActivityFeedQuery}; use domain::{ errors::DomainError, - models::{FeedEntry, collections::{PageParams, Paginated}}, + models::{ + FeedEntry, + collections::{PageParams, Paginated}, + }, }; -use crate::{context::AppContext, queries::GetActivityFeedQuery}; pub async fn execute( ctx: &AppContext, diff --git a/crates/application/src/use_cases/get_user_profile.rs b/crates/application/src/use_cases/get_user_profile.rs index d66540b..82ec005 100644 --- a/crates/application/src/use_cases/get_user_profile.rs +++ b/crates/application/src/use_cases/get_user_profile.rs @@ -1,3 +1,7 @@ +use crate::{ + context::AppContext, + queries::{GetUserProfileQuery, ProfileView}, +}; use chrono::Datelike; use domain::{ errors::DomainError, @@ -7,7 +11,6 @@ use domain::{ }, value_objects::UserId, }; -use crate::{context::AppContext, queries::{GetUserProfileQuery, ProfileView}}; pub struct UserProfileData { pub stats: UserStats, @@ -27,26 +30,61 @@ pub async fn execute( ProfileView::History => { let all_entries = ctx.diary_repository.get_user_history(&user_id).await?; let history = group_by_month(all_entries); - Ok(UserProfileData { stats, entries: None, history: Some(history), trends: None }) + Ok(UserProfileData { + stats, + entries: None, + history: Some(history), + trends: None, + }) } ProfileView::Trends => { let trends = ctx.stats_repository.get_user_trends(&user_id).await?; - Ok(UserProfileData { stats, entries: None, history: None, trends: Some(trends) }) + Ok(UserProfileData { + stats, + entries: None, + history: None, + trends: Some(trends), + }) } ProfileView::Ratings => { - let filter = paged_user_filter(user_id, SortDirection::ByRatingDesc, query.limit, query.offset)?; + let filter = paged_user_filter( + user_id, + SortDirection::ByRatingDesc, + query.limit, + query.offset, + )?; let entries = ctx.diary_repository.query_diary(&filter).await?; - Ok(UserProfileData { stats, entries: Some(entries), history: None, trends: None }) + Ok(UserProfileData { + stats, + entries: Some(entries), + history: None, + trends: None, + }) } ProfileView::Recent => { - let filter = paged_user_filter(user_id, SortDirection::Descending, query.limit, query.offset)?; + let filter = paged_user_filter( + user_id, + SortDirection::Descending, + query.limit, + query.offset, + )?; let entries = ctx.diary_repository.query_diary(&filter).await?; - Ok(UserProfileData { stats, entries: Some(entries), history: None, trends: None }) + Ok(UserProfileData { + stats, + entries: Some(entries), + history: None, + trends: None, + }) } } } -fn paged_user_filter(user_id: UserId, sort_by: SortDirection, limit: Option, offset: Option) -> Result { +fn paged_user_filter( + user_id: UserId, + sort_by: SortDirection, + limit: Option, + offset: Option, +) -> Result { let page = PageParams::new(limit, offset)?; Ok(DiaryFilter { sort_by, @@ -81,11 +119,22 @@ fn group_by_month(entries: Vec) -> Vec { fn format_year_month_long(ym: &str) -> String { let parts: Vec<&str> = ym.splitn(2, '-').collect(); - if parts.len() != 2 { return ym.to_string(); } + if parts.len() != 2 { + return ym.to_string(); + } let month = match parts[1] { - "01" => "January", "02" => "February", "03" => "March", "04" => "April", - "05" => "May", "06" => "June", "07" => "July", "08" => "August", - "09" => "September", "10" => "October", "11" => "November", "12" => "December", + "01" => "January", + "02" => "February", + "03" => "March", + "04" => "April", + "05" => "May", + "06" => "June", + "07" => "July", + "08" => "August", + "09" => "September", + "10" => "October", + "11" => "November", + "12" => "December", _ => parts[1], }; format!("{} {}", month, parts[0]) diff --git a/crates/application/src/use_cases/get_users.rs b/crates/application/src/use_cases/get_users.rs index 2efd6e4..0683792 100644 --- a/crates/application/src/use_cases/get_users.rs +++ b/crates/application/src/use_cases/get_users.rs @@ -1,5 +1,5 @@ -use domain::{errors::DomainError, models::UserSummary}; use crate::{context::AppContext, queries::GetUsersQuery}; +use domain::{errors::DomainError, models::UserSummary}; pub async fn execute( ctx: &AppContext, diff --git a/crates/application/src/use_cases/log_review.rs b/crates/application/src/use_cases/log_review.rs index 496e46c..24340b7 100644 --- a/crates/application/src/use_cases/log_review.rs +++ b/crates/application/src/use_cases/log_review.rs @@ -20,7 +20,9 @@ pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), Doma repository: ctx.movie_repository.as_ref(), metadata_client: ctx.metadata_client.as_ref(), }; - let (movie, is_new_movie) = MovieResolver::default_pipeline().resolve(&cmd, &deps).await?; + let (movie, is_new_movie) = MovieResolver::default_pipeline() + .resolve(&cmd, &deps) + .await?; ctx.movie_repository.upsert_movie(&movie).await?; diff --git a/crates/application/src/use_cases/register.rs b/crates/application/src/use_cases/register.rs index 8a36985..c746c14 100644 --- a/crates/application/src/use_cases/register.rs +++ b/crates/application/src/use_cases/register.rs @@ -1,4 +1,8 @@ -use domain::{errors::DomainError, models::User, value_objects::{Email, Username}}; +use domain::{ + errors::DomainError, + models::User, + value_objects::{Email, Username}, +}; use crate::{commands::RegisterCommand, context::AppContext}; @@ -19,13 +23,24 @@ pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), Domai let username = Username::new(cmd.username)?; if ctx.user_repository.find_by_email(&email).await?.is_some() { - return Err(DomainError::ValidationError("Email already registered".into())); + return Err(DomainError::ValidationError( + "Email already registered".into(), + )); } - if ctx.user_repository.find_by_username(&username).await?.is_some() { - return Err(DomainError::ValidationError("Username already taken".into())); + if ctx + .user_repository + .find_by_username(&username) + .await? + .is_some() + { + return Err(DomainError::ValidationError( + "Username already taken".into(), + )); } let hash = ctx.password_hasher.hash(&cmd.password).await?; - ctx.user_repository.save(&User::new(email, username, hash)).await + ctx.user_repository + .save(&User::new(email, username, hash)) + .await } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 88d020d..6bd82db 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -5,8 +5,8 @@ use crate::{ errors::DomainError, events::DomainEvent, models::{ - DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, User, UserStats, - UserSummary, UserTrends, + DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, Movie, Review, ReviewHistory, User, + UserStats, UserSummary, UserTrends, collections::{PageParams, Paginated}, }, value_objects::{ @@ -36,12 +36,17 @@ pub trait ReviewRepository: Send + Sync { async fn save_review(&self, review: &Review) -> Result; async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError>; async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>; + async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError>; } #[async_trait] pub trait DiaryRepository: Send + Sync { - async fn query_diary(&self, filter: &DiaryFilter) -> Result, DomainError>; - async fn query_activity_feed(&self, page: &PageParams) -> Result, DomainError>; + async fn query_diary(&self, filter: &DiaryFilter) + -> Result, DomainError>; + async fn query_activity_feed( + &self, + page: &PageParams, + ) -> Result, DomainError>; async fn get_review_history(&self, movie_id: &MovieId) -> Result; async fn get_user_history(&self, user_id: &UserId) -> Result, DomainError>; } @@ -122,7 +127,11 @@ pub trait PasswordHasher: Send + Sync { #[async_trait] pub trait DiaryExporter: Send + Sync { - async fn serialize_reviews(&self, reviews: &[Review]) -> Result, DomainError>; + async fn serialize_entries( + &self, + entries: &[DiaryEntry], + format: ExportFormat, + ) -> Result, DomainError>; } #[async_trait] diff --git a/crates/domain/src/value_objects.rs b/crates/domain/src/value_objects.rs index c00c0ff..861f18d 100644 --- a/crates/domain/src/value_objects.rs +++ b/crates/domain/src/value_objects.rs @@ -184,10 +184,18 @@ impl Username { 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).into(), + format!( + "Username must be {}–{} characters", + Self::MIN_LENGTH, + Self::MAX_LENGTH + ) + .into(), )); } - if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { + if !s + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { return Err(DomainError::ValidationError( "Username may only contain letters, digits, underscores, and hyphens".into(), )); diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index d23313d..cb65d4c 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -32,6 +32,7 @@ sqlx = { workspace = true } template-askama = { workspace = true } event-publisher = { workspace = true } rss = { workspace = true } +export = { workspace = true } infer = "0.19.0" percent-encoding = "2" diff --git a/crates/presentation/src/dtos.rs b/crates/presentation/src/dtos.rs index 32612b2..f7f1b3c 100644 --- a/crates/presentation/src/dtos.rs +++ b/crates/presentation/src/dtos.rs @@ -259,6 +259,16 @@ pub struct ProfileQueryParams { pub error: Option, } +#[derive(serde::Deserialize)] +pub struct ExportQueryParams { + #[serde(default = "default_export_format")] + pub format: String, +} + +fn default_export_format() -> String { + "csv".to_string() +} + #[cfg(test)] mod tests { use super::*; @@ -345,7 +355,10 @@ mod tests { movie_id: None, }; let query = GetDiaryQuery::from(params); - assert!(matches!(query.sort_by, Some(domain::models::SortDirection::Ascending))); + assert!(matches!( + query.sort_by, + Some(domain::models::SortDirection::Ascending) + )); } #[test] @@ -357,7 +370,10 @@ mod tests { movie_id: None, }; let query = GetDiaryQuery::from(params); - assert!(matches!(query.sort_by, Some(domain::models::SortDirection::Descending))); + assert!(matches!( + query.sort_by, + Some(domain::models::SortDirection::Descending) + )); } #[test] diff --git a/crates/presentation/src/event_handlers.rs b/crates/presentation/src/event_handlers.rs index 8b48fdc..8692814 100644 --- a/crates/presentation/src/event_handlers.rs +++ b/crates/presentation/src/event_handlers.rs @@ -2,8 +2,8 @@ use std::time::Duration; use application::{commands::SyncPosterCommand, context::AppContext, use_cases::sync_poster}; use async_trait::async_trait; -use domain::{errors::DomainError, events::DomainEvent}; use domain::ports::EventHandler; +use domain::{errors::DomainError, events::DomainEvent}; pub struct PosterSyncHandler { ctx: AppContext, diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index 842e4eb..d18faac 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -28,11 +28,7 @@ where "Missing or invalid auth token".into(), )) })?; - let user_id = app_state - .app_ctx - .auth_service - .validate_token(token) - .await?; + let user_id = app_state.app_ctx.auth_service.validate_token(token).await?; Ok(AuthenticatedUser(user_id)) } } @@ -98,28 +94,32 @@ where #[cfg(test)] mod tests { use super::*; - use std::sync::Arc; + use application::{config::AppConfig, context::AppContext}; use axum::{ + Router, body::Body, http::{Request, StatusCode}, routing::get, - Router, }; - use application::{config::AppConfig, context::AppContext}; use domain::{ errors::DomainError, events::DomainEvent, - models::{DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats, UserTrends, collections::{PageParams, Paginated}}, + models::{ + DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats, + UserTrends, + collections::{PageParams, Paginated}, + }, ports::{ AuthService, DiaryRepository, EventPublisher, GeneratedToken, MetadataClient, - MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, - ReviewRepository, StatsRepository, UserRepository, + MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository, + StatsRepository, UserRepository, }, value_objects::{ Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl, ReleaseYear, ReviewId, UserId, }, }; + use std::sync::Arc; use tower::ServiceExt; // --- Panic stubs (defined once) --- @@ -128,82 +128,232 @@ mod tests { #[async_trait::async_trait] impl MovieRepository for Panic { - async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result, DomainError> { panic!() } - async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { panic!() } - async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result, DomainError> { panic!() } - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!() } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!() } + async fn get_movie_by_external_id( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + panic!() + } + async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { + panic!() + } + async fn get_movies_by_title_and_year( + &self, + _: &MovieTitle, + _: &ReleaseYear, + ) -> Result, DomainError> { + panic!() + } + async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { + panic!() + } + async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { + panic!() + } } #[async_trait::async_trait] impl ReviewRepository for Panic { - async fn save_review(&self, _: &Review) -> Result { panic!() } - async fn get_review_by_id(&self, _: &ReviewId) -> Result, DomainError> { panic!() } - async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { panic!() } + async fn save_review(&self, _: &Review) -> Result { + panic!() + } + async fn get_review_by_id(&self, _: &ReviewId) -> Result, DomainError> { + panic!() + } + async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { + panic!() + } + async fn get_all_reviews_for_user(&self, _: &UserId) -> Result, DomainError> { + panic!() + } } #[async_trait::async_trait] impl DiaryRepository for Panic { - async fn query_diary(&self, _: &DiaryFilter) -> Result, DomainError> { panic!() } - async fn query_activity_feed(&self, _: &PageParams) -> Result, DomainError> { panic!() } - async fn get_review_history(&self, _: &MovieId) -> Result { panic!() } - async fn get_user_history(&self, _: &UserId) -> Result, DomainError> { panic!() } + async fn query_diary(&self, _: &DiaryFilter) -> Result, DomainError> { + panic!() + } + async fn query_activity_feed( + &self, + _: &PageParams, + ) -> Result, DomainError> { + panic!() + } + async fn get_review_history(&self, _: &MovieId) -> Result { + panic!() + } + async fn get_user_history(&self, _: &UserId) -> Result, DomainError> { + panic!() + } } #[async_trait::async_trait] impl StatsRepository for Panic { - async fn get_user_stats(&self, _: &UserId) -> Result { panic!() } - async fn get_user_trends(&self, _: &UserId) -> Result { panic!() } + async fn get_user_stats(&self, _: &UserId) -> Result { + panic!() + } + async fn get_user_trends(&self, _: &UserId) -> Result { + panic!() + } } #[async_trait::async_trait] impl MetadataClient for Panic { - async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result { panic!() } - async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result, DomainError> { panic!() } + async fn fetch_movie_metadata( + &self, + _: &domain::ports::MetadataSearchCriteria, + ) -> Result { + panic!() + } + async fn get_poster_url( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + panic!() + } } #[async_trait::async_trait] - impl PosterFetcherClient for Panic { async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result, DomainError> { panic!() } } + impl PosterFetcherClient for Panic { + async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result, DomainError> { + panic!() + } + } #[async_trait::async_trait] impl PosterStorage for Panic { - async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result { panic!() } - async fn get_poster(&self, _: &PosterPath) -> Result, DomainError> { panic!() } + async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result { + panic!() + } + async fn get_poster(&self, _: &PosterPath) -> Result, DomainError> { + panic!() + } } #[async_trait::async_trait] impl AuthService for Panic { - async fn generate_token(&self, _: &UserId) -> Result { panic!() } - async fn validate_token(&self, _: &str) -> Result { panic!() } + async fn generate_token(&self, _: &UserId) -> Result { + panic!() + } + async fn validate_token(&self, _: &str) -> Result { + panic!() + } } #[async_trait::async_trait] impl PasswordHasher for Panic { - async fn hash(&self, _: &str) -> Result { panic!() } - async fn verify(&self, _: &str, _: &PasswordHash) -> Result { panic!() } + async fn hash(&self, _: &str) -> Result { + panic!() + } + async fn verify(&self, _: &str, _: &PasswordHash) -> Result { + panic!() + } } #[async_trait::async_trait] impl UserRepository for Panic { - 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> { panic!() } - async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result, DomainError> { panic!() } - async fn list_with_stats(&self) -> Result, DomainError> { panic!() } + 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> { + panic!() + } + async fn find_by_username( + &self, + _: &domain::value_objects::Username, + ) -> Result, DomainError> { + panic!() + } + async fn list_with_stats(&self) -> Result, DomainError> { + panic!() + } } #[async_trait::async_trait] - impl EventPublisher for Panic { async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { panic!() } } + impl EventPublisher for Panic { + async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { + panic!() + } + } + #[async_trait::async_trait] + impl domain::ports::DiaryExporter for Panic { + async fn serialize_entries( + &self, + _: &[domain::models::DiaryEntry], + _: domain::models::ExportFormat, + ) -> Result, domain::errors::DomainError> { + panic!() + } + } impl crate::ports::HtmlRenderer for Panic { - fn render_diary_page(&self, _: &Paginated, _: application::ports::HtmlPageContext) -> Result { panic!() } - fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result { panic!() } - fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result { panic!() } - fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result { panic!() } - fn render_activity_feed_page(&self, _: application::ports::ActivityFeedPageData) -> Result { panic!() } - fn render_users_page(&self, _: application::ports::UsersPageData) -> Result { panic!() } - fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result { panic!() } - fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result { panic!() } - fn render_followers_page(&self, _: application::ports::FollowersPageData) -> Result { panic!() } + fn render_diary_page( + &self, + _: &Paginated, + _: application::ports::HtmlPageContext, + ) -> Result { + panic!() + } + fn render_login_page( + &self, + _: application::ports::LoginPageData<'_>, + ) -> Result { + panic!() + } + fn render_register_page( + &self, + _: application::ports::RegisterPageData<'_>, + ) -> Result { + panic!() + } + fn render_new_review_page( + &self, + _: application::ports::NewReviewPageData<'_>, + ) -> Result { + panic!() + } + fn render_activity_feed_page( + &self, + _: application::ports::ActivityFeedPageData, + ) -> Result { + panic!() + } + fn render_users_page( + &self, + _: application::ports::UsersPageData, + ) -> Result { + panic!() + } + fn render_profile_page( + &self, + _: application::ports::ProfilePageData, + ) -> Result { + panic!() + } + fn render_following_page( + &self, + _: application::ports::FollowingPageData, + ) -> Result { + panic!() + } + fn render_followers_page( + &self, + _: application::ports::FollowersPageData, + ) -> Result { + panic!() + } } impl crate::ports::RssFeedRenderer for Panic { - fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result { panic!() } + fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result { + panic!() + } } struct RejectingAuth; #[async_trait::async_trait] impl AuthService for RejectingAuth { - async fn generate_token(&self, _: &UserId) -> Result { panic!() } + async fn generate_token(&self, _: &UserId) -> Result { + panic!() + } async fn validate_token(&self, _: &str) -> Result { Err(DomainError::Unauthorized("bad token".into())) } @@ -218,6 +368,7 @@ mod tests { movie_repository: Arc::clone(&repo) as _, review_repository: Arc::clone(&repo) as _, diary_repository: Arc::clone(&repo) as _, + diary_exporter: Arc::clone(&repo) as _, stats_repository: Arc::clone(&repo) as _, metadata_client: Arc::clone(&repo) as _, poster_fetcher: Arc::clone(&repo) as _, @@ -226,7 +377,11 @@ mod tests { password_hasher: Arc::clone(&repo) as _, user_repository: Arc::clone(&repo) as _, auth_service, - config: AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20 }, + config: AppConfig { + allow_registration: false, + base_url: "http://localhost:3000".to_string(), + rate_limit: 20, + }, }, html_renderer: Arc::new(Panic), rss_renderer: Arc::new(Panic), @@ -236,47 +391,103 @@ mod tests { // --- Routers --- - async fn protected_handler(user: AuthenticatedUser) -> String { user.0.value().to_string() } - async fn optional_cookie_handler(user: OptionalCookieUser) -> String { - match user.0 { Some(id) => id.value().to_string(), None => "none".to_string() } + async fn protected_handler(user: AuthenticatedUser) -> String { + user.0.value().to_string() + } + async fn optional_cookie_handler(user: OptionalCookieUser) -> String { + match user.0 { + Some(id) => id.value().to_string(), + None => "none".to_string(), + } + } + async fn required_cookie_handler(user: RequiredCookieUser) -> String { + user.0.value().to_string() } - async fn required_cookie_handler(user: RequiredCookieUser) -> String { user.0.value().to_string() } - fn router_protected(state: crate::state::AppState) -> Router { Router::new().route("/protected", get(protected_handler)).with_state(state) } - fn router_optional(state: crate::state::AppState) -> Router { Router::new().route("/optional", get(optional_cookie_handler)).with_state(state) } - fn router_required(state: crate::state::AppState) -> Router { Router::new().route("/required", get(required_cookie_handler)).with_state(state) } + fn router_protected(state: crate::state::AppState) -> Router { + Router::new() + .route("/protected", get(protected_handler)) + .with_state(state) + } + fn router_optional(state: crate::state::AppState) -> Router { + Router::new() + .route("/optional", get(optional_cookie_handler)) + .with_state(state) + } + fn router_required(state: crate::state::AppState) -> Router { + Router::new() + .route("/required", get(required_cookie_handler)) + .with_state(state) + } // --- Tests --- #[tokio::test] async fn missing_auth_header_returns_401() { let app = router_protected(make_test_state(Arc::new(Panic))); - let resp = app.oneshot(Request::builder().uri("/protected").body(Body::empty()).unwrap()).await.unwrap(); + let resp = app + .oneshot( + Request::builder() + .uri("/protected") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn optional_cookie_user_returns_none_without_cookie() { let app = router_optional(make_test_state(Arc::new(Panic))); - let resp = app.oneshot(Request::builder().uri("/optional").body(Body::empty()).unwrap()).await.unwrap(); + let resp = app + .oneshot( + Request::builder() + .uri("/optional") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::OK); - let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); assert_eq!(&body[..], b"none"); } #[tokio::test] async fn optional_cookie_user_returns_none_with_invalid_token() { let app = router_optional(make_test_state(Arc::new(RejectingAuth))); - let resp = app.oneshot(Request::builder().uri("/optional").header("cookie", "token=bad.token.here").body(Body::empty()).unwrap()).await.unwrap(); + let resp = app + .oneshot( + Request::builder() + .uri("/optional") + .header("cookie", "token=bad.token.here") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::OK); - let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); assert_eq!(&body[..], b"none"); } #[tokio::test] async fn required_cookie_user_redirects_without_cookie() { let app = router_required(make_test_state(Arc::new(Panic))); - let resp = app.oneshot(Request::builder().uri("/required").body(Body::empty()).unwrap()).await.unwrap(); + let resp = app + .oneshot( + Request::builder() + .uri("/required") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!(resp.headers().get("location").unwrap(), "/login"); } @@ -284,7 +495,16 @@ mod tests { #[tokio::test] async fn required_cookie_user_redirects_with_invalid_token() { let app = router_required(make_test_state(Arc::new(RejectingAuth))); - let resp = app.oneshot(Request::builder().uri("/required").header("cookie", "token=bad.token.here").body(Body::empty()).unwrap()).await.unwrap(); + let resp = app + .oneshot( + Request::builder() + .uri("/required") + .header("cookie", "token=bad.token.here") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!(resp.headers().get("location").unwrap(), "/login"); } diff --git a/crates/presentation/src/handlers.rs b/crates/presentation/src/handlers.rs index b97f5b2..911682a 100644 --- a/crates/presentation/src/handlers.rs +++ b/crates/presentation/src/handlers.rs @@ -14,13 +14,17 @@ pub mod html { use uuid::Uuid; use application::{ - commands::{DeleteReviewCommand, LoginCommand, RegisterCommand}, + commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand}, ports::{ FollowersPageData, FollowingPageData, HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData, RemoteActorView, }, - use_cases::{delete_review, log_review, login as login_uc, register as register_uc}, + use_cases::{ + delete_review, export_diary as export_diary_uc, log_review, login as login_uc, + register as register_uc, + }, }; + use domain::models::ExportFormat; use domain::{errors::DomainError, value_objects::UserId}; use crate::{ @@ -265,6 +269,45 @@ pub mod html { } } + pub async fn get_export( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Query(params): Query, + ) -> impl IntoResponse { + let format = match params.format.as_str() { + "csv" => ExportFormat::Csv, + "json" => ExportFormat::Json, + _ => return StatusCode::BAD_REQUEST.into_response(), + }; + let (content_type, filename) = match &format { + ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), + ExportFormat::Json => ("application/json", "diary.json"), + }; + let cmd = ExportCommand { + user_id: user_id.value(), + format, + }; + match export_diary_uc::execute(&state.app_ctx, cmd).await { + Ok(bytes) => ( + StatusCode::OK, + [ + (axum::http::header::CONTENT_TYPE, content_type.to_string()), + ( + axum::http::header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", filename), + ), + ], + bytes, + ) + .into_response(), + Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(), + Err(e) => { + tracing::error!("export error: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } + } + pub async fn get_activity_feed( OptionalCookieUser(user_id): OptionalCookieUser, State(state): State, diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs index 3a83114..4cf11d8 100644 --- a/crates/presentation/src/lib.rs +++ b/crates/presentation/src/lib.rs @@ -1,6 +1,6 @@ -pub mod event_handlers; pub mod dtos; pub mod errors; +pub mod event_handlers; pub mod extractors; pub mod handlers; pub mod ports; diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index cbfa701..7cda957 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -10,15 +10,19 @@ use sqlx::sqlite::SqliteConnectOptions; use tokio::net::TcpListener; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use activitypub::{ + ActivityPubEventHandler, ActivityPubPort, ActivityPubService, DomainUserRepoAdapter, + ReviewObjectHandler, +}; use application::{config::AppConfig, context::AppContext}; -use auth::{AuthConfig, Argon2PasswordHasher, JwtAuthService}; +use auth::{Argon2PasswordHasher, AuthConfig, JwtAuthService}; +use export::ExportAdapter; use metadata::MetadataClientImpl; use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher}; use poster_storage::{PosterStorageAdapter, StorageConfig}; -use activitypub::{ActivityPubEventHandler, ActivityPubPort, ActivityPubService, DomainUserRepoAdapter, ReviewObjectHandler}; +use rss::RssAdapter; use sqlite::{SqliteMovieRepository, SqliteUserRepository}; use sqlite_federation::SqliteFederationRepository; -use rss::RssAdapter; use template_askama::AskamaHtmlRenderer; use presentation::{routes, state::AppState}; @@ -68,18 +72,23 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { .context("Database migration failed")?; use domain::ports::{ - AuthService, DiaryRepository, MetadataClient, MovieRepository, PasswordHasher, - PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository, UserRepository, + AuthService, DiaryExporter, DiaryRepository, MetadataClient, MovieRepository, + PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository, + UserRepository, }; - let movie_repository: Arc = Arc::clone(&sqlite_repo) as _; + let movie_repository: Arc = Arc::clone(&sqlite_repo) as _; let review_repository: Arc = Arc::clone(&sqlite_repo) as _; - let diary_repository: Arc = Arc::clone(&sqlite_repo) as _; - let stats_repository: Arc = Arc::clone(&sqlite_repo) as _; + let diary_repository: Arc = Arc::clone(&sqlite_repo) as _; + let stats_repository: Arc = Arc::clone(&sqlite_repo) as _; - let user_repository: Arc = Arc::new(SqliteUserRepository::new(pool.clone())); - let metadata_client: Arc = Arc::new(MetadataClientImpl::new_omdb(omdb_api_key)); - let poster_fetcher: Arc = Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?); - let poster_storage: Arc = Arc::new(PosterStorageAdapter::from_config(storage_config)); + let user_repository: Arc = + Arc::new(SqliteUserRepository::new(pool.clone())); + let metadata_client: Arc = + Arc::new(MetadataClientImpl::new_omdb(omdb_api_key)); + let poster_fetcher: Arc = + Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?); + let poster_storage: Arc = + Arc::new(PosterStorageAdapter::from_config(storage_config)); let auth_service: Arc = Arc::new(JwtAuthService::new(auth_config)); let password_hasher: Arc = Arc::new(Argon2PasswordHasher); @@ -89,6 +98,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { movie_repository: Arc::clone(&movie_repository), review_repository: Arc::clone(&review_repository), diary_repository: Arc::clone(&diary_repository), + diary_exporter: Arc::new(ExportAdapter) as Arc, stats_repository: Arc::clone(&stats_repository), metadata_client: Arc::clone(&metadata_client), poster_fetcher: Arc::clone(&poster_fetcher), @@ -139,6 +149,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { movie_repository, review_repository, diary_repository, + diary_exporter: Arc::new(ExportAdapter) as Arc, stats_repository, metadata_client, poster_fetcher, diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 9426705..8d07e4d 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -36,7 +36,11 @@ impl RateLimiter { let prev = self.window.load(Ordering::Acquire); if now != prev { // compare_exchange ensures only one thread wins the window reset - if self.window.compare_exchange(prev, now, Ordering::AcqRel, Ordering::Relaxed).is_ok() { + if self + .window + .compare_exchange(prev, now, Ordering::AcqRel, Ordering::Relaxed) + .is_ok() + { self.count.store(1, Ordering::Release); return true; } @@ -130,6 +134,7 @@ fn html_routes(rate_limit: u64) -> Router { "/posters/{*path}", routing::get(handlers::posters::get_poster), ) + .route("/diary/export", routing::get(handlers::html::get_export)) .route("/feed.rss", routing::get(handlers::rss::get_feed)) .route( "/users/{id}/feed.rss", diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index ec66cfb..c247b9c 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -21,9 +21,9 @@ use domain::{ }; use http_body_util::BodyExt; use presentation::{routes, state::AppState}; +use rss::RssAdapter; use sqlite::SqliteMovieRepository; use sqlx::SqlitePool; -use rss::RssAdapter; use template_askama::AskamaHtmlRenderer; use tower::ServiceExt; @@ -41,7 +41,10 @@ impl MetadataClient for PanicMeta { async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result { panic!("metadata not wired in tests") } - async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result, DomainError> { + async fn get_poster_url( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { panic!() } } @@ -68,25 +71,58 @@ impl PosterStorage for PanicStorage { struct PanicHasher; #[async_trait] impl PasswordHasher for PanicHasher { - async fn hash(&self, _: &str) -> Result { panic!() } - async fn verify(&self, _: &str, _: &PasswordHash) -> Result { panic!() } + async fn hash(&self, _: &str) -> Result { + panic!() + } + async fn verify(&self, _: &str, _: &PasswordHash) -> Result { + panic!() + } } struct PanicAuth; #[async_trait] impl AuthService for PanicAuth { - async fn generate_token(&self, _: &UserId) -> Result { panic!() } - async fn validate_token(&self, _: &str) -> Result { panic!() } + async fn generate_token(&self, _: &UserId) -> Result { + panic!() + } + async fn validate_token(&self, _: &str) -> Result { + panic!() + } } struct NobodyUserRepo; #[async_trait] impl UserRepository for NobodyUserRepo { - async fn find_by_email(&self, _: &Email) -> Result, DomainError> { Ok(None) } - async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result, DomainError> { Ok(None) } - async fn save(&self, _: &User) -> Result<(), DomainError> { panic!() } - async fn find_by_id(&self, _: &UserId) -> Result, DomainError> { panic!() } - async fn list_with_stats(&self) -> Result, DomainError> { panic!() } + async fn find_by_email(&self, _: &Email) -> Result, DomainError> { + Ok(None) + } + async fn find_by_username( + &self, + _: &domain::value_objects::Username, + ) -> Result, DomainError> { + Ok(None) + } + async fn save(&self, _: &User) -> Result<(), DomainError> { + panic!() + } + async fn find_by_id(&self, _: &UserId) -> Result, DomainError> { + panic!() + } + async fn list_with_stats(&self) -> Result, DomainError> { + panic!() + } +} + +struct PanicExporter; +#[async_trait] +impl domain::ports::DiaryExporter for PanicExporter { + async fn serialize_entries( + &self, + _: &[domain::models::DiaryEntry], + _: domain::models::ExportFormat, + ) -> Result, DomainError> { + panic!() + } } async fn test_app() -> Router { @@ -102,6 +138,7 @@ async fn test_app() -> Router { movie_repository: Arc::clone(&repo) as _, review_repository: Arc::clone(&repo) as _, diary_repository: Arc::clone(&repo) as _, + diary_exporter: Arc::new(PanicExporter), stats_repository: Arc::clone(&repo) as _, metadata_client: Arc::new(PanicMeta), poster_fetcher: Arc::new(PanicFetcher), @@ -110,7 +147,11 @@ async fn test_app() -> Router { auth_service: Arc::new(PanicAuth), password_hasher: Arc::new(PanicHasher), user_repository: Arc::new(NobodyUserRepo), - config: AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20 }, + config: AppConfig { + allow_registration: false, + base_url: "http://localhost:3000".to_string(), + rate_limit: 20, + }, }, html_renderer: Arc::new(AskamaHtmlRenderer::new()), rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())), @@ -124,7 +165,12 @@ async fn test_app() -> Router { async fn get_api_diary_returns_empty_list() { let app = test_app().await; let response = app - .oneshot(Request::builder().uri("/api/diary").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri("/api/diary") + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index d088129..4e9642b 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1,6 +1,6 @@ -use uuid::Uuid; use crate::client::{DiaryEntryDto, LogReviewRequest, ReviewHistoryResponse}; use crate::config::Config; +use uuid::Uuid; // ── Screens ─────────────────────────────────────────────────────────────────── @@ -25,7 +25,11 @@ pub struct LoginState { } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub enum LoginField { #[default] Email, Password } +pub enum LoginField { + #[default] + Email, + Password, +} // ── Main (4 tabs) ───────────────────────────────────────────────────────────── @@ -45,13 +49,22 @@ impl MainState { diary: DiaryState::default(), add_review: AddReviewState::default(), bulk_import: BulkImportState::default(), - settings: SettingsState { api_url, focused: SettingsField::default() }, + settings: SettingsState { + api_url, + focused: SettingsField::default(), + }, } } } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub enum Tab { #[default] Diary, AddReview, BulkImport, Settings } +pub enum Tab { + #[default] + Diary, + AddReview, + BulkImport, + Settings, +} // ── Diary ───────────────────────────────────────────────────────────────────── @@ -94,7 +107,14 @@ impl Default for AddReviewState { #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum AddReviewField { - #[default] ExternalId, Title, Year, Rating, WatchedAt, Comment, Submit, + #[default] + ExternalId, + Title, + Year, + Rating, + WatchedAt, + Comment, + Submit, } // ── Bulk Import ─────────────────────────────────────────────────────────────── @@ -111,9 +131,12 @@ pub struct BulkImportState { #[derive(Debug, Default, Clone, PartialEq, Eq)] pub enum BulkImportStage { - #[default] EnterPath, + #[default] + EnterPath, Preview, - Importing { done: usize }, + Importing { + done: usize, + }, Done, } @@ -132,7 +155,12 @@ pub struct SettingsState { } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub enum SettingsField { #[default] ApiUrl, Save, Logout } +pub enum SettingsField { + #[default] + ApiUrl, + Save, + Logout, +} // ── Status bar ──────────────────────────────────────────────────────────────── @@ -155,13 +183,22 @@ pub struct App { impl App { pub fn new(config: Option, token: Option) -> Self { - let api_url = config.as_ref().map(|c| c.api_url.clone()).unwrap_or_default(); + let api_url = config + .as_ref() + .map(|c| c.api_url.clone()) + .unwrap_or_default(); let screen = match &config { None => Screen::Setup(SetupState::default()), Some(_) if token.is_none() => Screen::Login(LoginState::default()), Some(c) => Screen::Main(MainState::new(c.api_url.clone())), }; - Self { screen, token, loading: false, status: None, api_url } + Self { + screen, + token, + loading: false, + status: None, + api_url, + } } } @@ -169,24 +206,51 @@ impl App { #[derive(Debug)] pub enum Action { - Quit, Escape, TabSelect(Tab), TabNext, TabPrev, + Quit, + Escape, + TabSelect(Tab), + TabNext, + TabPrev, SetupSubmit, - InputChar(char), Backspace, FocusNext, FocusPrev, + InputChar(char), + Backspace, + FocusNext, + FocusPrev, LoginSubmit, - ScrollDown, ScrollUp, OpenHistory, LoadMore, LoadPrev, - DeleteInit, DeleteConfirm, DeleteCancel, - RatingUp, RatingDown, ReviewSubmit, - BulkParseFile, BulkImportAll, BulkCancel, - SettingsSave, SettingsLogout, + ScrollDown, + ScrollUp, + OpenHistory, + LoadMore, + LoadPrev, + DeleteInit, + DeleteConfirm, + DeleteCancel, + RatingUp, + RatingDown, + ReviewSubmit, + BulkParseFile, + BulkImportAll, + BulkCancel, + SettingsSave, + SettingsLogout, // async results - AuthOk(String), AuthFail(String), - DiaryLoaded { entries: Vec, total: u64 }, + AuthOk(String), + AuthFail(String), + DiaryLoaded { + entries: Vec, + total: u64, + }, DiaryLoadFailed(String), HistoryLoaded(ReviewHistoryResponse), HistoryLoadFailed(String), - ReviewCreated, ReviewCreateFailed(String), - ReviewDeleted(Uuid), ReviewDeleteFailed(String), - BulkItemDone { index: usize, error: Option }, + ReviewCreated, + ReviewCreateFailed(String), + ReviewDeleted(Uuid), + ReviewDeleteFailed(String), + BulkItemDone { + index: usize, + error: Option, + }, } #[derive(Debug)] @@ -211,7 +275,10 @@ pub fn parse_csv(content: &str) -> Vec { let record = match result { Ok(r) => r, Err(e) => { - rows.push(ParsedRow { row: row_num, result: Err(e.to_string()) }); + rows.push(ParsedRow { + row: row_num, + result: Err(e.to_string()), + }); continue; } }; @@ -224,18 +291,36 @@ pub fn parse_csv(content: &str) -> Vec { let comment = record.get(5).unwrap_or("").trim().to_string(); if title.is_empty() && external_id.is_empty() { - rows.push(ParsedRow { row: row_num, result: Err("title or external_id required".into()) }); + rows.push(ParsedRow { + row: row_num, + result: Err("title or external_id required".into()), + }); continue; } let rating: u8 = match rating_str.trim().parse::() { Ok(r) if r <= 5 => r, - Ok(_) => { rows.push(ParsedRow { row: row_num, result: Err(format!("rating must be 0-5, got {rating_str}")) }); continue; } - Err(_) => { rows.push(ParsedRow { row: row_num, result: Err(format!("invalid rating: {rating_str}")) }); continue; } + Ok(_) => { + rows.push(ParsedRow { + row: row_num, + result: Err(format!("rating must be 0-5, got {rating_str}")), + }); + continue; + } + Err(_) => { + rows.push(ParsedRow { + row: row_num, + result: Err(format!("invalid rating: {rating_str}")), + }); + continue; + } }; if watched_at.is_empty() { - rows.push(ParsedRow { row: row_num, result: Err("watched_at required".into()) }); + rows.push(ParsedRow { + row: row_num, + result: Err("watched_at required".into()), + }); continue; } @@ -244,18 +329,32 @@ pub fn parse_csv(content: &str) -> Vec { } else { match year_str.parse() { Ok(y) => Some(y), - Err(_) => { rows.push(ParsedRow { row: row_num, result: Err(format!("invalid year: {year_str}")) }); continue; } + Err(_) => { + rows.push(ParsedRow { + row: row_num, + result: Err(format!("invalid year: {year_str}")), + }); + continue; + } } }; rows.push(ParsedRow { row: row_num, result: Ok(LogReviewRequest { - external_metadata_id: if external_id.is_empty() { None } else { Some(external_id) }, + external_metadata_id: if external_id.is_empty() { + None + } else { + Some(external_id) + }, manual_title: if title.is_empty() { None } else { Some(title) }, manual_release_year, rating, - comment: if comment.is_empty() { None } else { Some(comment) }, + comment: if comment.is_empty() { + None + } else { + Some(comment) + }, watched_at, }), }); @@ -277,14 +376,24 @@ pub fn update(app: &mut App, action: Action) -> Vec { Action::TabNext => { if let Screen::Main(m) = &mut app.screen { - m.tab = match m.tab { Tab::Diary => Tab::AddReview, Tab::AddReview => Tab::BulkImport, Tab::BulkImport => Tab::Settings, Tab::Settings => Tab::Diary }; + m.tab = match m.tab { + Tab::Diary => Tab::AddReview, + Tab::AddReview => Tab::BulkImport, + Tab::BulkImport => Tab::Settings, + Tab::Settings => Tab::Diary, + }; } vec![] } Action::TabPrev => { if let Screen::Main(m) = &mut app.screen { - m.tab = match m.tab { Tab::Diary => Tab::Settings, Tab::AddReview => Tab::Diary, Tab::BulkImport => Tab::AddReview, Tab::Settings => Tab::BulkImport }; + m.tab = match m.tab { + Tab::Diary => Tab::Settings, + Tab::AddReview => Tab::Diary, + Tab::BulkImport => Tab::AddReview, + Tab::Settings => Tab::BulkImport, + }; } vec![] } @@ -293,15 +402,23 @@ pub fn update(app: &mut App, action: Action) -> Vec { if let Screen::Main(m) = &mut app.screen { match m.tab { Tab::Diary => { - if m.diary.delete_pending.is_some() { m.diary.delete_pending = None; } - else { m.diary.history = None; } + if m.diary.delete_pending.is_some() { + m.diary.delete_pending = None; + } else { + m.diary.history = None; + } } Tab::BulkImport => { - if matches!(m.bulk_import.stage, BulkImportStage::Preview | BulkImportStage::Done) { + if matches!( + m.bulk_import.stage, + BulkImportStage::Preview | BulkImportStage::Done + ) { m.bulk_import.stage = BulkImportStage::EnterPath; } } - Tab::AddReview | Tab::Settings => { m.tab = Tab::Diary; } + Tab::AddReview | Tab::Settings => { + m.tab = Tab::Diary; + } } } vec![] @@ -324,7 +441,9 @@ pub fn update(app: &mut App, action: Action) -> Vec { AddReviewField::Comment => m.add_review.comment.push(c), _ => {} }, - Tab::BulkImport if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) => { + Tab::BulkImport + if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) => + { m.bulk_import.file_path.push(c); } Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => { @@ -338,21 +457,39 @@ pub fn update(app: &mut App, action: Action) -> Vec { Action::Backspace => { match &mut app.screen { - Screen::Setup(s) => { s.api_url.pop(); } + Screen::Setup(s) => { + s.api_url.pop(); + } Screen::Login(s) => match s.focused { - LoginField::Email => { s.email.pop(); } - LoginField::Password => { s.password.pop(); } + LoginField::Email => { + s.email.pop(); + } + LoginField::Password => { + s.password.pop(); + } }, Screen::Main(m) => match m.tab { Tab::AddReview => match m.add_review.focused { - AddReviewField::ExternalId => { m.add_review.external_id.pop(); } - AddReviewField::Title => { m.add_review.title.pop(); } - AddReviewField::Year => { m.add_review.year.pop(); } - AddReviewField::WatchedAt => { m.add_review.watched_at.pop(); } - AddReviewField::Comment => { m.add_review.comment.pop(); } + AddReviewField::ExternalId => { + m.add_review.external_id.pop(); + } + AddReviewField::Title => { + m.add_review.title.pop(); + } + AddReviewField::Year => { + m.add_review.year.pop(); + } + AddReviewField::WatchedAt => { + m.add_review.watched_at.pop(); + } + AddReviewField::Comment => { + m.add_review.comment.pop(); + } _ => {} }, - Tab::BulkImport if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) => { + Tab::BulkImport + if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) => + { m.bulk_import.file_path.pop(); } Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => { @@ -367,7 +504,11 @@ pub fn update(app: &mut App, action: Action) -> Vec { Action::FocusNext => { match &mut app.screen { Screen::Login(s) => { - s.focused = if s.focused == LoginField::Email { LoginField::Password } else { LoginField::Email }; + s.focused = if s.focused == LoginField::Email { + LoginField::Password + } else { + LoginField::Email + }; } Screen::Main(m) => match m.tab { Tab::AddReview => { @@ -398,7 +539,11 @@ pub fn update(app: &mut App, action: Action) -> Vec { Action::FocusPrev => { match &mut app.screen { Screen::Login(s) => { - s.focused = if s.focused == LoginField::Password { LoginField::Email } else { LoginField::Password }; + s.focused = if s.focused == LoginField::Password { + LoginField::Email + } else { + LoginField::Password + }; } Screen::Main(m) => match m.tab { Tab::AddReview => { @@ -451,7 +596,10 @@ pub fn update(app: &mut App, action: Action) -> Vec { Action::LoginSubmit => { if let Screen::Login(s) = &app.screen { if s.email.is_empty() || s.password.is_empty() { - app.status = Some(StatusMsg { text: "Email and password required".into(), is_error: true }); + app.status = Some(StatusMsg { + text: "Email and password required".into(), + is_error: true, + }); return vec![]; } let email = s.email.clone(); @@ -466,14 +614,20 @@ pub fn update(app: &mut App, action: Action) -> Vec { app.loading = false; app.status = None; app.screen = Screen::Main(MainState::new(app.api_url.clone())); - let cmds = vec![Command::SaveToken(token.clone()), Command::LoadDiary { offset: 0 }]; + let cmds = vec![ + Command::SaveToken(token.clone()), + Command::LoadDiary { offset: 0 }, + ]; app.token = Some(token); cmds } Action::AuthFail(msg) => { app.loading = false; - app.status = Some(StatusMsg { text: msg, is_error: true }); + app.status = Some(StatusMsg { + text: msg, + is_error: true, + }); vec![] } @@ -547,10 +701,16 @@ pub fn update(app: &mut App, action: Action) -> Vec { if msg.contains("unauthorized") || msg.contains("Unauthorized") { app.token = None; app.screen = Screen::Login(LoginState::default()); - app.status = Some(StatusMsg { text: "Session expired. Please log in again.".into(), is_error: true }); + app.status = Some(StatusMsg { + text: "Session expired. Please log in again.".into(), + is_error: true, + }); return vec![Command::ClearToken]; } - app.status = Some(StatusMsg { text: msg, is_error: true }); + app.status = Some(StatusMsg { + text: msg, + is_error: true, + }); vec![] } @@ -564,7 +724,10 @@ pub fn update(app: &mut App, action: Action) -> Vec { Action::HistoryLoadFailed(msg) => { app.loading = false; - app.status = Some(StatusMsg { text: msg, is_error: true }); + app.status = Some(StatusMsg { + text: msg, + is_error: true, + }); vec![] } @@ -602,26 +765,36 @@ pub fn update(app: &mut App, action: Action) -> Vec { } m.diary.history = None; } - app.status = Some(StatusMsg { text: "Review deleted".into(), is_error: false }); + app.status = Some(StatusMsg { + text: "Review deleted".into(), + is_error: false, + }); vec![] } Action::ReviewDeleteFailed(msg) => { - app.status = Some(StatusMsg { text: msg, is_error: true }); + app.status = Some(StatusMsg { + text: msg, + is_error: true, + }); vec![] } // ── Add Review ──────────────────────────────────────────────────────── Action::RatingUp => { if let Screen::Main(m) = &mut app.screen { - if m.add_review.rating < 5 { m.add_review.rating += 1; } + if m.add_review.rating < 5 { + m.add_review.rating += 1; + } } vec![] } Action::RatingDown => { if let Screen::Main(m) = &mut app.screen { - if m.add_review.rating > 0 { m.add_review.rating -= 1; } + if m.add_review.rating > 0 { + m.add_review.rating -= 1; + } } vec![] } @@ -633,19 +806,37 @@ pub fn update(app: &mut App, action: Action) -> Vec { 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 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 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 }); + 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 }); + app.status = Some(StatusMsg { + text: "Watched-at date required".into(), + is_error: true, + }); return vec![]; } let req = LogReviewRequest { @@ -665,7 +856,10 @@ pub fn update(app: &mut App, action: Action) -> Vec { Action::ReviewCreated => { app.loading = false; - app.status = Some(StatusMsg { text: "Review added!".into(), is_error: false }); + app.status = Some(StatusMsg { + text: "Review added!".into(), + is_error: false, + }); if let Screen::Main(m) = &mut app.screen { m.add_review = AddReviewState::default(); } @@ -674,7 +868,10 @@ pub fn update(app: &mut App, action: Action) -> Vec { Action::ReviewCreateFailed(msg) => { app.loading = false; - app.status = Some(StatusMsg { text: msg, is_error: true }); + app.status = Some(StatusMsg { + text: msg, + is_error: true, + }); vec![] } @@ -689,7 +886,10 @@ pub fn update(app: &mut App, action: Action) -> Vec { m.bulk_import.stage = BulkImportStage::Preview; } Err(e) => { - app.status = Some(StatusMsg { text: format!("Cannot read file: {e}"), is_error: true }); + app.status = Some(StatusMsg { + text: format!("Cannot read file: {e}"), + is_error: true, + }); } } } @@ -700,11 +900,17 @@ pub fn update(app: &mut App, action: Action) -> Vec { Action::BulkImportAll => { if let Screen::Main(m) = &mut app.screen { if m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::Preview { - let valid: Vec = m.bulk_import.parsed.iter() + 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 }); + app.status = Some(StatusMsg { + text: "No valid rows to import".into(), + is_error: true, + }); return vec![]; } m.bulk_import.results = vec![None; valid.len()]; @@ -730,7 +936,10 @@ pub fn update(app: &mut App, action: Action) -> Vec { Action::BulkItemDone { index, error } => { if let Screen::Main(m) = &mut app.screen { if index >= m.bulk_import.results.len() { - app.status = Some(StatusMsg { text: format!("Import error: unexpected index {index}"), is_error: true }); + app.status = Some(StatusMsg { + text: format!("Import error: unexpected index {index}"), + is_error: true, + }); m.bulk_import.stage = BulkImportStage::Done; return vec![]; } @@ -757,10 +966,16 @@ pub fn update(app: &mut App, action: Action) -> Vec { if let Screen::Main(m) = &app.screen { let url = m.settings.api_url.trim().to_string(); if url.is_empty() { - app.status = Some(StatusMsg { text: "URL required".into(), is_error: true }); + app.status = Some(StatusMsg { + text: "URL required".into(), + is_error: true, + }); return vec![]; } - app.status = Some(StatusMsg { text: "Settings saved".into(), is_error: false }); + app.status = Some(StatusMsg { + text: "Settings saved".into(), + is_error: false, + }); app.api_url = url.clone(); return vec![Command::SaveConfig(url)]; } @@ -784,7 +999,10 @@ mod tests { fn setup_app() -> App { App { - screen: Screen::Setup(SetupState { api_url: String::new(), error: None }), + screen: Screen::Setup(SetupState { + api_url: String::new(), + error: None, + }), token: None, loading: false, status: None, @@ -814,8 +1032,18 @@ mod tests { fn diary_entry() -> DiaryEntryDto { DiaryEntryDto { - movie: MovieDto { id: Uuid::new_v4(), title: "The Matrix".into(), release_year: 1999, director: None }, - review: ReviewDto { id: Uuid::new_v4(), rating: 5, comment: None, watched_at: "1999-03-31T00:00:00".into() }, + movie: MovieDto { + id: Uuid::new_v4(), + title: "The Matrix".into(), + release_year: 1999, + director: None, + }, + review: ReviewDto { + id: Uuid::new_v4(), + rating: 5, + comment: None, + watched_at: "1999-03-31T00:00:00".into(), + }, } } @@ -828,7 +1056,9 @@ mod tests { update(&mut app, Action::InputChar('i')); if let Screen::Setup(s) = &app.screen { assert_eq!(s.api_url, "hi"); - } else { panic!("expected Setup"); } + } else { + panic!("expected Setup"); + } } #[test] @@ -838,7 +1068,9 @@ mod tests { assert!(cmds.is_empty()); if let Screen::Setup(s) = &app.screen { assert!(s.error.is_some()); - } else { panic!("expected Setup"); } + } else { + panic!("expected Setup"); + } } #[test] @@ -859,7 +1091,9 @@ mod tests { if let Screen::Login(s) = &app.screen { assert_eq!(s.email, "a"); assert_eq!(s.password, ""); - } else { panic!(); } + } else { + panic!(); + } } #[test] @@ -868,7 +1102,9 @@ mod tests { update(&mut app, Action::FocusNext); if let Screen::Login(s) = &app.screen { assert_eq!(s.focused, LoginField::Password); - } else { panic!(); } + } else { + panic!(); + } } #[test] @@ -878,15 +1114,21 @@ mod tests { update(&mut app, Action::InputChar('x')); if let Screen::Login(s) = &app.screen { assert_eq!(s.password, "x"); - } else { panic!(); } + } else { + panic!(); + } } #[test] fn login_submit_returns_login_command_and_sets_loading() { let mut app = login_app(); - for c in "user@example.com".chars() { update(&mut app, Action::InputChar(c)); } + for c in "user@example.com".chars() { + update(&mut app, Action::InputChar(c)); + } update(&mut app, Action::FocusNext); - for c in "pass123".chars() { update(&mut app, Action::InputChar(c)); } + for c in "pass123".chars() { + update(&mut app, Action::InputChar(c)); + } let cmds = update(&mut app, Action::LoginSubmit); assert!(cmds.iter().any(|c| matches!(c, Command::Login { .. }))); assert!(app.loading); @@ -925,34 +1167,55 @@ mod tests { #[test] fn diary_scroll_down_increments_selected() { let mut app = main_app(); - update(&mut app, Action::DiaryLoaded { - entries: vec![diary_entry(), diary_entry(), diary_entry()], - total: 3, - }); + update( + &mut app, + Action::DiaryLoaded { + entries: vec![diary_entry(), diary_entry(), diary_entry()], + total: 3, + }, + ); update(&mut app, Action::ScrollDown); if let Screen::Main(m) = &app.screen { assert_eq!(m.diary.selected, 1); - } else { panic!(); } + } else { + panic!(); + } } #[test] fn diary_scroll_up_clamps_at_zero() { let mut app = main_app(); - update(&mut app, Action::DiaryLoaded { entries: vec![diary_entry()], total: 1 }); + update( + &mut app, + Action::DiaryLoaded { + entries: vec![diary_entry()], + total: 1, + }, + ); update(&mut app, Action::ScrollUp); if let Screen::Main(m) = &app.screen { assert_eq!(m.diary.selected, 0); - } else { panic!(); } + } else { + panic!(); + } } #[test] fn diary_scroll_down_clamps_at_last_entry() { let mut app = main_app(); - update(&mut app, Action::DiaryLoaded { entries: vec![diary_entry()], total: 1 }); + update( + &mut app, + Action::DiaryLoaded { + entries: vec![diary_entry()], + total: 1, + }, + ); update(&mut app, Action::ScrollDown); if let Screen::Main(m) = &app.screen { assert_eq!(m.diary.selected, 0); - } else { panic!(); } + } else { + panic!(); + } } #[test] @@ -960,11 +1223,19 @@ mod tests { let mut app = main_app(); let entry = diary_entry(); let review_id = entry.review.id; - update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); + update( + &mut app, + Action::DiaryLoaded { + entries: vec![entry], + total: 1, + }, + ); update(&mut app, Action::DeleteInit); if let Screen::Main(m) = &app.screen { assert_eq!(m.diary.delete_pending, Some(review_id)); - } else { panic!(); } + } else { + panic!(); + } } #[test] @@ -972,22 +1243,39 @@ mod tests { let mut app = main_app(); let entry = diary_entry(); let review_id = entry.review.id; - update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); + update( + &mut app, + Action::DiaryLoaded { + entries: vec![entry], + total: 1, + }, + ); update(&mut app, Action::DeleteInit); let cmds = update(&mut app, Action::DeleteConfirm); - assert!(cmds.iter().any(|c| matches!(c, Command::DeleteReview(id) if *id == review_id))); + assert!( + cmds.iter() + .any(|c| matches!(c, Command::DeleteReview(id) if *id == review_id)) + ); } #[test] fn delete_cancel_clears_pending() { let mut app = main_app(); let entry = diary_entry(); - update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); + update( + &mut app, + Action::DiaryLoaded { + entries: vec![entry], + total: 1, + }, + ); update(&mut app, Action::DeleteInit); update(&mut app, Action::DeleteCancel); if let Screen::Main(m) = &app.screen { assert!(m.diary.delete_pending.is_none()); - } else { panic!(); } + } else { + panic!(); + } } #[test] @@ -995,12 +1283,20 @@ mod tests { let mut app = main_app(); let entry = diary_entry(); let review_id = entry.review.id; - update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); + update( + &mut app, + Action::DiaryLoaded { + entries: vec![entry], + total: 1, + }, + ); update(&mut app, Action::ReviewDeleted(review_id)); if let Screen::Main(m) = &app.screen { assert!(m.diary.entries.is_empty()); assert_eq!(m.diary.total, 0); - } else { panic!(); } + } else { + panic!(); + } } // ── Add Review ──────────────────────────────────────────────────────────── @@ -1008,17 +1304,27 @@ mod tests { #[test] fn rating_up_increments_rating() { let mut app = main_app(); - if let Screen::Main(m) = &mut app.screen { m.tab = Tab::AddReview; m.add_review.rating = 3; } + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::AddReview; + m.add_review.rating = 3; + } update(&mut app, Action::RatingUp); - if let Screen::Main(m) = &app.screen { assert_eq!(m.add_review.rating, 4); } + if let Screen::Main(m) = &app.screen { + assert_eq!(m.add_review.rating, 4); + } } #[test] fn rating_clamps_at_5() { let mut app = main_app(); - if let Screen::Main(m) = &mut app.screen { m.tab = Tab::AddReview; m.add_review.rating = 5; } + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::AddReview; + m.add_review.rating = 5; + } update(&mut app, Action::RatingUp); - if let Screen::Main(m) = &app.screen { assert_eq!(m.add_review.rating, 5); } + if let Screen::Main(m) = &app.screen { + assert_eq!(m.add_review.rating, 5); + } } #[test] @@ -1054,19 +1360,17 @@ mod tests { if let Screen::Main(m) = &mut app.screen { m.tab = Tab::BulkImport; m.bulk_import.stage = BulkImportStage::Preview; - m.bulk_import.parsed = vec![ - ParsedRow { - row: 2, - result: Ok(LogReviewRequest { - external_metadata_id: None, - manual_title: Some("The Matrix".into()), - manual_release_year: None, - rating: 5, - comment: None, - watched_at: "1999-03-31T00:00:00".into(), - }), - }, - ]; + m.bulk_import.parsed = vec![ParsedRow { + row: 2, + result: Ok(LogReviewRequest { + external_metadata_id: None, + manual_title: Some("The Matrix".into()), + manual_release_year: None, + rating: 5, + comment: None, + watched_at: "1999-03-31T00:00:00".into(), + }), + }]; } let cmds = update(&mut app, Action::BulkImportAll); assert!(cmds.iter().any(|c| matches!(c, Command::ImportNext(0)))); @@ -1079,12 +1383,32 @@ mod tests { m.tab = Tab::BulkImport; m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; m.bulk_import.valid_requests = vec![ - LogReviewRequest { external_metadata_id: None, manual_title: Some("A".into()), manual_release_year: None, rating: 5, comment: None, watched_at: "2024-01-01T00:00:00".into() }, - LogReviewRequest { external_metadata_id: None, manual_title: Some("B".into()), manual_release_year: None, rating: 4, comment: None, watched_at: "2024-01-02T00:00:00".into() }, + LogReviewRequest { + external_metadata_id: None, + manual_title: Some("A".into()), + manual_release_year: None, + rating: 5, + comment: None, + watched_at: "2024-01-01T00:00:00".into(), + }, + LogReviewRequest { + external_metadata_id: None, + manual_title: Some("B".into()), + manual_release_year: None, + rating: 4, + comment: None, + watched_at: "2024-01-02T00:00:00".into(), + }, ]; m.bulk_import.results = vec![None, None]; } - let cmds = update(&mut app, Action::BulkItemDone { index: 0, error: None }); + let cmds = update( + &mut app, + Action::BulkItemDone { + index: 0, + error: None, + }, + ); assert!(cmds.iter().any(|c| matches!(c, Command::ImportNext(1)))); } @@ -1094,12 +1418,23 @@ mod tests { if let Screen::Main(m) = &mut app.screen { m.tab = Tab::BulkImport; m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; - m.bulk_import.valid_requests = vec![ - LogReviewRequest { external_metadata_id: None, manual_title: Some("A".into()), manual_release_year: None, rating: 5, comment: None, watched_at: "2024-01-01T00:00:00".into() }, - ]; + m.bulk_import.valid_requests = vec![LogReviewRequest { + external_metadata_id: None, + manual_title: Some("A".into()), + manual_release_year: None, + rating: 5, + comment: None, + watched_at: "2024-01-01T00:00:00".into(), + }]; m.bulk_import.results = vec![None]; } - let cmds = update(&mut app, Action::BulkItemDone { index: 0, error: None }); + let cmds = update( + &mut app, + Action::BulkItemDone { + index: 0, + error: None, + }, + ); assert!(cmds.is_empty()); if let Screen::Main(m) = &app.screen { assert!(matches!(m.bulk_import.stage, BulkImportStage::Done)); @@ -1117,7 +1452,10 @@ mod tests { m.settings.api_url = "http://new-server:8080".into(); } let cmds = update(&mut app, Action::SettingsSave); - assert!(cmds.iter().any(|c| matches!(c, Command::SaveConfig(url) if url.contains("8080")))); + assert!( + cmds.iter() + .any(|c| matches!(c, Command::SaveConfig(url) if url.contains("8080"))) + ); } #[test] @@ -1136,7 +1474,9 @@ mod tests { update(&mut app, Action::AuthOk("tok".into())); if let Screen::Main(m) = &app.screen { assert_eq!(m.settings.api_url, "http://test-server:9000"); - } else { panic!("expected Main"); } + } else { + panic!("expected Main"); + } } // ── parse_csv ───────────────────────────────────────────────────────────── diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 375c2f7..3d14fa8 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -123,7 +123,10 @@ impl ApiClient { let resp = self .http .post(format!("{}/api/auth/login", self.url())) - .json(&LoginRequest { email: email.into(), password: password.into() }) + .json(&LoginRequest { + email: email.into(), + password: password.into(), + }) .send() .await?; Ok(check_status(resp).await?.json().await?) @@ -159,11 +162,7 @@ impl ApiClient { Ok(check_status(resp).await?.json().await?) } - pub async fn create_review( - &self, - token: &str, - req: &LogReviewRequest, - ) -> Result<(), ApiError> { + pub async fn create_review(&self, token: &str, req: &LogReviewRequest) -> Result<(), ApiError> { let resp = self .http .post(format!("{}/api/reviews", self.url())) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 2fe85a8..c3f614a 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -83,7 +83,9 @@ mod tests { #[test] fn config_roundtrip() { - let config = Config { api_url: "http://localhost:3000".into() }; + let config = Config { + api_url: "http://localhost:3000".into(), + }; let json = serde_json::to_string(&config).unwrap(); let decoded: Config = serde_json::from_str(&json).unwrap(); assert_eq!(decoded.api_url, "http://localhost:3000"); diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index ab64a39..9b723a2 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -4,9 +4,7 @@ use tokio::sync::mpsc; use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers}; -use tui::app::{ - self, Action, App, BulkImportStage, Command, Screen, SettingsField, Tab, -}; +use tui::app::{self, Action, App, BulkImportStage, Command, Screen, SettingsField, Tab}; use tui::client::ApiClient; use tui::config::Config; @@ -29,9 +27,14 @@ async fn run() -> anyhow::Result<()> { } } - let initial_url = config.as_ref().map(|c| c.api_url.as_str()).unwrap_or("http://localhost:3000"); + let initial_url = config + .as_ref() + .map(|c| c.api_url.as_str()) + .unwrap_or("http://localhost:3000"); let client = Arc::new(ApiClient::new(initial_url)); - let saved_token = tokio::task::spawn_blocking(Config::load_token).await.unwrap_or(None); + let saved_token = tokio::task::spawn_blocking(Config::load_token) + .await + .unwrap_or(None); let mut app = App::new(config, saved_token.clone()); let (tx, mut rx) = mpsc::channel::(64); @@ -45,7 +48,10 @@ async fn run() -> anyhow::Result<()> { 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 }, + Ok(r) => Action::DiaryLoaded { + entries: r.items, + total: r.total_count, + }, Err(e) => Action::DiaryLoadFailed(e.to_string()), }; let _ = tx2.send(action).await; @@ -84,7 +90,8 @@ async fn run() -> anyhow::Result<()> { } } Ok::<(), anyhow::Error>(()) - }.await; + } + .await; ratatui::restore(); result @@ -95,11 +102,15 @@ async fn run() -> anyhow::Result<()> { fn handle_command(cmd: Command, app: &App, client: &Arc, tx: &mpsc::Sender) { match cmd { Command::SaveConfig(url) => { - let config = Config { api_url: url.clone() }; + let config = Config { + api_url: url.clone(), + }; if let Err(e) = config.save() { let tx2 = tx.clone(); let msg = format!("Failed to save config: {e}"); - tokio::spawn(async move { let _ = tx2.send(Action::DiaryLoadFailed(msg)).await; }); + tokio::spawn(async move { + let _ = tx2.send(Action::DiaryLoadFailed(msg)).await; + }); } client.update_url(&url); } @@ -136,12 +147,17 @@ fn handle_command(cmd: Command, app: &App, client: &Arc, tx: &mpsc::S } Command::LoadDiary { offset } => { - let Some(token) = app.token.clone() else { return }; + let Some(token) = app.token.clone() else { + return; + }; let c = client.clone(); let tx = tx.clone(); tokio::spawn(async move { let action = match c.get_diary(&token, offset, 20).await { - Ok(r) => Action::DiaryLoaded { entries: r.items, total: r.total_count }, + Ok(r) => Action::DiaryLoaded { + entries: r.items, + total: r.total_count, + }, Err(e) => Action::DiaryLoadFailed(e.to_string()), }; let _ = tx.send(action).await; @@ -149,7 +165,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc, tx: &mpsc::S } Command::LoadHistory { movie_id } => { - let Some(token) = app.token.clone() else { return }; + let Some(token) = app.token.clone() else { + return; + }; let c = client.clone(); let tx = tx.clone(); tokio::spawn(async move { @@ -162,7 +180,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc, tx: &mpsc::S } Command::CreateReview(req) => { - let Some(token) = app.token.clone() else { return }; + let Some(token) = app.token.clone() else { + return; + }; let c = client.clone(); let tx = tx.clone(); tokio::spawn(async move { @@ -175,7 +195,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc, tx: &mpsc::S } Command::DeleteReview(id) => { - let Some(token) = app.token.clone() else { return }; + let Some(token) = app.token.clone() else { + return; + }; let c = client.clone(); let tx = tx.clone(); tokio::spawn(async move { @@ -188,7 +210,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc, tx: &mpsc::S } Command::ImportNext(index) => { - let Some(token) = app.token.clone() else { return }; + let Some(token) = app.token.clone() else { + return; + }; let req = match &app.screen { Screen::Main(m) => match m.bulk_import.valid_requests.get(index) { Some(r) => r.clone(), @@ -199,7 +223,11 @@ fn handle_command(cmd: Command, app: &App, client: &Arc, tx: &mpsc::S let c = client.clone(); let tx = tx.clone(); tokio::spawn(async move { - let error = c.create_review(&token, &req).await.err().map(|e| e.to_string()); + let error = c + .create_review(&token, &req) + .await + .err() + .map(|e| e.to_string()); let _ = tx.send(Action::BulkItemDone { index, error }).await; }); } @@ -248,8 +276,12 @@ fn key_to_action(app: &App, key: ratatui::crossterm::event::KeyEvent) -> Option< KeyCode::Down | KeyCode::Char('j') => Some(Action::ScrollDown), KeyCode::Enter => Some(Action::OpenHistory), KeyCode::Char('d') => Some(Action::DeleteInit), - KeyCode::Char('y') if m.diary.delete_pending.is_some() => Some(Action::DeleteConfirm), - KeyCode::Char('n') if m.diary.delete_pending.is_some() => Some(Action::DeleteCancel), + KeyCode::Char('y') if m.diary.delete_pending.is_some() => { + Some(Action::DeleteConfirm) + } + KeyCode::Char('n') if m.diary.delete_pending.is_some() => { + Some(Action::DeleteCancel) + } KeyCode::Esc => Some(Action::Escape), KeyCode::Char('q') => Some(Action::Quit), KeyCode::Tab => Some(Action::TabNext), diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index a62c154..a59ab73 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -99,8 +99,20 @@ fn draw_login(frame: &mut Frame, area: Rect, state: &LoginState) { .split(popup); let pass_masked = "*".repeat(state.password.len()); - render_input(frame, rows[1], "Email", &state.email, state.focused == LoginField::Email); - render_input(frame, rows[3], "Password", &pass_masked, state.focused == LoginField::Password); + render_input( + frame, + rows[1], + "Email", + &state.email, + state.focused == LoginField::Email, + ); + render_input( + frame, + rows[3], + "Password", + &pass_masked, + state.focused == LoginField::Password, + ); frame.render_widget( Paragraph::new("Tab: next field Enter: login").alignment(Alignment::Center), rows[4], @@ -175,9 +187,16 @@ fn draw_diary(frame: &mut Frame, area: Rect, state: &DiaryState) { let can_load_prev = state.offset > 0; let page = state.offset / 20 + 1; let total_pages = state.total.div_ceil(20).max(1); - let mut title = format!(" Diary ({} entries, page {}/{}) ", state.total, page, total_pages); - if can_load_prev { title.push_str("[b: prev] "); } - if can_load_more { title.push_str("[m: next] "); } + let mut title = format!( + " Diary ({} entries, page {}/{}) ", + state.total, page, total_pages + ); + if can_load_prev { + title.push_str("[b: prev] "); + } + if can_load_more { + title.push_str("[m: next] "); + } let mut list_state = ListState::default(); list_state.select(Some(state.selected)); let list = List::new(items).block(Block::default().title(title).borders(Borders::ALL)); @@ -273,23 +292,61 @@ fn draw_add_review(frame: &mut Frame, area: Rect, state: &AddReviewState) { ]) .split(inner); - render_input(frame, rows[0], "External ID (TMDB/OMDB)", &state.external_id, state.focused == AddReviewField::ExternalId); - render_input(frame, rows[1], "Title", &state.title, state.focused == AddReviewField::Title); - render_input(frame, rows[2], "Year", &state.year, state.focused == AddReviewField::Year); + render_input( + frame, + rows[0], + "External ID (TMDB/OMDB)", + &state.external_id, + state.focused == AddReviewField::ExternalId, + ); + render_input( + frame, + rows[1], + "Title", + &state.title, + state.focused == AddReviewField::Title, + ); + render_input( + frame, + rows[2], + "Year", + &state.year, + state.focused == AddReviewField::Year, + ); let rating_active = state.focused == AddReviewField::Rating; frame.render_widget( - Paragraph::new(format!("{} \u{2190} \u{2192} to adjust", stars(state.rating))).block( + Paragraph::new(format!( + "{} \u{2190} \u{2192} to adjust", + stars(state.rating) + )) + .block( Block::default() .title("Rating (0-5)") .borders(Borders::ALL) - .border_style(if rating_active { Style::default().fg(Color::Yellow) } else { Style::default() }), + .border_style(if rating_active { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }), ), rows[3], ); - render_input(frame, rows[4], "Watched at (YYYY-MM-DDTHH:MM:SS)", &state.watched_at, state.focused == AddReviewField::WatchedAt); - render_input(frame, rows[5], "Comment (optional)", &state.comment, state.focused == AddReviewField::Comment); + render_input( + frame, + rows[4], + "Watched at (YYYY-MM-DDTHH:MM:SS)", + &state.watched_at, + state.focused == AddReviewField::WatchedAt, + ); + render_input( + frame, + rows[5], + "Comment (optional)", + &state.comment, + state.focused == AddReviewField::Comment, + ); let submit_style = if state.focused == AddReviewField::Submit { Style::default() @@ -507,7 +564,13 @@ fn draw_settings(frame: &mut Frame, area: Rect, state: &SettingsState) { ]) .split(inner); - render_input(frame, rows[0], "API URL", &state.api_url, state.focused == SettingsField::ApiUrl); + render_input( + frame, + rows[0], + "API URL", + &state.api_url, + state.focused == SettingsField::ApiUrl, + ); let save_style = if state.focused == SettingsField::Save { Style::default() @@ -555,11 +618,22 @@ fn draw_status_bar(frame: &mut Frame, area: Rect, status: Option<&StatusMsg>, lo // ── Helpers ─────────────────────────────────────────────────────────────────── fn render_input(frame: &mut Frame, area: Rect, title: &str, value: &str, active: bool) { - let text = if active { format!("{value}_") } else { value.to_string() }; - let border_style = if active { Style::default().fg(Color::Yellow) } else { Style::default() }; + let text = if active { + format!("{value}_") + } else { + value.to_string() + }; + let border_style = if active { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; frame.render_widget( Paragraph::new(text).block( - Block::default().title(title).borders(Borders::ALL).border_style(border_style), + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style), ), area, );