use crate::state::{ CacheBehaviour, ContentFile, ContentItem, ContentItemUpdate, ContentOwnershipKind, ContentProvider, ContentSet, ContentUpdatePlan, ContentUpdatePlanAction, ContentUpdateResolution, ContentUpdateResolutionChoice, ContentUpdateScope, Dependency, InstanceContentSnapshot, InstanceInstallCandidate, InstanceInstallTarget, LinkedModpackInfo, ProjectType, State, }; use dashmap::DashMap; use std::sync::LazyLock; static CONTENT_UPDATE_PLANS: LazyLock> = LazyLock::new(DashMap::new); #[tracing::instrument] pub async fn sync_content_files( instance_id: &str, ) -> crate::Result> { let state = State::get().await?; crate::state::sync_content_files(instance_id, &state).await } #[tracing::instrument] pub async fn list_content_sets( instance_id: &str, ) -> crate::Result> { let state = State::get().await?; crate::state::list_content_sets(instance_id, &state.pool).await } #[tracing::instrument] pub async fn get_projects( instance_id: &str, cache_behaviour: Option, ) -> crate::Result> { let state = State::get().await?; crate::state::get_content_projects( instance_id, None, cache_behaviour, &state, ) .await } #[tracing::instrument] pub async fn get_installed_project_ids( instance_id: &str, ) -> crate::Result> { let state = State::get().await?; crate::state::get_installed_project_ids_for_instance( instance_id, None, &state, ) .await } #[tracing::instrument] pub async fn get_install_candidates( project_id: &str, project_type: ProjectType, targets: Vec, ) -> crate::Result> { let state = State::get().await?; crate::state::get_instance_install_candidates( project_id, project_type, &targets, &state.pool, ) .await } #[tracing::instrument] pub async fn get_content_items( instance_id: &str, cache_behaviour: Option, ) -> crate::Result> { let state = State::get().await?; crate::state::list_content(instance_id, None, cache_behaviour, &state).await } #[tracing::instrument] pub async fn get_content_items_by_paths( instance_id: &str, paths: Vec, cache_behaviour: Option, ) -> crate::Result> { let state = State::get().await?; crate::state::list_content_by_paths( instance_id, &paths, cache_behaviour, &state, ) .await } #[tracing::instrument] pub async fn get_content_snapshot( instance_id: &str, ) -> crate::Result { let state = State::get().await?; crate::state::get_content_snapshot(instance_id, false, &state).await } #[tracing::instrument] pub async fn refresh_content( instance_id: &str, ) -> crate::Result { let state = State::get().await?; require_installed_instance(instance_id, &state).await?; crate::state::get_content_snapshot(instance_id, true, &state).await } #[tracing::instrument] pub async fn plan_content_updates( instance_id: &str, scope: ContentUpdateScope, target: Option<&str>, ) -> crate::Result { let state = State::get().await?; let snapshot = crate::state::get_content_snapshot(instance_id, true, &state).await?; let mut actions = Vec::new(); match scope { ContentUpdateScope::UserAdded | ContentUpdateScope::Item => { if scope == ContentUpdateScope::Item && target.is_none() { return Err(crate::ErrorKind::InputError( "An item update plan requires a stable content ID" .to_string(), ) .into()); } for item in &snapshot.items { let content_id = item .entry_id .as_deref() .or(item.member_id.as_deref()) .or(item.file_id.as_deref()); let selected = update_scope_selects_item(scope, target, item, content_id); if !selected { continue; } let Some(content) = item.content.as_ref() else { continue; }; let Some(update) = content.update.as_ref() else { continue; }; let Some(content_id) = content_id else { continue; }; actions.push(update_action( content_id.to_string(), Some(item.expected_relative_path.clone()), item.ownership_kind, update, )); } } ContentUpdateScope::Pack => { let pack = snapshot.pack.as_ref().ok_or_else(|| { crate::ErrorKind::InputError( "This instance is not linked to a managed pack".to_string(), ) })?; if !pack.reconciled { return Err(crate::ErrorKind::InputError( "Pack membership is not calibrated yet; refresh while online before updating the pack" .to_string(), ) .into()); } if let Some(target_release_id) = target { let provider = pack.provider.ok_or_else(|| { crate::ErrorKind::InputError( "This pack has no managed provider".to_string(), ) })?; actions.push(ContentUpdatePlanAction { content_id: "pack".to_string(), relative_path: None, ownership_kind: ContentOwnershipKind::PackManaged, provider, current_release_id: pack.version_id.clone(), target_release_id: target_release_id.to_string(), }); } else if let Some(update) = pack .metadata .as_ref() .and_then(|metadata| metadata.update.as_ref()) { actions.push(update_action( "pack".to_string(), None, ContentOwnershipKind::PackManaged, update, )); } } } let plan = ContentUpdatePlan { id: format!("content-update-plan:{}", uuid::Uuid::new_v4()), instance_id: instance_id.to_string(), revision: snapshot.revision, scope, actions, warnings: snapshot .warnings .into_iter() .map(|warning| warning.message) .collect(), }; CONTENT_UPDATE_PLANS.insert(plan.id.clone(), plan.clone()); Ok(plan) } #[tracing::instrument] pub async fn apply_content_update_plan( plan_id: &str, resolutions: Vec, ) -> crate::Result { let plan = CONTENT_UPDATE_PLANS .get(plan_id) .map(|entry| entry.clone()) .ok_or_else(|| { crate::ErrorKind::InputError( "The content update plan has expired".to_string(), ) })?; let state = State::get().await?; let current_revision = crate::state::instances::adapters::sqlite::content_rows::get_applied_content_set( &plan.instance_id, &state.pool, ) .await? .ok_or_else(|| { crate::ErrorKind::InputError( "Instance has no applied content set".to_string(), ) })? .revision; if let Err(error) = ensure_update_plan_revision(plan.revision, current_revision) { CONTENT_UPDATE_PLANS.remove(plan_id); return Err(error); } for resolution in resolutions { if resolution.choice == ContentUpdateResolutionChoice::RestorePackDefault { super::projects::restore_pack_member_default( &plan.instance_id, &resolution.content_id, ) .await?; } } if plan.scope == ContentUpdateScope::Pack { if let Some(action) = plan.actions.first() { match action.provider { ContentProvider::Modrinth => { super::projects::update_managed_modrinth_version( &plan.instance_id, &action.target_release_id, ) .await?; } ContentProvider::CurseForge => { let file_id = action .target_release_id .parse::() .map_err(|_| { crate::ErrorKind::InputError( "The planned CurseForge file ID is invalid" .to_string(), ) })?; crate::install::runner::update_managed_curseforge_modpack( plan.instance_id.clone(), file_id, ) .await?; } ContentProvider::McArchive => { return Err(crate::ErrorKind::InputError( "MCArchive does not support automatic pack updates" .to_string(), ) .into()); } ContentProvider::Local => {} } } } else { for action in &plan.actions { super::projects::switch_content_entry_version( &plan.instance_id, &action.content_id, &action.target_release_id, ) .await?; } } CONTENT_UPDATE_PLANS.remove(plan_id); crate::state::get_content_snapshot(&plan.instance_id, false, &state).await } fn update_action( content_id: String, relative_path: Option, ownership_kind: ContentOwnershipKind, update: &ContentItemUpdate, ) -> ContentUpdatePlanAction { match update { ContentItemUpdate::Modrinth { current_version_id, target_version_id, .. } => ContentUpdatePlanAction { content_id, relative_path, ownership_kind, provider: ContentProvider::Modrinth, current_release_id: Some(current_version_id.to_string()), target_release_id: target_version_id.to_string(), }, ContentItemUpdate::CurseForge { current_file_id, target_file_id, .. } => ContentUpdatePlanAction { content_id, relative_path, ownership_kind, provider: ContentProvider::CurseForge, current_release_id: Some(current_file_id.get().to_string()), target_release_id: target_file_id.get().to_string(), }, } } fn update_scope_selects_item( scope: ContentUpdateScope, target: Option<&str>, item: &crate::state::InstanceContentSnapshotItem, content_id: Option<&str>, ) -> bool { match scope { ContentUpdateScope::UserAdded => { item.ownership_kind == ContentOwnershipKind::UserAdded } ContentUpdateScope::Item => content_id == target, ContentUpdateScope::Pack => false, } } fn ensure_update_plan_revision( planned_revision: u64, current_revision: u64, ) -> crate::Result<()> { if planned_revision == current_revision { return Ok(()); } Err(crate::ErrorKind::InputError(format!( "The content update plan is stale (planned revision {planned_revision}, current revision {current_revision})" )) .into()) } #[tracing::instrument] pub async fn get_linked_modpack_content( instance_id: &str, cache_behaviour: Option, ) -> crate::Result> { let state = State::get().await?; crate::state::list_linked_modpack_content( instance_id, None, cache_behaviour, &state, ) .await } #[tracing::instrument] pub async fn get_dependencies_as_content_items( dependencies: Vec, cache_behaviour: Option, ) -> crate::Result> { let state = State::get().await?; crate::state::dependencies_to_content_items( &dependencies, cache_behaviour, &state.pool, &state.api_semaphore, ) .await } #[tracing::instrument] pub async fn get_linked_modpack_info( instance_id: &str, cache_behaviour: Option, ) -> crate::Result> { let state = State::get().await?; require_installed_instance(instance_id, &state).await?; crate::state::get_linked_modpack_info( instance_id, None, cache_behaviour, &state, ) .await } async fn require_installed_instance( instance_id: &str, state: &State, ) -> crate::Result<()> { let metadata = crate::state::instances::commands::get_instance_metadata( instance_id, &state.pool, ) .await? .ok_or_else(|| { crate::ErrorKind::UnmanagedInstanceError(instance_id.to_string()) })?; let stage = metadata.instance.install_stage; if stage != crate::state::InstanceInstallStage::Installed { return Err(crate::ErrorKind::InstanceNotReady { instance_id: instance_id.to_string(), stage: stage.as_str().to_string(), } .into()); } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::state::instances::{ ContentItemCapabilities, InstanceContentSnapshotItem, PackMemberMaterializationState, PackMemberOverrideKind, }; fn snapshot_item( ownership_kind: ContentOwnershipKind, entry_id: Option<&str>, member_id: Option<&str>, file_id: Option<&str>, ) -> InstanceContentSnapshotItem { InstanceContentSnapshotItem { file_id: file_id.map(str::to_string), entry_id: entry_id.map(str::to_string), member_id: member_id.map(str::to_string), ownership_kind, materialization_state: PackMemberMaterializationState::Present, override_kind: PackMemberOverrideKind::None, expected_relative_path: "mods/test.jar".to_string(), required: true, project_type: ProjectType::Mod, provider: None, provider_project_id: None, provider_release_id: None, content: None, capabilities: ContentItemCapabilities::default(), dependency: None, } } #[test] fn user_added_update_scope_excludes_pack_and_discovered_content() { let user_added = snapshot_item( ContentOwnershipKind::UserAdded, Some("user-entry"), None, Some("user-file"), ); let pack_managed = snapshot_item( ContentOwnershipKind::PackManaged, Some("pack-entry"), Some("pack-member"), Some("pack-file"), ); let discovered = snapshot_item( ContentOwnershipKind::LocalDiscovered, None, None, Some("discovered-file"), ); assert!(update_scope_selects_item( ContentUpdateScope::UserAdded, None, &user_added, user_added.entry_id.as_deref(), )); assert!(!update_scope_selects_item( ContentUpdateScope::UserAdded, None, &pack_managed, pack_managed.entry_id.as_deref(), )); assert!(!update_scope_selects_item( ContentUpdateScope::UserAdded, None, &discovered, discovered.file_id.as_deref(), )); } #[test] fn update_plan_revision_rejects_stale_plans() { assert!(ensure_update_plan_revision(7, 7).is_ok()); let error = ensure_update_plan_revision(7, 8).unwrap_err(); assert!(error.to_string().contains("planned revision 7")); assert!(error.to_string().contains("current revision 8")); } }