531 lines
16 KiB
Rust
531 lines
16 KiB
Rust
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<DashMap<String, ContentUpdatePlan>> =
|
|
LazyLock::new(DashMap::new);
|
|
|
|
#[tracing::instrument]
|
|
pub async fn sync_content_files(
|
|
instance_id: &str,
|
|
) -> crate::Result<Vec<crate::state::instances::InstanceFile>> {
|
|
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<Vec<ContentSet>> {
|
|
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<CacheBehaviour>,
|
|
) -> crate::Result<DashMap<String, ContentFile>> {
|
|
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<Vec<String>> {
|
|
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<InstanceInstallTarget>,
|
|
) -> crate::Result<Vec<InstanceInstallCandidate>> {
|
|
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<CacheBehaviour>,
|
|
) -> crate::Result<Vec<ContentItem>> {
|
|
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<String>,
|
|
cache_behaviour: Option<CacheBehaviour>,
|
|
) -> crate::Result<Vec<ContentItem>> {
|
|
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<InstanceContentSnapshot> {
|
|
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<InstanceContentSnapshot> {
|
|
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<ContentUpdatePlan> {
|
|
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<ContentUpdateResolution>,
|
|
) -> crate::Result<InstanceContentSnapshot> {
|
|
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::<u32>()
|
|
.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<String>,
|
|
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<CacheBehaviour>,
|
|
) -> crate::Result<Vec<ContentItem>> {
|
|
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<Dependency>,
|
|
cache_behaviour: Option<CacheBehaviour>,
|
|
) -> crate::Result<Vec<ContentItem>> {
|
|
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<CacheBehaviour>,
|
|
) -> crate::Result<Option<LinkedModpackInfo>> {
|
|
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"));
|
|
}
|
|
}
|