diff --git a/packages/app-lib/src/api/instance.rs b/packages/app-lib/src/api/instance.rs index 1b5f1baf68..d4155b21df 100644 --- a/packages/app-lib/src/api/instance.rs +++ b/packages/app-lib/src/api/instance.rs @@ -1,7 +1,6 @@ //! Theseus instance management interface mod content; -mod content_set_diff; mod export_mrpack; mod get; mod groups; diff --git a/packages/app-lib/src/api/instance/content_set_diff.rs b/packages/app-lib/src/api/instance/content_set_diff.rs deleted file mode 100644 index 57d1e9c178..0000000000 --- a/packages/app-lib/src/api/instance/content_set_diff.rs +++ /dev/null @@ -1,152 +0,0 @@ -use crate::state::Version; -use std::collections::{HashMap, HashSet}; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ContentSetDiffKind { - Added, - Removed, - Updated, -} - -#[derive(Clone, Debug)] -pub(crate) enum ContentSetDiffEntry { - Project { - kind: ContentSetDiffKind, - project_id: String, - current_version_name: Option, - new_version_name: Option, - disabled: bool, - }, - ExternalFile { - kind: ContentSetDiffKind, - file_name: String, - disabled: bool, - }, -} - -#[derive(Clone, Debug, Default)] -pub(crate) struct ContentSetDiffOptions { - pub removed_disabled_project_ids: HashSet, - pub removed_disabled_external_files: HashSet, - pub common_external_files_are_updated: bool, -} - -#[derive(Clone, Debug, Default)] -pub(crate) struct ContentSetSnapshot { - pub versions: Vec, - pub external_files: HashSet, -} - -#[derive(Clone, Debug)] -pub(crate) struct ContentSetSnapshotVersion { - pub project_id: String, - pub version_id: String, - pub version_name: String, -} - -impl From for ContentSetSnapshotVersion { - fn from(version: Version) -> Self { - Self { - project_id: version.project_id, - version_id: version.id, - version_name: version.version_number, - } - } -} - -pub(crate) fn diff_content_sets( - current: &ContentSetSnapshot, - latest: &ContentSetSnapshot, - options: &ContentSetDiffOptions, -) -> Vec { - let current_versions = versions_by_project(current); - let latest_versions = versions_by_project(latest); - let project_ids = current_versions - .keys() - .chain(latest_versions.keys()) - .copied() - .collect::>(); - - let mut diffs = Vec::new(); - for project_id in project_ids { - let current = current_versions.get(project_id); - let latest = latest_versions.get(project_id); - - match (current, latest) { - (None, Some(latest)) => { - diffs.push(ContentSetDiffEntry::Project { - kind: ContentSetDiffKind::Added, - project_id: project_id.to_string(), - current_version_name: None, - new_version_name: Some(latest.version_name.clone()), - disabled: false, - }); - } - (Some(current), None) => { - diffs.push(ContentSetDiffEntry::Project { - kind: ContentSetDiffKind::Removed, - project_id: project_id.to_string(), - current_version_name: Some(current.version_name.clone()), - new_version_name: None, - disabled: options - .removed_disabled_project_ids - .contains(project_id), - }); - } - (Some(current), Some(latest)) - if current.version_id != latest.version_id => - { - diffs.push(ContentSetDiffEntry::Project { - kind: ContentSetDiffKind::Updated, - project_id: project_id.to_string(), - current_version_name: Some(current.version_name.clone()), - new_version_name: Some(latest.version_name.clone()), - disabled: false, - }); - } - _ => {} - } - } - - for file_name in latest.external_files.difference(¤t.external_files) { - diffs.push(ContentSetDiffEntry::ExternalFile { - kind: ContentSetDiffKind::Added, - file_name: file_name.clone(), - disabled: false, - }); - } - - if options.common_external_files_are_updated { - for file_name in - latest.external_files.intersection(¤t.external_files) - { - diffs.push(ContentSetDiffEntry::ExternalFile { - kind: ContentSetDiffKind::Updated, - file_name: file_name.clone(), - disabled: false, - }); - } - } - - for file_name in current.external_files.difference(&latest.external_files) { - diffs.push(ContentSetDiffEntry::ExternalFile { - kind: ContentSetDiffKind::Removed, - file_name: file_name.clone(), - disabled: options - .removed_disabled_external_files - .contains(file_name), - }); - } - - diffs -} - -fn versions_by_project( - snapshot: &ContentSetSnapshot, -) -> HashMap<&str, &ContentSetSnapshotVersion> { - snapshot - .versions - .iter() - .map(|version| (version.project_id.as_str(), version)) - .collect() -} diff --git a/packages/app-lib/src/api/instance/shared/diff.rs b/packages/app-lib/src/api/instance/shared/diff.rs index 072c9cb661..0d67205da5 100644 --- a/packages/app-lib/src/api/instance/shared/diff.rs +++ b/packages/app-lib/src/api/instance/shared/diff.rs @@ -3,48 +3,44 @@ use super::publish::*; use super::types::*; use super::*; +struct SharedContentSnapshot { + version_ids: Vec, + external_files: BTreeSet, + configuration: ContentSetConfiguration, +} + pub(super) async fn shared_instance_update_diffs( metadata: &crate::state::InstanceMetadata, version: &InstanceVersionResponse, state: &State, ) -> crate::Result> { - let remote_modpack_id = - version.modpack_id.as_deref().filter(|id| !id.is_empty()); - let current_modpack_id = shared_modpack_id(&metadata.link); - let modpack_unlinked = - current_modpack_id.is_some() && remote_modpack_id.is_none(); - let (current_version_ids, current_external_files) = + let before_configuration = local_configuration(metadata); + let after_configuration = remote_configuration(version); + let modpack_unlinked = before_configuration.modpack_version_id.is_some() + && after_configuration.modpack_version_id.is_none(); + let (version_ids, external_files) = current_shared_content(metadata, modpack_unlinked, state).await?; - let (latest_version_ids, latest_external_files) = - remote_shared_content(version); - let removed_disabled_project_ids = HashSet::new(); - let removed_disabled_external_files = HashSet::new(); - let mut diffs = shared_content_diffs( - ¤t_version_ids, - ¤t_external_files, - &latest_version_ids, - &latest_external_files, - &removed_disabled_project_ids, - &removed_disabled_external_files, - true, - state, - ) - .await?; - let mut configuration_diffs = shared_instance_configuration_diffs( - current_modpack_id.as_deref(), - remote_modpack_id, - &metadata.applied_content_set.game_version, - &version.game_version, - metadata.applied_content_set.loader, - version.loader, - metadata.applied_content_set.loader_version.as_deref(), - Some(version.loader_version.as_str()), + let before = SharedContentSnapshot { + version_ids, + external_files, + configuration: before_configuration, + }; + let (version_ids, external_files) = remote_shared_content(version)?; + let after = SharedContentSnapshot { + version_ids, + external_files, + configuration: after_configuration, + }; + + shared_content_diffs( + &before, + &after, + &HashSet::new(), + &BTreeSet::new(), + CommonExternalFilePolicy::AssumeUpdated, state, ) - .await?; - configuration_diffs.append(&mut diffs); - - Ok(configuration_diffs) + .await } pub(super) async fn shared_instance_publish_diffs( @@ -53,11 +49,10 @@ pub(super) async fn shared_instance_publish_diffs( snapshot: &CurrentPublishSnapshot, state: &State, ) -> crate::Result> { - let remote_modpack_id = - version.modpack_id.as_deref().filter(|id| !id.is_empty()); - let current_modpack_id = shared_modpack_id(&metadata.link); - let modpack_unlinked = - remote_modpack_id.is_some() && current_modpack_id.is_none(); + let before_configuration = remote_configuration(version); + let after_configuration = local_configuration(metadata); + let modpack_unlinked = before_configuration.modpack_version_id.is_some() + && after_configuration.modpack_version_id.is_none(); let disabled_versions = async { if snapshot.disabled_version_ids.is_empty() { Ok(HashMap::new()) @@ -66,83 +61,103 @@ pub(super) async fn shared_instance_publish_diffs( .await } }; - let ((latest_version_ids, latest_external_files), disabled_versions) = tokio::try_join!( + let ((version_ids, external_files), disabled_versions) = tokio::try_join!( remote_publish_content(version, modpack_unlinked, state), disabled_versions, )?; - let current_external_files = snapshot - .external_files - .iter() - .map(|file| file.file_name.clone()) - .collect::>(); - let current_version_ids = snapshot - .version_ids - .iter() - .filter(|id| current_modpack_id.as_deref() != Some(id.as_str())) - .cloned() - .collect::>(); + let before = SharedContentSnapshot { + version_ids, + external_files, + configuration: before_configuration, + }; + let after = SharedContentSnapshot { + version_ids: snapshot + .version_ids + .iter() + .filter(|id| { + after_configuration.modpack_version_id.as_deref() + != Some(id.as_str()) + }) + .cloned() + .collect(), + external_files: snapshot + .external_files + .iter() + .map(|file| { + shared_external_file_key(&file.file_type, &file.file_name) + }) + .collect::>()?, + configuration: after_configuration, + }; let mut removed_disabled_project_ids = snapshot.disabled_project_ids.clone(); removed_disabled_project_ids.extend(disabled_versions.into_keys()); - let mut diffs = shared_content_diffs( - &latest_version_ids, - &latest_external_files, - ¤t_version_ids, - ¤t_external_files, + + shared_content_diffs( + &before, + &after, &removed_disabled_project_ids, &snapshot.disabled_external_files, - false, + CommonExternalFilePolicy::AssumeUnchanged, state, ) - .await?; - let mut configuration_diffs = shared_instance_configuration_diffs( - remote_modpack_id, - current_modpack_id.as_deref(), - &version.game_version, - &metadata.applied_content_set.game_version, - version.loader, - metadata.applied_content_set.loader, - Some(version.loader_version.as_str()), - metadata.applied_content_set.loader_version.as_deref(), - state, - ) - .await?; - configuration_diffs.append(&mut diffs); + .await +} - Ok(configuration_diffs) +fn local_configuration( + metadata: &crate::state::InstanceMetadata, +) -> ContentSetConfiguration { + ContentSetConfiguration { + modpack_version_id: shared_modpack_id(&metadata.link) + .filter(|id| !id.is_empty()), + game_version: metadata.applied_content_set.game_version.clone(), + loader: LoaderReference { + name: metadata.applied_content_set.loader.as_str().to_string(), + version: metadata.applied_content_set.loader_version.clone(), + }, + } } -pub(super) async fn shared_instance_configuration_diffs( - current_modpack_id: Option<&str>, - new_modpack_id: Option<&str>, - current_game_version: &str, - new_game_version: &str, - current_loader: ModLoader, - new_loader: ModLoader, - current_loader_version: Option<&str>, - new_loader_version: Option<&str>, +fn remote_configuration( + version: &InstanceVersionResponse, +) -> ContentSetConfiguration { + ContentSetConfiguration { + modpack_version_id: version + .modpack_id + .clone() + .filter(|id| !id.is_empty()), + game_version: version.game_version.clone(), + loader: LoaderReference { + name: version.loader.as_str().to_string(), + version: Some(version.loader_version.clone()), + }, + } +} + +async fn shared_configuration_diffs( + changes: Vec, state: &State, -) -> crate::Result> { +) -> Vec { let mut diffs = Vec::new(); - - if current_modpack_id != new_modpack_id { - match (current_modpack_id, new_modpack_id) { - (None, Some(_)) => diffs.push(configuration_diff( - SharedInstanceUpdateDiffType::ModpackLinked, - None, - shared_modpack_version_label(new_modpack_id, state).await, - )), - (Some(_), None) => diffs.push(configuration_diff( - SharedInstanceUpdateDiffType::ModpackUnlinked, - shared_modpack_version_label(current_modpack_id, state).await, - None, - )), - (Some(current_modpack_id), Some(new_modpack_id)) => { + for change in changes { + match change { + ConfigurationDiff::Modpack(Change::Added { after }) => { + diffs.push(configuration_diff( + SharedInstanceUpdateDiffType::ModpackLinked, + None, + shared_modpack_version_label(Some(&after), state).await, + )) + } + ConfigurationDiff::Modpack(Change::Removed { before }) => diffs + .push(configuration_diff( + SharedInstanceUpdateDiffType::ModpackUnlinked, + shared_modpack_version_label(Some(&before), state).await, + None, + )), + ConfigurationDiff::Modpack(Change::Updated { before, after }) => { let current = - shared_modpack_version_details(current_modpack_id, state) - .await; - let new = - shared_modpack_version_details(new_modpack_id, state).await; + shared_modpack_version_details(&before, state).await; + let new = shared_modpack_version_details(&after, state).await; let project_name = new .as_ref() .and_then(|details| details.project_name.clone()) @@ -151,7 +166,6 @@ pub(super) async fn shared_instance_configuration_diffs( .as_ref() .and_then(|details| details.project_name.clone()) }); - diffs.push(SharedInstanceUpdateDiff { type_: SharedInstanceUpdateDiffType::ModpackUpdated, project_id: None, @@ -164,32 +178,23 @@ pub(super) async fn shared_instance_configuration_diffs( disabled: false, }); } - (None, None) => unreachable!(), + ConfigurationDiff::GameVersion(change) => { + diffs.push(configuration_diff( + SharedInstanceUpdateDiffType::GameVersionUpdated, + change.before().cloned(), + change.after().cloned(), + )) + } + ConfigurationDiff::Loader(change) => { + diffs.push(configuration_diff( + SharedInstanceUpdateDiffType::LoaderUpdated, + change.before().map(shared_loader_label), + change.after().map(shared_loader_label), + )) + } } } - - if current_game_version != new_game_version { - diffs.push(configuration_diff( - SharedInstanceUpdateDiffType::GameVersionUpdated, - Some(current_game_version.to_string()), - Some(new_game_version.to_string()), - )); - } - - let current_loader_version = - normalized_loader_version(current_loader_version); - let new_loader_version = normalized_loader_version(new_loader_version); - if current_loader != new_loader - || current_loader_version != new_loader_version - { - diffs.push(configuration_diff( - SharedInstanceUpdateDiffType::LoaderUpdated, - Some(shared_loader_label(current_loader, current_loader_version)), - Some(shared_loader_label(new_loader, new_loader_version)), - )); - } - - Ok(diffs) + diffs } pub(super) fn configuration_diff( @@ -265,62 +270,64 @@ async fn shared_modpack_version_details( }) } -pub(super) fn normalized_loader_version( - loader_version: Option<&str>, -) -> Option<&str> { - loader_version.filter(|version| !version.is_empty()) -} - -pub(super) fn shared_loader_label( - loader: ModLoader, - loader_version: Option<&str>, -) -> String { - let loader_name = match loader { - ModLoader::Vanilla => "Vanilla", - ModLoader::Forge => "Forge", - ModLoader::Fabric => "Fabric", - ModLoader::Quilt => "Quilt", - ModLoader::NeoForge => "NeoForge", +fn shared_loader_label(loader: &LoaderReference) -> String { + let loader_name = match loader.name.as_str() { + "vanilla" => "Vanilla", + "forge" => "Forge", + "fabric" => "Fabric", + "quilt" => "Quilt", + "neoforge" => "NeoForge", + name => name, }; - - match loader_version { + match loader.version.as_deref() { Some(version) => format!("{loader_name} {version}"), None => loader_name.to_string(), } } -pub(super) async fn shared_content_diffs( - current_version_ids: &[String], - current_external_files: &HashSet, - latest_version_ids: &[String], - latest_external_files: &HashSet, +async fn shared_content_diffs( + before: &SharedContentSnapshot, + after: &SharedContentSnapshot, removed_disabled_project_ids: &HashSet, - removed_disabled_external_files: &HashSet, - common_external_files_are_updated: bool, + removed_disabled_external_files: &BTreeSet, + common_external_files: CommonExternalFilePolicy, state: &State, ) -> crate::Result> { - let current = shared_content_snapshot( - current_version_ids, - current_external_files, - state, - ) - .await?; - let latest = shared_content_snapshot( - latest_version_ids, - latest_external_files, - state, + let (before_versions, after_versions) = tokio::try_join!( + shared_versions_by_project(&before.version_ids, state), + shared_versions_by_project(&after.version_ids, state), + )?; + let to_snapshot = + |source: &SharedContentSnapshot, + versions: &HashMap| { + ContentSetSnapshot { + projects: versions + .iter() + .map(|(project_id, version)| { + (project_id.clone(), version.id.clone()) + }) + .collect(), + external_files: source.external_files.clone(), + } + }; + let diff = diff_content_sets( + &to_snapshot(before, &before_versions), + &to_snapshot(after, &after_versions), + &ContentSetDiffOptions { + common_external_files, + }, ) - .await?; - let options = ContentSetDiffOptions { - removed_disabled_project_ids: removed_disabled_project_ids.clone(), - removed_disabled_external_files: removed_disabled_external_files - .clone(), - common_external_files_are_updated, - }; - let content_diffs = diff_content_sets(¤t, &latest, &options); - let project_ids = content_diffs + .with_additional(diff_configuration( + &before.configuration, + &after.configuration, + )); + if !diff.has_changes() { + return Ok(Vec::new()); + } + let project_ids = diff + .content .iter() - .filter_map(|diff| match diff { + .filter_map(|entry| match entry { ContentSetDiffEntry::Project { project_id, .. } => { Some(project_id.clone()) } @@ -328,44 +335,46 @@ pub(super) async fn shared_content_diffs( }) .collect::>(); let project_names = shared_project_names(&project_ids, state).await?; - - let mut diffs = Vec::new(); - for diff in content_diffs { - match diff { - ContentSetDiffEntry::Project { - kind, - project_id, - current_version_name, - new_version_name, - disabled, - } => { - let project_name = Some( - project_names - .get(&project_id) - .cloned() - .unwrap_or_else(|| project_id.clone()), - ); - diffs.push(SharedInstanceUpdateDiff { - type_: shared_update_diff_type(kind), + let mut content_diffs = Vec::new(); + for entry in diff.content { + match entry { + ContentSetDiffEntry::Project { project_id, change } => { + let disabled = change.kind() == ContentSetDiffKind::Removed + && removed_disabled_project_ids.contains(&project_id); + content_diffs.push(SharedInstanceUpdateDiff { + type_: shared_update_diff_type(change.kind()), + project_name: Some( + project_names + .get(&project_id) + .cloned() + .unwrap_or_else(|| project_id.clone()), + ), + current_version_name: change.before().map(|id| { + before_versions + .get(&project_id) + .map(|version| version.version_number.clone()) + .unwrap_or_else(|| id.clone()) + }), + new_version_name: change.after().map(|id| { + after_versions + .get(&project_id) + .map(|version| version.version_number.clone()) + .unwrap_or_else(|| id.clone()) + }), project_id: Some(project_id), - project_name, file_name: None, - current_version_name, - new_version_name, config_file_count: None, disabled, }); } - ContentSetDiffEntry::ExternalFile { - kind, - file_name, - disabled, - } => { - diffs.push(SharedInstanceUpdateDiff { + ContentSetDiffEntry::ExternalFile { file, kind } => { + let disabled = kind == ContentSetDiffKind::Removed + && removed_disabled_external_files.contains(&file); + content_diffs.push(SharedInstanceUpdateDiff { type_: shared_update_diff_type(kind), project_id: None, project_name: None, - file_name: Some(file_name), + file_name: Some(file.path), current_version_name: None, new_version_name: None, config_file_count: None, @@ -374,17 +383,18 @@ pub(super) async fn shared_content_diffs( } } } - - diffs.sort_by(|a, b| { + content_diffs.sort_by(|a, b| { a.project_name .as_deref() .or(a.file_name.as_deref()) .cmp(&b.project_name.as_deref().or(b.file_name.as_deref())) }); + let mut diffs = shared_configuration_diffs(diff.additional, state).await; + diffs.extend(content_diffs); Ok(diffs) } -pub(super) fn shared_update_diff_type( +fn shared_update_diff_type( kind: ContentSetDiffKind, ) -> SharedInstanceUpdateDiffType { match kind { @@ -394,39 +404,33 @@ pub(super) fn shared_update_diff_type( } } -pub(super) async fn shared_content_snapshot( - version_ids: &[String], - external_files: &HashSet, - state: &State, -) -> crate::Result { - let versions = shared_versions_by_project(version_ids, state) - .await? - .into_values() - .map(ContentSetSnapshotVersion::from) - .collect(); - - Ok(ContentSetSnapshot { - versions, - external_files: external_files.clone(), +pub(super) fn shared_external_file_key( + file_type: &str, + path: &str, +) -> crate::Result { + Ok(ExternalFileKey { + content_type: file_type.parse().map_err( + |error: modrinth_content_management::Error| { + crate::ErrorKind::InputError(error.to_string()) + }, + )?, + path: path.to_string(), }) } -pub(super) fn remote_shared_content( +fn remote_shared_content( version: &InstanceVersionResponse, -) -> (Vec, HashSet) { +) -> crate::Result<(Vec, BTreeSet)> { let mut version_ids = version.modrinth_ids.clone(); if let Some(modpack_id) = version.modpack_id.as_deref() { version_ids.retain(|id| id != modpack_id); } dedupe_strings(&mut version_ids); - - ( - version_ids, - version - .external_files - .iter() - .filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE) - .map(|file| file.file_name.clone()) - .collect(), - ) + let external_files = version + .external_files + .iter() + .filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE) + .map(|file| shared_external_file_key(&file.file_type, &file.file_name)) + .collect::>()?; + Ok((version_ids, external_files)) } diff --git a/packages/app-lib/src/api/instance/shared/mod.rs b/packages/app-lib/src/api/instance/shared/mod.rs index 7706a4f841..4fdce76f1c 100644 --- a/packages/app-lib/src/api/instance/shared/mod.rs +++ b/packages/app-lib/src/api/instance/shared/mod.rs @@ -1,7 +1,3 @@ -use super::content_set_diff::{ - ContentSetDiffEntry, ContentSetDiffKind, ContentSetDiffOptions, - ContentSetSnapshot, ContentSetSnapshotVersion, diff_content_sets, -}; use crate::SharedInstanceUnavailableReason; use crate::event::InstancePayloadType; use crate::event::emit::emit_instance; @@ -20,10 +16,17 @@ use crate::util::fetch::{ NO_TIMEOUT_REQWEST_CLIENT, REQWEST_CLIENT, }; use chrono::{DateTime, Utc}; +use modrinth_content_management::{ + Change, CommonExternalFilePolicy, ConfigurationDiff, + ContentSetConfiguration, ContentSetDiffEntry, ContentSetDiffKind, + ContentSetDiffOptions, ContentSetSnapshot, ExternalFileKey, + LoaderReference, diff_configuration, diff_content_sets, +}; use reqwest::{Method, StatusCode}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::json; +use std::collections::BTreeSet; use std::collections::{HashMap, HashSet}; use std::io::Read; diff --git a/packages/app-lib/src/api/instance/shared/publish.rs b/packages/app-lib/src/api/instance/shared/publish.rs index 74b737bd26..c4c7af1d12 100644 --- a/packages/app-lib/src/api/instance/shared/publish.rs +++ b/packages/app-lib/src/api/instance/shared/publish.rs @@ -171,7 +171,7 @@ pub(super) async fn remote_publish_content( version: &InstanceVersionResponse, include_modpack_dependencies: bool, state: &State, -) -> crate::Result<(Vec, HashSet)> { +) -> crate::Result<(Vec, BTreeSet)> { let mut version_ids = version.modrinth_ids.clone(); if let Some(modpack_id) = version.modpack_id.as_deref().filter(|id| !id.is_empty()) @@ -179,9 +179,12 @@ pub(super) async fn remote_publish_content( version_ids.retain(|id| id != modpack_id); if include_modpack_dependencies { - version_ids.extend( - modpack_dependency_version_ids(modpack_id, state).await?, - ); + extend_shared_modpack_dependencies( + &mut version_ids, + modpack_id, + state, + ) + .await?; } } dedupe_strings(&mut version_ids); @@ -192,8 +195,10 @@ pub(super) async fn remote_publish_content( .external_files .iter() .filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE) - .map(|file| file.file_name.clone()) - .collect(), + .map(|file| { + shared_external_file_key(&file.file_type, &file.file_name) + }) + .collect::>()?, )) } @@ -221,6 +226,28 @@ pub(super) async fn modpack_dependency_version_ids( .collect()) } +/// Adds the modpack's dependencies to the list. If a project is already in the +/// list, keep that version instead of the one bundled with the modpack. +async fn extend_shared_modpack_dependencies( + version_ids: &mut Vec, + modpack_id: &str, + state: &State, +) -> crate::Result<()> { + let dependency_ids = + modpack_dependency_version_ids(modpack_id, state).await?; + let (explicit, inherited) = tokio::try_join!( + shared_versions_by_project(version_ids, state), + shared_versions_by_project(&dependency_ids, state), + )?; + version_ids.extend( + inherited + .into_values() + .filter(|version| !explicit.contains_key(&version.project_id)) + .map(|version| version.id), + ); + Ok(()) +} + pub(super) async fn shared_instance_install_modpack( version: &InstanceVersionResponse, state: &State, @@ -272,7 +299,7 @@ pub(super) async fn current_shared_content( metadata: &crate::state::InstanceMetadata, include_linked_modpack_content: bool, state: &State, -) -> crate::Result<(Vec, HashSet)> { +) -> crate::Result<(Vec, BTreeSet)> { let entries = crate::state::instances::adapters::sqlite::content_rows::get_content_entries( &metadata.applied_content_set.id, @@ -288,7 +315,7 @@ pub(super) async fn current_shared_content( .map(|file| (file.id.clone(), file)) .collect::>(); let mut version_ids = Vec::new(); - let mut external_files = HashSet::new(); + let mut external_files = BTreeSet::new(); for entry in entries { let include_entry = entry.source_kind @@ -309,14 +336,21 @@ pub(super) async fn current_shared_content( continue; }; if let Some(file) = files.get(&file_id) { - external_files.insert(file.file_name.clone()); + external_files.insert(ExternalFileKey { + content_type: entry.project_type.into(), + path: file.file_name.clone(), + }); } } if include_linked_modpack_content && let Some(modpack_id) = shared_modpack_id(&metadata.link) { - version_ids - .extend(modpack_dependency_version_ids(&modpack_id, state).await?); + extend_shared_modpack_dependencies( + &mut version_ids, + &modpack_id, + state, + ) + .await?; } dedupe_strings(&mut version_ids); @@ -328,7 +362,7 @@ pub(super) struct CurrentPublishSnapshot { pub(super) external_files: Vec, pub(super) disabled_project_ids: HashSet, pub(super) disabled_version_ids: Vec, - pub(super) disabled_external_files: HashSet, + pub(super) disabled_external_files: BTreeSet, pub(super) config_files: Vec, } @@ -370,7 +404,7 @@ pub(super) async fn collect_publish_snapshot( let mut disabled_project_ids = HashSet::new(); let mut disabled_version_ids = Vec::new(); let mut seen_disabled_version_ids = HashSet::new(); - let mut disabled_external_files = HashSet::new(); + let mut disabled_external_files = BTreeSet::new(); for item in items { if item.enabled { @@ -419,7 +453,10 @@ pub(super) async fn collect_publish_snapshot( continue; } - disabled_external_files.insert(enabled_file_name(&item.file_name)); + disabled_external_files.insert(ExternalFileKey { + content_type: item.project_type.into(), + path: enabled_file_name(&item.file_name), + }); } Ok(CurrentPublishSnapshot { @@ -446,10 +483,28 @@ pub(super) async fn shared_versions_by_project( ) .await?; - Ok(versions - .into_iter() - .map(|version| (version.project_id.clone(), version)) - .collect()) + let fetched_ids = versions + .iter() + .map(|version| version.id.as_str()) + .collect::>(); + if let Some(missing) = version_ids + .iter() + .find(|id| !fetched_ids.contains(id.as_str())) + { + return Err(crate::ErrorKind::InputError(format!( + "Shared content version {missing} was not found" + )) + .into()); + } + let mut snapshot = ContentSetSnapshot::default(); + let mut by_project = HashMap::new(); + for version in versions { + snapshot + .insert_project(version.project_id.clone(), version.id.clone()) + .map_err(|error| crate::ErrorKind::InputError(error.to_string()))?; + by_project.insert(version.project_id.clone(), version); + } + Ok(by_project) } pub(super) async fn shared_project_names( diff --git a/packages/modrinth-content-management/src/diff/configuration.rs b/packages/modrinth-content-management/src/diff/configuration.rs new file mode 100644 index 0000000000..5a70a9e2ce --- /dev/null +++ b/packages/modrinth-content-management/src/diff/configuration.rs @@ -0,0 +1,65 @@ +use serde::{Deserialize, Serialize}; + +use crate::diff::Change; + +/// The linked modpack, Minecraft version, and loader settings to compare. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ContentSetConfiguration { + pub modpack_version_id: Option, + pub game_version: String, + pub loader: LoaderReference, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LoaderReference { + pub name: String, + pub version: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "type", content = "change", rename_all = "snake_case")] +pub enum ConfigurationDiff { + Modpack(Change), + GameVersion(Change), + Loader(Change), +} + +/// Lists changes to the linked modpack, Minecraft version, and mod loader. +/// +/// Some sources use an empty string where others use `None` for an unset +/// modpack ID or loader version. Treat them the same so this difference alone +/// does not show an update. +pub fn diff_configuration( + before: &ContentSetConfiguration, + after: &ContentSetConfiguration, +) -> Vec { + let mut changes = Vec::new(); + if let Some(change) = Change::between( + before + .modpack_version_id + .as_ref() + .filter(|id| !id.is_empty()), + after + .modpack_version_id + .as_ref() + .filter(|id| !id.is_empty()), + ) { + changes.push(ConfigurationDiff::Modpack(change)); + } + if let Some(change) = + Change::between(Some(&before.game_version), Some(&after.game_version)) + { + changes.push(ConfigurationDiff::GameVersion(change)); + } + let normalize_loader = |loader: &LoaderReference| LoaderReference { + name: loader.name.to_lowercase(), + version: loader.version.clone().filter(|version| !version.is_empty()), + }; + if let Some(change) = Change::between( + Some(&normalize_loader(&before.loader)), + Some(&normalize_loader(&after.loader)), + ) { + changes.push(ConfigurationDiff::Loader(change)); + } + changes +} diff --git a/packages/modrinth-content-management/src/diff/mod.rs b/packages/modrinth-content-management/src/diff/mod.rs new file mode 100644 index 0000000000..b503c7b5be --- /dev/null +++ b/packages/modrinth-content-management/src/diff/mod.rs @@ -0,0 +1,72 @@ +//! Finds added, removed, and updated content, with room for extra changes +//! such as linking a modpack. + +use std::collections::BTreeSet; + +pub use configuration::{ + ConfigurationDiff, ContentSetConfiguration, LoaderReference, + diff_configuration, +}; +pub use model::{ + Change, CommonExternalFilePolicy, ContentSetDiff, ContentSetDiffEntry, + ContentSetDiffKind, ContentSetDiffOptions, ContentSetSnapshot, + ExternalFileKey, +}; + +/// Lists what would change when replacing `before` with `after`. +/// +/// Projects are matched by project ID and updated when their version ID changes. +/// Files are matched by content type and path. For matching files, `options` +/// decides whether to show an update; file contents are not checked. +/// +/// Results are sorted by project ID, followed by files sorted by type and path. +pub fn diff_content_sets( + before: &ContentSetSnapshot, + after: &ContentSetSnapshot, + options: &ContentSetDiffOptions, +) -> ContentSetDiff { + let mut content = Vec::new(); + let project_ids = before + .projects + .keys() + .chain(after.projects.keys()) + .collect::>(); + for project_id in project_ids { + if let Some(change) = Change::between( + before.projects.get(project_id), + after.projects.get(project_id), + ) { + content.push(ContentSetDiffEntry::Project { + project_id: project_id.clone(), + change, + }); + } + } + for file in before.external_files.union(&after.external_files) { + let kind = match ( + before.external_files.contains(file), + after.external_files.contains(file), + ) { + (false, true) => ContentSetDiffKind::Added, + (true, false) => ContentSetDiffKind::Removed, + (true, true) + if options.common_external_files + == CommonExternalFilePolicy::AssumeUpdated => + { + ContentSetDiffKind::Updated + } + _ => continue, + }; + content.push(ContentSetDiffEntry::ExternalFile { + file: file.clone(), + kind, + }); + } + ContentSetDiff { + content, + additional: Vec::new(), + } +} + +mod configuration; +mod model; diff --git a/packages/modrinth-content-management/src/diff/model.rs b/packages/modrinth-content-management/src/diff/model.rs new file mode 100644 index 0000000000..692435a1bb --- /dev/null +++ b/packages/modrinth-content-management/src/diff/model.rs @@ -0,0 +1,190 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::convert::Infallible; + +use serde::{Deserialize, Serialize}; + +use crate::shared::{ContentType, Error}; + +/// The projects and files to compare. +/// +/// Include only content relevant to the operation. For example, when receiving +/// a shared-instance update, leave out mods the player added themselves. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct ContentSetSnapshot { + /// Each project has one version. Keys are project IDs; values are version IDs. + pub projects: BTreeMap, + pub external_files: BTreeSet, +} + +impl ContentSetSnapshot { + /// Adds a project's version. Adding the same version again does nothing. + /// Adding a different version for that project returns an error and keeps the original. + pub fn insert_project( + &mut self, + project_id: String, + version_id: String, + ) -> Result<(), Error> { + if let Some(existing) = self.projects.get(&project_id) { + if existing != &version_id { + return Err(Error::ConflictingProjectVersions { + project_id, + before: existing.clone(), + after: version_id, + }); + } + } else { + self.projects.insert(project_id, version_id); + } + Ok(()) + } +} + +/// A file's type and path, so a mod and a plugin with the same filename are +/// treated as separate files. For example, `mods/example.jar` uses type `Mod` +/// and path `example.jar`. +#[derive( + Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, +)] +pub struct ExternalFileKey { + pub content_type: ContentType, + pub path: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ContentSetDiffKind { + Added, + Removed, + Updated, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Change { + Added { after: T }, + Removed { before: T }, + Updated { before: T, after: T }, +} + +impl Change { + /// Converts the before/after values, for example from version IDs to display names. + pub fn map(self, mut map: impl FnMut(T) -> U) -> Change { + match self { + Self::Added { after } => Change::Added { after: map(after) }, + Self::Removed { before } => Change::Removed { + before: map(before), + }, + Self::Updated { before, after } => Change::Updated { + before: map(before), + after: map(after), + }, + } + } + + pub fn kind(&self) -> ContentSetDiffKind { + match self { + Self::Added { .. } => ContentSetDiffKind::Added, + Self::Removed { .. } => ContentSetDiffKind::Removed, + Self::Updated { .. } => ContentSetDiffKind::Updated, + } + } + + /// Returns `None` for an added item because it had no previous value. + pub fn before(&self) -> Option<&T> { + match self { + Self::Removed { before } | Self::Updated { before, .. } => { + Some(before) + } + Self::Added { .. } => None, + } + } + + /// Returns `None` for a removed item because it no longer has a value. + pub fn after(&self) -> Option<&T> { + match self { + Self::Added { after } | Self::Updated { after, .. } => Some(after), + Self::Removed { .. } => None, + } + } +} + +impl Change { + /// Compares the old and new values. `None` means the item is absent. + /// Returns no change if the values are equal or both absent. + pub fn between(before: Option<&T>, after: Option<&T>) -> Option { + match (before, after) { + (None, Some(after)) => Some(Self::Added { + after: after.clone(), + }), + (Some(before), None) => Some(Self::Removed { + before: before.clone(), + }), + (Some(before), Some(after)) if before != after => { + Some(Self::Updated { + before: before.clone(), + after: after.clone(), + }) + } + _ => None, + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ContentSetDiffEntry { + Project { + project_id: String, + change: Change, + }, + ExternalFile { + file: ExternalFileKey, + kind: ContentSetDiffKind, + }, +} + +/// Decides whether to show an update when a file has the same type and path +/// in both sets. The comparison does not read the files or compare their contents. +#[derive( + Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, +)] +#[serde(rename_all = "snake_case")] +pub enum CommonExternalFilePolicy { + #[default] + AssumeUnchanged, + AssumeUpdated, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct ContentSetDiffOptions { + pub common_external_files: CommonExternalFilePolicy, +} + +/// Changes to projects and files, plus any extra changes the app or server adds, +/// such as a linked modpack or selected config files. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ContentSetDiff { + pub content: Vec, + pub additional: Vec, +} + +impl ContentSetDiff { + /// Returns true for extra changes too, even when no projects or files changed. + pub fn has_changes(&self) -> bool { + !self.content.is_empty() || !self.additional.is_empty() + } +} + +impl ContentSetDiff { + /// Adds other changes to the result, such as a modpack change or selected config files. + /// These count towards `has_changes()` too. + pub fn with_additional( + self, + entries: impl IntoIterator, + ) -> ContentSetDiff { + ContentSetDiff { + content: self.content, + additional: entries.into_iter().collect(), + } + } +} diff --git a/packages/modrinth-content-management/src/install.rs b/packages/modrinth-content-management/src/install/mod.rs similarity index 96% rename from packages/modrinth-content-management/src/install.rs rename to packages/modrinth-content-management/src/install/mod.rs index d6b97c07e2..fa109437d3 100644 --- a/packages/modrinth-content-management/src/install.rs +++ b/packages/modrinth-content-management/src/install/mod.rs @@ -1,14 +1,17 @@ +//! Chooses which version of a project to install and finds the dependencies it needs. + use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; -use crate::model::{ - ContentType, Dependency, DependencyType, Error, ResolutionPreferences, - ResolveContentPlan, ResolveContentRequest, ResolvedContent, SkippedContent, - SkippedReason, Version, +use crate::shared::{ContentType, Error}; + +pub use model::{ + Dependency, DependencyType, ResolutionPreferences, ResolveContentPlan, + ResolveContentRequest, ResolvedContent, SkippedContent, SkippedReason, + Version, }; -use crate::provider::ContentMetadataProvider; +pub use provider::ContentMetadataProvider; -// Skip Fabric API if you're installing a fabric project onto a quilt instance. const QUILT_FABRIC_API_EXCEPTION_PROJECT_ID: &str = "P7dR8mSH"; pub async fn resolve_content( @@ -352,3 +355,6 @@ fn should_skip_quilt_fabric_api( .iter() .any(|loader| loaders_match(loader, "quilt")) } + +mod model; +mod provider; diff --git a/packages/modrinth-content-management/src/model.rs b/packages/modrinth-content-management/src/install/model.rs similarity index 76% rename from packages/modrinth-content-management/src/model.rs rename to packages/modrinth-content-management/src/install/model.rs index 10f54d991d..ef27d5f394 100644 --- a/packages/modrinth-content-management/src/model.rs +++ b/packages/modrinth-content-management/src/install/model.rs @@ -1,33 +1,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("metadata provider error: {0}")] - Provider(String), - #[error("project `{0}` was not found")] - ProjectNotFound(String), - #[error("version `{0}` was not found")] - VersionNotFound(String), - #[error("version `{version_id}` does not belong to project `{project_id}`")] - VersionProjectMismatch { - version_id: String, - project_id: String, - }, - #[error("no compatible version was found for project `{0}`")] - NoCompatibleVersion(String), -} - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum ContentType { - Mod, - Plugin, - DataPack, - ResourcePack, - Shader, - ModPack, -} +use crate::shared::ContentType; #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub struct ResolutionPreferences { diff --git a/packages/modrinth-content-management/src/provider/mod.rs b/packages/modrinth-content-management/src/install/provider.rs similarity index 85% rename from packages/modrinth-content-management/src/provider/mod.rs rename to packages/modrinth-content-management/src/install/provider.rs index 410fdb677a..145e888955 100644 --- a/packages/modrinth-content-management/src/provider/mod.rs +++ b/packages/modrinth-content-management/src/install/provider.rs @@ -1,6 +1,7 @@ use async_trait::async_trait; -use crate::model::{Error, Version}; +use crate::install::Version; +use crate::shared::Error; #[async_trait] pub trait ContentMetadataProvider: Send + Sync { diff --git a/packages/modrinth-content-management/src/lib.rs b/packages/modrinth-content-management/src/lib.rs index 0e25aa73b3..b61576ef0c 100644 --- a/packages/modrinth-content-management/src/lib.rs +++ b/packages/modrinth-content-management/src/lib.rs @@ -1,11 +1,17 @@ +pub mod diff; pub mod install; -pub mod model; -pub mod provider; -pub use install::resolve_content; -pub use model::{ - ContentType, Dependency, DependencyType, Error, ResolutionPreferences, +pub use diff::{ + Change, CommonExternalFilePolicy, ConfigurationDiff, + ContentSetConfiguration, ContentSetDiff, ContentSetDiffEntry, + ContentSetDiffKind, ContentSetDiffOptions, ContentSetSnapshot, + ExternalFileKey, LoaderReference, diff_configuration, diff_content_sets, +}; +pub use install::{ + ContentMetadataProvider, Dependency, DependencyType, ResolutionPreferences, ResolveContentPlan, ResolveContentRequest, ResolvedContent, SkippedContent, - SkippedReason, Version, + SkippedReason, Version, resolve_content, }; -pub use provider::ContentMetadataProvider; +pub use shared::{ContentType, Error}; + +mod shared; diff --git a/packages/modrinth-content-management/src/shared/mod.rs b/packages/modrinth-content-management/src/shared/mod.rs new file mode 100644 index 0000000000..96938d1ed9 --- /dev/null +++ b/packages/modrinth-content-management/src/shared/mod.rs @@ -0,0 +1,82 @@ +//! Common content types and errors used when installing or comparing content. + +use serde::{Deserialize, Serialize}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("unknown content type `{0}`")] + UnknownContentType(String), + #[error( + "project `{project_id}` has conflicting versions `{before}` and `{after}`" + )] + ConflictingProjectVersions { + project_id: String, + before: String, + after: String, + }, + #[error("metadata provider error: {0}")] + Provider(String), + #[error("project `{0}` was not found")] + ProjectNotFound(String), + #[error("version `{0}` was not found")] + VersionNotFound(String), + #[error("version `{version_id}` does not belong to project `{project_id}`")] + VersionProjectMismatch { + version_id: String, + project_id: String, + }, + #[error("no compatible version was found for project `{0}`")] + NoCompatibleVersion(String), +} + +#[derive( + Clone, + Copy, + Debug, + Deserialize, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, + Serialize, +)] +#[serde(rename_all = "lowercase")] +pub enum ContentType { + Mod, + Plugin, + DataPack, + ResourcePack, + Shader, + ModPack, +} + +impl std::str::FromStr for ContentType { + type Err = Error; + + fn from_str(value: &str) -> Result { + match value { + "mod" => Ok(Self::Mod), + "plugin" => Ok(Self::Plugin), + "datapack" => Ok(Self::DataPack), + "resourcepack" => Ok(Self::ResourcePack), + "shader" => Ok(Self::Shader), + "modpack" => Ok(Self::ModPack), + _ => Err(Error::UnknownContentType(value.to_owned())), + } + } +} + +impl ContentType { + /// Returns the API name, such as `mod` or `resourcepack`. + pub fn as_str(self) -> &'static str { + match self { + Self::Mod => "mod", + Self::Plugin => "plugin", + Self::DataPack => "datapack", + Self::ResourcePack => "resourcepack", + Self::Shader => "shader", + Self::ModPack => "modpack", + } + } +} diff --git a/packages/modrinth-content-management/tests/diff.rs b/packages/modrinth-content-management/tests/diff.rs new file mode 100644 index 0000000000..61255d0043 --- /dev/null +++ b/packages/modrinth-content-management/tests/diff.rs @@ -0,0 +1,529 @@ +use modrinth_content_management::{ + Change, CommonExternalFilePolicy, ConfigurationDiff, + ContentSetConfiguration, ContentSetDiffEntry, ContentSetDiffKind, + ContentSetDiffOptions, ContentSetSnapshot, ContentType, Error, + ExternalFileKey, LoaderReference, diff_configuration, diff_content_sets, +}; + +fn snapshot( + projects: &[(&str, &str)], + files: &[(ContentType, &str)], +) -> ContentSetSnapshot { + ContentSetSnapshot { + projects: projects + .iter() + .map(|(project, version)| { + (project.to_string(), version.to_string()) + }) + .collect(), + external_files: files + .iter() + .map(|(content_type, path)| file(*content_type, path)) + .collect(), + } +} + +fn file(content_type: ContentType, path: &str) -> ExternalFileKey { + ExternalFileKey { + content_type, + path: path.to_string(), + } +} + +fn configuration( + modpack_version_id: Option<&str>, + game_version: &str, + loader: &str, + loader_version: Option<&str>, +) -> ContentSetConfiguration { + ContentSetConfiguration { + modpack_version_id: modpack_version_id.map(str::to_string), + game_version: game_version.to_string(), + loader: LoaderReference { + name: loader.to_string(), + version: loader_version.map(str::to_string), + }, + } +} + +#[test] +fn empty_and_identical_snapshots_have_no_changes() { + for content in [ + ContentSetSnapshot::default(), + snapshot(&[("sodium", "v1")], &[(ContentType::Mod, "custom.jar")]), + ] { + let diff = diff_content_sets( + &content, + &content, + &ContentSetDiffOptions::default(), + ); + assert!(!diff.has_changes()); + assert!(diff.content.is_empty()); + assert!(diff.additional.is_empty()); + } +} + +#[test] +fn project_changes_preserve_identity_and_both_version_ids() { + let before = snapshot( + &[("unchanged", "v1"), ("updated", "old"), ("removed", "v2")], + &[], + ); + let after = snapshot( + &[("updated", "new"), ("added", "v3"), ("unchanged", "v1")], + &[], + ); + + let diff = + diff_content_sets(&before, &after, &ContentSetDiffOptions::default()); + + assert!(diff.has_changes()); + assert_eq!( + diff.content, + vec![ + ContentSetDiffEntry::Project { + project_id: "added".to_string(), + change: Change::Added { + after: "v3".to_string() + }, + }, + ContentSetDiffEntry::Project { + project_id: "removed".to_string(), + change: Change::Removed { + before: "v2".to_string() + }, + }, + ContentSetDiffEntry::Project { + project_id: "updated".to_string(), + change: Change::Updated { + before: "old".to_string(), + after: "new".to_string(), + }, + }, + ] + ); +} + +#[test] +fn reversing_snapshots_reverses_project_and_file_changes() { + let before = snapshot( + &[("updated", "old"), ("removed", "v1")], + &[(ContentType::Mod, "removed.jar")], + ); + let after = snapshot( + &[("updated", "new"), ("added", "v2")], + &[(ContentType::Mod, "added.jar")], + ); + + let reverse = + diff_content_sets(&after, &before, &ContentSetDiffOptions::default()); + + assert_eq!( + reverse.content, + vec![ + ContentSetDiffEntry::Project { + project_id: "added".to_string(), + change: Change::Removed { + before: "v2".to_string() + }, + }, + ContentSetDiffEntry::Project { + project_id: "removed".to_string(), + change: Change::Added { + after: "v1".to_string() + }, + }, + ContentSetDiffEntry::Project { + project_id: "updated".to_string(), + change: Change::Updated { + before: "new".to_string(), + after: "old".to_string(), + }, + }, + ContentSetDiffEntry::ExternalFile { + file: file(ContentType::Mod, "added.jar"), + kind: ContentSetDiffKind::Removed, + }, + ContentSetDiffEntry::ExternalFile { + file: file(ContentType::Mod, "removed.jar"), + kind: ContentSetDiffKind::Added, + }, + ] + ); +} + +#[test] +fn common_file_policy_only_affects_files_present_on_both_sides() { + let before = snapshot( + &[], + &[ + (ContentType::Mod, "common.jar"), + (ContentType::Mod, "removed.jar"), + ], + ); + let after = snapshot( + &[], + &[ + (ContentType::Mod, "added.jar"), + (ContentType::Mod, "common.jar"), + ], + ); + let added = ContentSetDiffEntry::ExternalFile { + file: file(ContentType::Mod, "added.jar"), + kind: ContentSetDiffKind::Added, + }; + let removed = ContentSetDiffEntry::ExternalFile { + file: file(ContentType::Mod, "removed.jar"), + kind: ContentSetDiffKind::Removed, + }; + let common = ContentSetDiffEntry::ExternalFile { + file: file(ContentType::Mod, "common.jar"), + kind: ContentSetDiffKind::Updated, + }; + for (policy, expected) in [ + ( + CommonExternalFilePolicy::AssumeUnchanged, + vec![added.clone(), removed.clone()], + ), + ( + CommonExternalFilePolicy::AssumeUpdated, + vec![added, common, removed], + ), + ] { + let diff = diff_content_sets( + &before, + &after, + &ContentSetDiffOptions { + common_external_files: policy, + }, + ); + assert_eq!(diff.content, expected, "{policy:?}"); + } +} + +#[test] +fn assumed_common_file_update_counts_as_a_change() { + let content = snapshot(&[], &[(ContentType::ResourcePack, "textures.zip")]); + let diff = diff_content_sets( + &content, + &content, + &ContentSetDiffOptions { + common_external_files: CommonExternalFilePolicy::AssumeUpdated, + }, + ); + + assert!(diff.has_changes()); + assert_eq!( + diff.content, + vec![ContentSetDiffEntry::ExternalFile { + file: file(ContentType::ResourcePack, "textures.zip"), + kind: ContentSetDiffKind::Updated, + }] + ); +} + +#[test] +fn file_identity_includes_content_type_and_relative_path() { + let before = snapshot( + &[], + &[ + (ContentType::Mod, "shared.jar"), + (ContentType::DataPack, "first/data.zip"), + ], + ); + let after = snapshot( + &[], + &[ + (ContentType::Plugin, "shared.jar"), + (ContentType::DataPack, "second/data.zip"), + ], + ); + + let diff = + diff_content_sets(&before, &after, &ContentSetDiffOptions::default()); + + assert_eq!( + diff.content, + vec![ + ContentSetDiffEntry::ExternalFile { + file: file(ContentType::Mod, "shared.jar"), + kind: ContentSetDiffKind::Removed, + }, + ContentSetDiffEntry::ExternalFile { + file: file(ContentType::Plugin, "shared.jar"), + kind: ContentSetDiffKind::Added, + }, + ContentSetDiffEntry::ExternalFile { + file: file(ContentType::DataPack, "first/data.zip"), + kind: ContentSetDiffKind::Removed, + }, + ContentSetDiffEntry::ExternalFile { + file: file(ContentType::DataPack, "second/data.zip"), + kind: ContentSetDiffKind::Added, + }, + ] + ); +} + +#[test] +fn diff_order_is_independent_of_snapshot_insertion_order() { + let first = snapshot( + &[("z", "v2"), ("a", "v1")], + &[(ContentType::Mod, "z.jar"), (ContentType::Mod, "a.jar")], + ); + let second = snapshot( + &[("a", "v1"), ("z", "v2")], + &[(ContentType::Mod, "a.jar"), (ContentType::Mod, "z.jar")], + ); + let empty = ContentSetSnapshot::default(); + let options = ContentSetDiffOptions::default(); + + assert_eq!( + diff_content_sets(&empty, &first, &options), + diff_content_sets(&empty, &second, &options), + ); +} + +#[test] +fn inserting_the_same_project_version_is_idempotent() { + let mut content = ContentSetSnapshot::default(); + content + .insert_project("sodium".to_string(), "v1".to_string()) + .unwrap(); + let original = content.clone(); + + content + .insert_project("sodium".to_string(), "v1".to_string()) + .unwrap(); + + assert_eq!(content, original); +} + +#[test] +fn conflicting_project_version_is_rejected_without_changing_the_snapshot() { + let mut content = snapshot(&[("sodium", "v1")], &[]); + let original = content.clone(); + + let error = content + .insert_project("sodium".to_string(), "v2".to_string()) + .unwrap_err(); + + assert!( + matches!(error, Error::ConflictingProjectVersions { project_id, before, after } + if project_id == "sodium" && before == "v1" && after == "v2") + ); + assert_eq!(content, original); +} + +#[derive(Debug, PartialEq)] +enum AdditionalChange { + ConfigFilesUpdated { file_count: usize }, +} + +#[test] +fn caller_entries_can_trigger_changes_without_content_changes() { + let content = ContentSetSnapshot::default(); + let base = diff_content_sets( + &content, + &content, + &ContentSetDiffOptions::default(), + ); + let mut diff = base.with_additional(Vec::::new()); + assert!(!diff.has_changes()); + + diff.additional + .push(AdditionalChange::ConfigFilesUpdated { file_count: 2 }); + + assert!(diff.content.is_empty()); + assert!(diff.has_changes()); + assert_eq!( + diff.additional, + vec![AdditionalChange::ConfigFilesUpdated { file_count: 2 }] + ); +} + +#[test] +fn attaching_caller_entries_preserves_content_changes() { + let before = ContentSetSnapshot::default(); + let after = snapshot(&[("sodium", "v1")], &[]); + let base = + diff_content_sets(&before, &after, &ContentSetDiffOptions::default()); + let content = base.content.clone(); + let mut diff = + base.with_additional([AdditionalChange::ConfigFilesUpdated { + file_count: 1, + }]); + + assert_eq!(diff.content, content); + assert_eq!(diff.additional.len(), 1); + diff.additional.clear(); + assert!(diff.has_changes()); +} + +#[test] +fn modpack_link_unlink_and_update_have_directional_version_ids() { + for (before_id, after_id, expected) in [ + ( + None, + Some("pack-v1"), + Change::Added { + after: "pack-v1".to_string(), + }, + ), + ( + Some("pack-v1"), + None, + Change::Removed { + before: "pack-v1".to_string(), + }, + ), + ( + Some("pack-v1"), + Some("pack-v2"), + Change::Updated { + before: "pack-v1".to_string(), + after: "pack-v2".to_string(), + }, + ), + ] { + let before = + configuration(before_id, "1.21.1", "fabric", Some("0.16.0")); + let after = configuration(after_id, "1.21.1", "fabric", Some("0.16.0")); + + assert_eq!( + diff_configuration(&before, &after), + vec![ConfigurationDiff::Modpack(expected)] + ); + } +} + +#[test] +fn configuration_normalization_does_not_create_false_changes() { + let empty = configuration(None, "1.21.1", "fabric", None); + let explicit_empty = configuration(Some(""), "1.21.1", "Fabric", Some("")); + let linked = + configuration(Some("pack-v1"), "1.21.1", "NeoForge", Some("21.1.1")); + let canonical = + configuration(Some("pack-v1"), "1.21.1", "neoforge", Some("21.1.1")); + + for (before, after) in [(&empty, &explicit_empty), (&linked, &canonical)] { + assert!(diff_configuration(before, after).is_empty()); + assert!(diff_configuration(after, before).is_empty()); + assert!(diff_configuration(before, before).is_empty()); + } +} + +#[test] +fn changing_only_the_game_version_produces_one_configuration_entry() { + let before = configuration(None, "1.21.1", "fabric", Some("0.16.0")); + let after = configuration(None, "1.21.2", "fabric", Some("0.16.0")); + + assert_eq!( + diff_configuration(&before, &after), + vec![ConfigurationDiff::GameVersion(Change::Updated { + before: "1.21.1".to_string(), + after: "1.21.2".to_string(), + }),] + ); +} + +#[test] +fn loader_version_changes_include_added_and_removed_optional_versions() { + for (before_version, after_version) in [ + (Some("0.16.0"), Some("0.16.1")), + (None, Some("0.16.0")), + (Some("0.16.0"), None), + ] { + let before = configuration(None, "1.21.1", "fabric", before_version); + let after = configuration(None, "1.21.1", "fabric", after_version); + + assert_eq!( + diff_configuration(&before, &after), + vec![ConfigurationDiff::Loader(Change::Updated { + before: before.loader.clone(), + after: after.loader.clone(), + }),] + ); + } +} + +#[test] +fn compatible_loader_names_still_represent_different_installed_loaders() { + let before = configuration(None, "1.21.1", "paper", Some("123")); + let after = configuration(None, "1.21.1", "purpur", Some("123")); + + assert_eq!( + diff_configuration(&before, &after), + vec![ConfigurationDiff::Loader(Change::Updated { + before: before.loader.clone(), + after: after.loader.clone(), + }),] + ); +} + +#[test] +fn simultaneous_configuration_changes_are_composed_in_stable_order() { + let before = + configuration(Some("pack-v1"), "1.21.1", "fabric", Some("0.16.0")); + let after = + configuration(Some("pack-v2"), "1.21.2", "neoforge", Some("21.2.1")); + let content = ContentSetSnapshot::default(); + let diff = diff_content_sets( + &content, + &content, + &ContentSetDiffOptions::default(), + ) + .with_additional(diff_configuration(&before, &after)); + + assert!(diff.content.is_empty()); + assert!(diff.has_changes()); + assert_eq!( + diff.additional, + vec![ + ConfigurationDiff::Modpack(Change::Updated { + before: "pack-v1".to_string(), + after: "pack-v2".to_string(), + }), + ConfigurationDiff::GameVersion(Change::Updated { + before: "1.21.1".to_string(), + after: "1.21.2".to_string(), + }), + ConfigurationDiff::Loader(Change::Updated { + before: before.loader, + after: after.loader, + }), + ] + ); +} + +#[test] +fn mapping_changes_preserves_their_kind_and_before_after_values() { + for (change, kind, before, after) in [ + ( + Change::Added { after: 2 }, + ContentSetDiffKind::Added, + None, + Some("v2"), + ), + ( + Change::Removed { before: 1 }, + ContentSetDiffKind::Removed, + Some("v1"), + None, + ), + ( + Change::Updated { + before: 1, + after: 2, + }, + ContentSetDiffKind::Updated, + Some("v1"), + Some("v2"), + ), + ] { + let mapped = change.map(|version| format!("v{version}")); + assert_eq!(mapped.kind(), kind); + assert_eq!(mapped.before().map(String::as_str), before); + assert_eq!(mapped.after().map(String::as_str), after); + } +}