refactor: group use cases into DDD bounded contexts

Flat use_cases/ (44 files) + monolithic commands.rs/queries.rs
split into diary/, movies/, watchlist/, import/, auth/, users/,
integrations/, search/, person/, federation/ — each with own
commands.rs, queries.rs, and use case modules.

Inline tests extracted to sibling tests/ dirs.
This commit is contained in:
2026-06-02 19:49:09 +02:00
parent aadad3cfb0
commit dcc9244d4e
92 changed files with 1617 additions and 1500 deletions

View File

@@ -0,0 +1,185 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::Movie,
ports::{MetadataClient, MetadataSearchCriteria, MovieRepository},
value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear},
};
use crate::diary::commands::MovieInput;
pub struct MovieResolverDeps<'a> {
pub repository: &'a dyn MovieRepository,
pub metadata_client: &'a dyn MetadataClient,
}
#[async_trait]
pub trait ResolutionStrategy: Send + Sync {
fn can_handle(&self, input: &MovieInput) -> bool;
async fn resolve(
&self,
input: &MovieInput,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError>;
}
pub struct ExternalIdStrategy;
pub struct TitleSearchStrategy;
pub struct ManualMovieStrategy;
pub struct MovieResolver {
strategies: Vec<Box<dyn ResolutionStrategy>>,
}
impl MovieResolver {
pub fn default_pipeline() -> Self {
Self {
strategies: vec![
Box::new(ExternalIdStrategy),
Box::new(TitleSearchStrategy),
Box::new(ManualMovieStrategy),
],
}
}
pub async fn resolve(
&self,
input: &MovieInput,
deps: &MovieResolverDeps<'_>,
) -> Result<(Movie, bool), DomainError> {
for strategy in &self.strategies {
if strategy.can_handle(input)
&& let Some(result) = strategy.resolve(input, deps).await?
{
return Ok(result);
}
}
Err(DomainError::ValidationError(
"Manual title required if TMDB fetch fails or is omitted".into(),
))
}
}
#[async_trait]
impl ResolutionStrategy for ExternalIdStrategy {
fn can_handle(&self, input: &MovieInput) -> bool {
input.external_metadata_id.is_some()
}
async fn resolve(
&self,
input: &MovieInput,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError> {
let ext_id_str = input.external_metadata_id.as_deref().unwrap();
let tmdb_id = ExternalMetadataId::new(ext_id_str.to_string())?;
if let Some(m) = deps.repository.get_movie_by_external_id(&tmdb_id).await? {
return Ok(Some((m, false)));
}
match deps
.metadata_client
.fetch_movie_metadata(&MetadataSearchCriteria::ImdbId(tmdb_id))
.await
{
Ok(m) => Ok(Some((m, true))),
Err(e) => {
tracing::warn!(
"Failed to fetch from TMDB, falling back to manual entry: {:?}",
e
);
Ok(None)
}
}
}
}
#[async_trait]
impl ResolutionStrategy for TitleSearchStrategy {
fn can_handle(&self, input: &MovieInput) -> bool {
input.manual_title.is_some()
}
async fn resolve(
&self,
input: &MovieInput,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError> {
let title = input.manual_title.as_deref().unwrap();
let criteria = MetadataSearchCriteria::Title {
title: MovieTitle::new(title.to_string())?,
year: input
.manual_release_year
.map(ReleaseYear::new)
.transpose()?,
};
match deps.metadata_client.fetch_movie_metadata(&criteria).await {
Ok(m) => {
// Movie may already exist in DB under this external_metadata_id
if let Some(ext_id) = m.external_metadata_id()
&& let Some(existing) = deps.repository.get_movie_by_external_id(ext_id).await?
{
return Ok(Some((existing, false)));
}
Ok(Some((m, true)))
}
Err(e) => {
tracing::warn!("OMDb title search failed, falling back to manual: {:?}", e);
Ok(None)
}
}
}
}
#[async_trait]
impl ResolutionStrategy for ManualMovieStrategy {
fn can_handle(&self, input: &MovieInput) -> bool {
input.manual_title.is_some()
}
async fn resolve(
&self,
input: &MovieInput,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError> {
let title_str = match &input.manual_title {
Some(t) => t,
None => return Ok(None),
};
let year_val = input.manual_release_year.ok_or_else(|| {
DomainError::ValidationError(
"Manual release year required if TMDB fetch fails or is omitted".into(),
)
})?;
let title = MovieTitle::new(title_str.clone())?;
let release_year = ReleaseYear::new(year_val)?;
let candidates = deps
.repository
.get_movies_by_title_and_year(&title, &release_year)
.await?;
let matched = candidates
.into_iter()
.find(|m| m.is_manual_match(&title, &release_year, input.manual_director.as_deref()));
if let Some(existing) = matched {
Ok(Some((existing, false)))
} else {
let new_movie = Movie::new(
None,
title,
release_year,
input.manual_director.clone(),
None,
);
Ok(Some((new_movie, true)))
}
}
}
#[cfg(test)]
#[path = "tests/movie_resolver.rs"]
mod tests;