forked from AxTps/Starlight_Lancher
feat:移除了弹窗,服务器添加sls
This commit is contained in:
17
packages/modrinth-content-management/Cargo.toml
Normal file
17
packages/modrinth-content-management/Cargo.toml
Normal file
@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "modrinth-content-management"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
1
packages/modrinth-content-management/README.md
Normal file
1
packages/modrinth-content-management/README.md
Normal file
@ -0,0 +1 @@
|
||||
Content management logic shared between Modrinth App & Labrinth
|
||||
510
packages/modrinth-content-management/src/install.rs
Normal file
510
packages/modrinth-content-management/src/install.rs
Normal file
@ -0,0 +1,510 @@
|
||||
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::provider::ContentMetadataProvider;
|
||||
|
||||
// Fabric API is replaced by Quilted Fabric API when the install target is
|
||||
// quilt. See Prism Launcher's override table for the equivalent mapping.
|
||||
const FABRIC_API_PROJECT_ID: &str = "P7dR8mSH";
|
||||
const QUILTED_FABRIC_API_PROJECT_ID: &str = "qvIfYCYJ";
|
||||
const IRIS_PROJECT_ID: &str = "YL57xq9U";
|
||||
const SODIUM_PROJECT_ID: &str = "AANobbMI";
|
||||
const MAX_DEPENDENCY_DEPTH: usize = 32;
|
||||
|
||||
// Some Modrinth versions omit required dependencies from their metadata even
|
||||
// though the mod JAR itself declares them (e.g. several Iris releases omit
|
||||
// Sodium). Correct those records locally so install plans match what the mod
|
||||
// actually requires; entries can be removed once upstream metadata is fixed.
|
||||
struct MissingDependencyCorrection {
|
||||
project_id: &'static str,
|
||||
version_id: &'static str,
|
||||
dependency_project_id: &'static str,
|
||||
}
|
||||
|
||||
const MISSING_DEPENDENCY_CORRECTIONS: &[MissingDependencyCorrection] = &[
|
||||
MissingDependencyCorrection {
|
||||
project_id: IRIS_PROJECT_ID,
|
||||
version_id: "Cjwm9s3i", // Iris 1.6.14 for Minecraft 1.20.2
|
||||
dependency_project_id: SODIUM_PROJECT_ID,
|
||||
},
|
||||
MissingDependencyCorrection {
|
||||
project_id: IRIS_PROJECT_ID,
|
||||
version_id: "G5dd9TM4", // Iris 1.7.1 for Minecraft 1.20.1
|
||||
dependency_project_id: SODIUM_PROJECT_ID,
|
||||
},
|
||||
MissingDependencyCorrection {
|
||||
project_id: IRIS_PROJECT_ID,
|
||||
version_id: "keLlmlCc", // Iris 1.7.1 for Minecraft 1.20.5/1.20.6
|
||||
dependency_project_id: SODIUM_PROJECT_ID,
|
||||
},
|
||||
MissingDependencyCorrection {
|
||||
project_id: IRIS_PROJECT_ID,
|
||||
version_id: "Kdz76qQt", // Iris 1.8.0-beta.3 for Fabric 1.21
|
||||
dependency_project_id: SODIUM_PROJECT_ID,
|
||||
},
|
||||
MissingDependencyCorrection {
|
||||
project_id: IRIS_PROJECT_ID,
|
||||
version_id: "di7sM681", // Iris 1.8.0-beta.2 for Fabric 1.21
|
||||
dependency_project_id: SODIUM_PROJECT_ID,
|
||||
},
|
||||
];
|
||||
|
||||
pub async fn resolve_content<P: ContentMetadataProvider>(
|
||||
mut provider: P,
|
||||
request: ResolveContentRequest,
|
||||
) -> Result<ResolveContentPlan, Error> {
|
||||
let primary_version =
|
||||
resolve_primary_version(&mut provider, &request).await?;
|
||||
let primary = ResolvedContent {
|
||||
project_id: primary_version.project_id.clone(),
|
||||
version_id: primary_version.id.clone(),
|
||||
dependent_on_version_id: None,
|
||||
required: true,
|
||||
};
|
||||
let mut resolver = InstallResolver::new(provider, &request);
|
||||
resolver
|
||||
.resolve_dependencies_for_version(primary_version)
|
||||
.await?;
|
||||
|
||||
Ok(ResolveContentPlan {
|
||||
primary,
|
||||
dependencies: resolver.dependencies,
|
||||
skipped: resolver.skipped,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves exact versions by identity and applies target filters only when
|
||||
/// choosing a version automatically.
|
||||
async fn resolve_primary_version<P: ContentMetadataProvider>(
|
||||
provider: &mut P,
|
||||
request: &ResolveContentRequest,
|
||||
) -> Result<Version, Error> {
|
||||
if let Some(version_id) = &request.version_id {
|
||||
let version = provider
|
||||
.get_version(version_id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::VersionNotFound(version_id.clone()))?;
|
||||
|
||||
if version.project_id != request.project_id {
|
||||
return Err(Error::VersionProjectMismatch {
|
||||
version_id: version.id,
|
||||
project_id: request.project_id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(version);
|
||||
}
|
||||
|
||||
let versions = provider.get_project_versions(&request.project_id).await?;
|
||||
if versions.is_empty() {
|
||||
return Err(Error::ProjectNotFound(request.project_id.clone()));
|
||||
}
|
||||
|
||||
select_newest_matching_version(
|
||||
versions,
|
||||
request.content_type,
|
||||
&request.selected,
|
||||
&request.target,
|
||||
)
|
||||
.ok_or_else(|| Error::NoCompatibleVersion(request.project_id.clone()))
|
||||
}
|
||||
|
||||
struct InstallResolver<'a, P> {
|
||||
provider: P,
|
||||
content_type: ContentType,
|
||||
selected: &'a ResolutionPreferences,
|
||||
target: &'a ResolutionPreferences,
|
||||
existing_project_ids: HashSet<String>,
|
||||
force_project_ids: HashSet<String>,
|
||||
excluded_project_ids: HashSet<String>,
|
||||
planned_project_versions: HashMap<String, String>,
|
||||
visited_versions: HashSet<String>,
|
||||
dependencies: Vec<ResolvedContent>,
|
||||
skipped: Vec<SkippedContent>,
|
||||
}
|
||||
|
||||
impl<'a, P: ContentMetadataProvider> InstallResolver<'a, P> {
|
||||
fn new(provider: P, request: &'a ResolveContentRequest) -> Self {
|
||||
let mut planned_project_versions = HashMap::new();
|
||||
planned_project_versions.insert(
|
||||
request.project_id.clone(),
|
||||
request.version_id.clone().unwrap_or_default(),
|
||||
);
|
||||
|
||||
Self {
|
||||
provider,
|
||||
content_type: request.content_type,
|
||||
selected: &request.selected,
|
||||
target: &request.target,
|
||||
existing_project_ids: request
|
||||
.existing_project_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
force_project_ids: request
|
||||
.force_project_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
excluded_project_ids: request
|
||||
.excluded_project_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
planned_project_versions,
|
||||
visited_versions: HashSet::new(),
|
||||
dependencies: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_dependencies_for_version(
|
||||
&mut self,
|
||||
version: Version,
|
||||
) -> Result<(), Error> {
|
||||
let mut stack = vec![(version, 0_usize)];
|
||||
|
||||
while let Some((version, depth)) = stack.pop() {
|
||||
if !self.visited_versions.insert(version.id.clone()) {
|
||||
self.skipped.push(SkippedContent {
|
||||
project_id: version.project_id,
|
||||
version_id: Some(version.id),
|
||||
dependent_on_version_id: None,
|
||||
reason: SkippedReason::DependencyCycle,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if depth >= MAX_DEPENDENCY_DEPTH {
|
||||
self.skipped.push(SkippedContent {
|
||||
project_id: version.project_id,
|
||||
version_id: Some(version.id),
|
||||
dependent_on_version_id: None,
|
||||
reason: SkippedReason::DependencyDepthExceeded,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let corrected_dependencies =
|
||||
dependency_metadata_corrections(&version);
|
||||
for original_dependency in version
|
||||
.dependencies
|
||||
.iter()
|
||||
.chain(corrected_dependencies.iter())
|
||||
{
|
||||
if !matches!(
|
||||
original_dependency.dependency_type,
|
||||
DependencyType::Required | DependencyType::Optional
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let overridden_dependency;
|
||||
let dependency = if should_use_quilted_fabric_api(
|
||||
original_dependency,
|
||||
self.target,
|
||||
) {
|
||||
overridden_dependency = Dependency {
|
||||
project_id: Some(
|
||||
QUILTED_FABRIC_API_PROJECT_ID.to_string(),
|
||||
),
|
||||
version_id: None,
|
||||
file_name: original_dependency.file_name.clone(),
|
||||
dependency_type: DependencyType::Required,
|
||||
};
|
||||
&overridden_dependency
|
||||
} else {
|
||||
original_dependency
|
||||
};
|
||||
|
||||
let Some(dependency_version) =
|
||||
self.resolve_dependency_version(dependency).await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let project_id = dependency
|
||||
.project_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| dependency_version.project_id.clone());
|
||||
let required = matches!(
|
||||
dependency.dependency_type,
|
||||
DependencyType::Required
|
||||
);
|
||||
|
||||
if self.excluded_project_ids.contains(&project_id) {
|
||||
self.skipped.push(SkippedContent {
|
||||
project_id,
|
||||
version_id: Some(dependency_version.id),
|
||||
dependent_on_version_id: Some(version.id.clone()),
|
||||
reason: SkippedReason::ExcludedByUser,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if self.existing_project_ids.contains(&project_id)
|
||||
&& !self.force_project_ids.contains(&project_id)
|
||||
{
|
||||
self.skipped.push(SkippedContent {
|
||||
project_id,
|
||||
version_id: Some(dependency_version.id),
|
||||
dependent_on_version_id: Some(version.id.clone()),
|
||||
reason: SkippedReason::AlreadyInstalled,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(planned_version_id) =
|
||||
self.planned_project_versions.get(&project_id)
|
||||
{
|
||||
if required {
|
||||
if let Some(existing) = self
|
||||
.dependencies
|
||||
.iter_mut()
|
||||
.find(|existing| existing.project_id == project_id)
|
||||
{
|
||||
existing.required = true;
|
||||
}
|
||||
}
|
||||
let reason = if planned_version_id.is_empty()
|
||||
|| planned_version_id == &dependency_version.id
|
||||
{
|
||||
SkippedReason::DuplicateProject
|
||||
} else {
|
||||
SkippedReason::ConflictingDependency
|
||||
};
|
||||
|
||||
self.skipped.push(SkippedContent {
|
||||
project_id,
|
||||
version_id: Some(dependency_version.id),
|
||||
dependent_on_version_id: Some(version.id.clone()),
|
||||
reason,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
self.planned_project_versions
|
||||
.insert(project_id.clone(), dependency_version.id.clone());
|
||||
self.dependencies.push(ResolvedContent {
|
||||
project_id,
|
||||
version_id: dependency_version.id.clone(),
|
||||
dependent_on_version_id: Some(version.id.clone()),
|
||||
required,
|
||||
});
|
||||
stack.push((dependency_version, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_dependency_version(
|
||||
&mut self,
|
||||
dependency: &Dependency,
|
||||
) -> Result<Option<Version>, Error> {
|
||||
if let Some(version_id) = &dependency.version_id {
|
||||
let version = self.provider.get_version(version_id).await?;
|
||||
if version.is_none() {
|
||||
self.skipped.push(SkippedContent {
|
||||
project_id: dependency
|
||||
.project_id
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
version_id: Some(version_id.clone()),
|
||||
dependent_on_version_id: None,
|
||||
reason: SkippedReason::MissingVersion,
|
||||
});
|
||||
}
|
||||
return Ok(version);
|
||||
}
|
||||
|
||||
let Some(project_id) = &dependency.project_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
let versions = self.provider.get_project_versions(project_id).await?;
|
||||
let version = select_newest_matching_version(
|
||||
versions,
|
||||
self.content_type,
|
||||
self.selected,
|
||||
self.target,
|
||||
);
|
||||
|
||||
if version.is_none() {
|
||||
self.skipped.push(SkippedContent {
|
||||
project_id: project_id.clone(),
|
||||
version_id: None,
|
||||
dependent_on_version_id: None,
|
||||
reason: SkippedReason::NoCompatibleVersion,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
}
|
||||
|
||||
fn dependency_metadata_corrections(version: &Version) -> Vec<Dependency> {
|
||||
MISSING_DEPENDENCY_CORRECTIONS
|
||||
.iter()
|
||||
.filter(|correction| {
|
||||
correction.project_id == version.project_id
|
||||
&& correction.version_id == version.id.as_str()
|
||||
})
|
||||
.map(|correction| Dependency {
|
||||
project_id: Some(correction.dependency_project_id.to_string()),
|
||||
version_id: None,
|
||||
file_name: None,
|
||||
dependency_type: DependencyType::Required,
|
||||
})
|
||||
.filter(|dependency| {
|
||||
!version.dependencies.iter().any(|declared| {
|
||||
declared.project_id.as_deref()
|
||||
== dependency.project_id.as_deref()
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn select_newest_matching_version(
|
||||
versions: Vec<Version>,
|
||||
content_type: ContentType,
|
||||
selected: &ResolutionPreferences,
|
||||
target: &ResolutionPreferences,
|
||||
) -> Option<Version> {
|
||||
select_matching_version(
|
||||
versions,
|
||||
content_type,
|
||||
selected,
|
||||
target,
|
||||
&[
|
||||
ReleaseChannel::Release,
|
||||
ReleaseChannel::Beta,
|
||||
ReleaseChannel::Alpha,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn select_matching_version(
|
||||
mut versions: Vec<Version>,
|
||||
content_type: ContentType,
|
||||
selected: &ResolutionPreferences,
|
||||
target: &ResolutionPreferences,
|
||||
channel_order: &[ReleaseChannel],
|
||||
) -> Option<Version> {
|
||||
versions.sort_by_key(|version| {
|
||||
(
|
||||
channel_order
|
||||
.iter()
|
||||
.position(|channel| {
|
||||
*channel == release_channel(&version.version_type)
|
||||
})
|
||||
.unwrap_or(channel_order.len()),
|
||||
Reverse(version.date_published),
|
||||
)
|
||||
});
|
||||
let merged = selected.merge(target);
|
||||
|
||||
versions
|
||||
.iter()
|
||||
.find(|version| version_matches(version, content_type, &merged))
|
||||
.or_else(|| {
|
||||
versions
|
||||
.iter()
|
||||
.find(|version| version_matches(version, content_type, target))
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum ReleaseChannel {
|
||||
Release,
|
||||
Beta,
|
||||
Alpha,
|
||||
}
|
||||
|
||||
fn release_channel(version_type: &str) -> ReleaseChannel {
|
||||
if version_type.eq_ignore_ascii_case("beta") {
|
||||
ReleaseChannel::Beta
|
||||
} else if version_type.eq_ignore_ascii_case("alpha") {
|
||||
ReleaseChannel::Alpha
|
||||
} else {
|
||||
ReleaseChannel::Release
|
||||
}
|
||||
}
|
||||
|
||||
trait MergePreferences {
|
||||
fn merge(&self, target: &Self) -> Self;
|
||||
}
|
||||
|
||||
impl MergePreferences for ResolutionPreferences {
|
||||
fn merge(&self, target: &Self) -> Self {
|
||||
Self {
|
||||
game_versions: if self.game_versions.is_empty() {
|
||||
target.game_versions.clone()
|
||||
} else {
|
||||
self.game_versions.clone()
|
||||
},
|
||||
loaders: if self.loaders.is_empty() {
|
||||
target.loaders.clone()
|
||||
} else {
|
||||
self.loaders.clone()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn version_matches(
|
||||
version: &Version,
|
||||
content_type: ContentType,
|
||||
preferences: &ResolutionPreferences,
|
||||
) -> bool {
|
||||
matches_game_versions(version, preferences)
|
||||
&& matches_loaders(version, content_type, preferences)
|
||||
}
|
||||
|
||||
fn matches_game_versions(
|
||||
version: &Version,
|
||||
preferences: &ResolutionPreferences,
|
||||
) -> bool {
|
||||
preferences.game_versions.is_empty()
|
||||
|| preferences.game_versions.iter().any(|game_version| {
|
||||
version
|
||||
.game_versions
|
||||
.iter()
|
||||
.any(|candidate| candidate == game_version)
|
||||
})
|
||||
}
|
||||
|
||||
fn matches_loaders(
|
||||
version: &Version,
|
||||
_content_type: ContentType,
|
||||
preferences: &ResolutionPreferences,
|
||||
) -> bool {
|
||||
if preferences.loaders.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
preferences.loaders.iter().any(|loader| {
|
||||
version
|
||||
.loaders
|
||||
.iter()
|
||||
.any(|candidate| loaders_match(loader, candidate))
|
||||
})
|
||||
}
|
||||
|
||||
fn loaders_match(expected: &str, candidate: &str) -> bool {
|
||||
expected.eq_ignore_ascii_case(candidate)
|
||||
}
|
||||
|
||||
fn should_use_quilted_fabric_api(
|
||||
dependency: &Dependency,
|
||||
target: &ResolutionPreferences,
|
||||
) -> bool {
|
||||
dependency.project_id.as_deref() == Some(FABRIC_API_PROJECT_ID)
|
||||
&& target
|
||||
.loaders
|
||||
.iter()
|
||||
.any(|loader| loaders_match(loader, "quilt"))
|
||||
}
|
||||
11
packages/modrinth-content-management/src/lib.rs
Normal file
11
packages/modrinth-content-management/src/lib.rs
Normal file
@ -0,0 +1,11 @@
|
||||
pub mod install;
|
||||
pub mod model;
|
||||
pub mod provider;
|
||||
|
||||
pub use install::resolve_content;
|
||||
pub use model::{
|
||||
ContentType, Dependency, DependencyType, Error, ResolutionPreferences,
|
||||
ResolveContentPlan, ResolveContentRequest, ResolvedContent, SkippedContent,
|
||||
SkippedReason, Version,
|
||||
};
|
||||
pub use provider::ContentMetadataProvider;
|
||||
133
packages/modrinth-content-management/src/model.rs
Normal file
133
packages/modrinth-content-management/src/model.rs
Normal file
@ -0,0 +1,133 @@
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct ResolutionPreferences {
|
||||
#[serde(default)]
|
||||
pub game_versions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub loaders: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct ResolveContentRequest {
|
||||
pub project_id: String,
|
||||
pub version_id: Option<String>,
|
||||
pub content_type: ContentType,
|
||||
#[serde(default)]
|
||||
pub selected: ResolutionPreferences,
|
||||
#[serde(default)]
|
||||
pub target: ResolutionPreferences,
|
||||
#[serde(default)]
|
||||
pub existing_project_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub excluded_project_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub force_project_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct ResolveContentPlan {
|
||||
pub primary: ResolvedContent,
|
||||
pub dependencies: Vec<ResolvedContent>,
|
||||
pub skipped: Vec<SkippedContent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct ResolvedContent {
|
||||
pub project_id: String,
|
||||
pub version_id: String,
|
||||
pub dependent_on_version_id: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct SkippedContent {
|
||||
pub project_id: String,
|
||||
pub version_id: Option<String>,
|
||||
pub dependent_on_version_id: Option<String>,
|
||||
pub reason: SkippedReason,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SkippedReason {
|
||||
AlreadyInstalled,
|
||||
DuplicateProject,
|
||||
ConflictingDependency,
|
||||
NoCompatibleVersion,
|
||||
MissingVersion,
|
||||
QuiltFabricApi,
|
||||
ExcludedByUser,
|
||||
DependencyCycle,
|
||||
DependencyDepthExceeded,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct Version {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub date_published: DateTime<Utc>,
|
||||
#[serde(default = "default_version_type")]
|
||||
pub version_type: String,
|
||||
#[serde(default)]
|
||||
pub dependencies: Vec<Dependency>,
|
||||
#[serde(default)]
|
||||
pub game_versions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub loaders: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_version_type() -> String {
|
||||
"release".to_string()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct Dependency {
|
||||
pub version_id: Option<String>,
|
||||
pub project_id: Option<String>,
|
||||
pub file_name: Option<String>,
|
||||
pub dependency_type: DependencyType,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DependencyType {
|
||||
Required,
|
||||
Optional,
|
||||
Incompatible,
|
||||
Embedded,
|
||||
}
|
||||
16
packages/modrinth-content-management/src/provider/mod.rs
Normal file
16
packages/modrinth-content-management/src/provider/mod.rs
Normal file
@ -0,0 +1,16 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::model::{Error, Version};
|
||||
|
||||
#[async_trait]
|
||||
pub trait ContentMetadataProvider: Send + Sync {
|
||||
async fn get_version(
|
||||
&mut self,
|
||||
version_id: &str,
|
||||
) -> Result<Option<Version>, Error>;
|
||||
|
||||
async fn get_project_versions(
|
||||
&mut self,
|
||||
project_id: &str,
|
||||
) -> Result<Vec<Version>, Error>;
|
||||
}
|
||||
815
packages/modrinth-content-management/tests/install.rs
Normal file
815
packages/modrinth-content-management/tests/install.rs
Normal file
@ -0,0 +1,815 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use modrinth_content_management::{
|
||||
ContentMetadataProvider, ContentType, Dependency, DependencyType,
|
||||
Error, ResolutionPreferences, ResolveContentRequest, SkippedReason,
|
||||
Version, resolve_content,
|
||||
};
|
||||
|
||||
const QUILT_FABRIC_API_EXCEPTION_PROJECT_ID: &str = "P7dR8mSH";
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemoryProvider {
|
||||
versions: HashMap<String, Version>,
|
||||
project_versions: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ContentMetadataProvider for MemoryProvider {
|
||||
async fn get_version(
|
||||
&mut self,
|
||||
version_id: &str,
|
||||
) -> Result<Option<Version>, Error> {
|
||||
Ok(self.versions.get(version_id).cloned())
|
||||
}
|
||||
|
||||
async fn get_project_versions(
|
||||
&mut self,
|
||||
project_id: &str,
|
||||
) -> Result<Vec<Version>, Error> {
|
||||
Ok(self
|
||||
.project_versions
|
||||
.get(project_id)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|id| self.versions.get(id))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryProvider {
|
||||
fn with_versions(mut self, versions: Vec<Version>) -> Self {
|
||||
for version in versions {
|
||||
self.project_versions
|
||||
.entry(version.project_id.clone())
|
||||
.or_default()
|
||||
.push(version.id.clone());
|
||||
self.versions.insert(version.id.clone(), version);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
fn with_project_versions(
|
||||
mut self,
|
||||
project_id: &str,
|
||||
version_ids: &[&str],
|
||||
) -> Self {
|
||||
self.project_versions.insert(
|
||||
project_id.to_string(),
|
||||
version_ids.iter().map(|id| id.to_string()).collect(),
|
||||
);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn version(
|
||||
id: &str,
|
||||
project_id: &str,
|
||||
date: &str,
|
||||
game_versions: &[&str],
|
||||
loaders: &[&str],
|
||||
dependencies: Vec<Dependency>,
|
||||
) -> Version {
|
||||
version_with_channel(
|
||||
id,
|
||||
project_id,
|
||||
date,
|
||||
"release",
|
||||
game_versions,
|
||||
loaders,
|
||||
dependencies,
|
||||
)
|
||||
}
|
||||
|
||||
fn version_with_channel(
|
||||
id: &str,
|
||||
project_id: &str,
|
||||
date: &str,
|
||||
version_type: &str,
|
||||
game_versions: &[&str],
|
||||
loaders: &[&str],
|
||||
dependencies: Vec<Dependency>,
|
||||
) -> Version {
|
||||
Version {
|
||||
id: id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
date_published: DateTime::parse_from_rfc3339(date)
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
version_type: version_type.to_string(),
|
||||
dependencies,
|
||||
game_versions: game_versions
|
||||
.iter()
|
||||
.map(|v| v.to_string())
|
||||
.collect(),
|
||||
loaders: loaders.iter().map(|v| v.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dependency(
|
||||
project_id: Option<&str>,
|
||||
version_id: Option<&str>,
|
||||
dependency_type: DependencyType,
|
||||
) -> Dependency {
|
||||
Dependency {
|
||||
version_id: version_id.map(str::to_string),
|
||||
project_id: project_id.map(str::to_string),
|
||||
file_name: None,
|
||||
dependency_type,
|
||||
}
|
||||
}
|
||||
|
||||
fn required_project_dependency(project_id: &str) -> Dependency {
|
||||
dependency(Some(project_id), None, DependencyType::Required)
|
||||
}
|
||||
|
||||
fn required_version_dependency(version_id: &str) -> Dependency {
|
||||
dependency(None, Some(version_id), DependencyType::Required)
|
||||
}
|
||||
|
||||
fn request(project_id: &str) -> ResolveContentRequest {
|
||||
ResolveContentRequest {
|
||||
project_id: project_id.to_string(),
|
||||
version_id: None,
|
||||
content_type: ContentType::Mod,
|
||||
selected: ResolutionPreferences::default(),
|
||||
target: ResolutionPreferences {
|
||||
game_versions: vec!["1.20.1".to_string()],
|
||||
loaders: vec!["fabric".to_string()],
|
||||
},
|
||||
existing_project_ids: Vec::new(),
|
||||
excluded_project_ids: Vec::new(),
|
||||
force_project_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_primary_version_is_used() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![version(
|
||||
"v1",
|
||||
"p1",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["fabric"],
|
||||
vec![],
|
||||
)]);
|
||||
let mut request = request("p1");
|
||||
request.version_id = Some("v1".to_string());
|
||||
|
||||
let plan = resolve_content(provider, request).await.unwrap();
|
||||
|
||||
assert_eq!(plan.primary.version_id, "v1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_primary_version_bypasses_target_filters() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![version(
|
||||
"v1",
|
||||
"p1",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.21.1"],
|
||||
&["neoforge"],
|
||||
vec![],
|
||||
)]);
|
||||
let mut request = request("p1");
|
||||
request.version_id = Some("v1".to_string());
|
||||
|
||||
let plan = resolve_content(provider, request).await.unwrap();
|
||||
|
||||
assert_eq!(plan.primary.version_id, "v1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn newest_matching_primary_version_is_selected() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![
|
||||
version(
|
||||
"old",
|
||||
"p1",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["fabric"],
|
||||
vec![],
|
||||
),
|
||||
version(
|
||||
"new",
|
||||
"p1",
|
||||
"2024-02-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["fabric"],
|
||||
vec![],
|
||||
),
|
||||
]);
|
||||
|
||||
let plan = resolve_content(provider, request("p1")).await.unwrap();
|
||||
|
||||
assert_eq!(plan.primary.version_id, "new");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automatic_primary_prefers_a_release_over_a_newer_beta() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![
|
||||
version_with_channel(
|
||||
"iris-release",
|
||||
"iris",
|
||||
"2026-05-01T00:00:00Z",
|
||||
"release",
|
||||
&["1.21.1"],
|
||||
&["neoforge"],
|
||||
vec![dependency(
|
||||
Some("sodium"),
|
||||
Some("sodium-release"),
|
||||
DependencyType::Required,
|
||||
)],
|
||||
),
|
||||
version_with_channel(
|
||||
"iris-newer-beta",
|
||||
"iris",
|
||||
"2026-06-01T00:00:00Z",
|
||||
"beta",
|
||||
&["1.21.1"],
|
||||
&["neoforge"],
|
||||
vec![],
|
||||
),
|
||||
version_with_channel(
|
||||
"sodium-release",
|
||||
"sodium",
|
||||
"2026-04-01T00:00:00Z",
|
||||
"release",
|
||||
&["1.21.1"],
|
||||
&["neoforge"],
|
||||
vec![],
|
||||
),
|
||||
]);
|
||||
let mut install_request = request("iris");
|
||||
install_request.target.game_versions = vec!["1.21.1".to_string()];
|
||||
install_request.target.loaders = vec!["neoforge".to_string()];
|
||||
|
||||
let plan = resolve_content(provider, install_request).await.unwrap();
|
||||
|
||||
assert_eq!(plan.primary.version_id, "iris-release");
|
||||
assert_eq!(plan.dependencies.len(), 1);
|
||||
assert_eq!(plan.dependencies[0].version_id, "sodium-release");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_only_dependency_uses_the_target_runtime() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![
|
||||
version_with_channel(
|
||||
"primary",
|
||||
"primary",
|
||||
"2026-06-01T00:00:00Z",
|
||||
"release",
|
||||
&["1.21.1"],
|
||||
&["neoforge"],
|
||||
vec![required_project_dependency("dependency")],
|
||||
),
|
||||
version_with_channel(
|
||||
"wrong-game-version",
|
||||
"dependency",
|
||||
"2026-07-01T00:00:00Z",
|
||||
"beta",
|
||||
&["1.21.2"],
|
||||
&["neoforge"],
|
||||
vec![],
|
||||
),
|
||||
version_with_channel(
|
||||
"wrong-loader",
|
||||
"dependency",
|
||||
"2026-06-15T00:00:00Z",
|
||||
"beta",
|
||||
&["1.21.1"],
|
||||
&["fabric"],
|
||||
vec![],
|
||||
),
|
||||
version_with_channel(
|
||||
"matching-runtime",
|
||||
"dependency",
|
||||
"2026-05-01T00:00:00Z",
|
||||
"release",
|
||||
&["1.21.1"],
|
||||
&["neoforge"],
|
||||
vec![],
|
||||
),
|
||||
]);
|
||||
let mut install_request = request("primary");
|
||||
install_request.version_id = Some("primary".to_string());
|
||||
install_request.target.game_versions = vec!["1.21.1".to_string()];
|
||||
install_request.target.loaders = vec!["neoforge".to_string()];
|
||||
|
||||
let plan = resolve_content(provider, install_request).await.unwrap();
|
||||
|
||||
assert_eq!(plan.primary.version_id, "primary");
|
||||
assert_eq!(plan.dependencies.len(), 1);
|
||||
assert_eq!(plan.dependencies[0].version_id, "matching-runtime");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_only_dependency_selects_matching_version() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![
|
||||
version(
|
||||
"p1v1",
|
||||
"p1",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["fabric"],
|
||||
vec![required_project_dependency("dep")],
|
||||
),
|
||||
version(
|
||||
"depv1",
|
||||
"dep",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["fabric"],
|
||||
vec![],
|
||||
),
|
||||
]);
|
||||
|
||||
let plan = resolve_content(provider, request("p1")).await.unwrap();
|
||||
|
||||
assert_eq!(plan.dependencies[0].project_id, "dep");
|
||||
assert_eq!(
|
||||
plan.dependencies[0].dependent_on_version_id.as_deref(),
|
||||
Some("p1v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exact_version_dependency_bypasses_target_filters() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![
|
||||
version(
|
||||
"p1v1",
|
||||
"p1",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["fabric"],
|
||||
vec![required_version_dependency("depv1")],
|
||||
),
|
||||
version(
|
||||
"depv1",
|
||||
"dep",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.19.4"],
|
||||
&["forge"],
|
||||
vec![],
|
||||
),
|
||||
]);
|
||||
|
||||
let plan = resolve_content(provider, request("p1")).await.unwrap();
|
||||
|
||||
assert_eq!(plan.dependencies.len(), 1);
|
||||
assert_eq!(plan.dependencies[0].project_id, "dep");
|
||||
assert_eq!(plan.dependencies[0].version_id, "depv1");
|
||||
assert!(plan.skipped.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn already_installed_dependencies_are_skipped() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![
|
||||
version(
|
||||
"p1v1",
|
||||
"p1",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["fabric"],
|
||||
vec![required_project_dependency("dep")],
|
||||
),
|
||||
version(
|
||||
"depv1",
|
||||
"dep",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["fabric"],
|
||||
vec![],
|
||||
),
|
||||
]);
|
||||
let mut request = request("p1");
|
||||
request.existing_project_ids = vec!["dep".to_string()];
|
||||
|
||||
let plan = resolve_content(provider, request).await.unwrap();
|
||||
|
||||
assert!(plan.dependencies.is_empty());
|
||||
assert_eq!(plan.skipped[0].reason, SkippedReason::AlreadyInstalled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_dependency_projects_are_skipped() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![
|
||||
version(
|
||||
"p1v1",
|
||||
"p1",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["fabric"],
|
||||
vec![
|
||||
required_project_dependency("dep"),
|
||||
required_project_dependency("dep"),
|
||||
],
|
||||
),
|
||||
version(
|
||||
"depv1",
|
||||
"dep",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["fabric"],
|
||||
vec![],
|
||||
),
|
||||
]);
|
||||
|
||||
let plan = resolve_content(provider, request("p1")).await.unwrap();
|
||||
|
||||
assert_eq!(plan.dependencies.len(), 1);
|
||||
assert_eq!(plan.skipped[0].reason, SkippedReason::DuplicateProject);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn conflicting_dependency_versions_are_skipped() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![
|
||||
version(
|
||||
"p1v1",
|
||||
"p1",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["fabric"],
|
||||
vec![
|
||||
dependency(
|
||||
Some("dep"),
|
||||
Some("depv1"),
|
||||
DependencyType::Required,
|
||||
),
|
||||
dependency(
|
||||
Some("dep"),
|
||||
Some("depv2"),
|
||||
DependencyType::Required,
|
||||
),
|
||||
],
|
||||
),
|
||||
version(
|
||||
"depv1",
|
||||
"dep",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["fabric"],
|
||||
vec![],
|
||||
),
|
||||
version(
|
||||
"depv2",
|
||||
"dep",
|
||||
"2024-02-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["fabric"],
|
||||
vec![],
|
||||
),
|
||||
]);
|
||||
|
||||
let plan = resolve_content(provider, request("p1")).await.unwrap();
|
||||
|
||||
assert_eq!(plan.dependencies.len(), 1);
|
||||
assert_eq!(
|
||||
plan.skipped[0].reason,
|
||||
SkippedReason::ConflictingDependency
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quilt_instances_replace_fabric_api_with_quilted_fabric_api() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![
|
||||
version(
|
||||
"p1v1",
|
||||
"p1",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["quilt"],
|
||||
vec![required_project_dependency(
|
||||
QUILT_FABRIC_API_EXCEPTION_PROJECT_ID,
|
||||
)],
|
||||
),
|
||||
version(
|
||||
"qfabv1",
|
||||
"qvIfYCYJ",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["quilt"],
|
||||
vec![],
|
||||
),
|
||||
]);
|
||||
let mut request = request("p1");
|
||||
request.target.loaders = vec!["quilt".to_string()];
|
||||
|
||||
let plan = resolve_content(provider, request).await.unwrap();
|
||||
|
||||
assert_eq!(plan.dependencies.len(), 1);
|
||||
assert_eq!(plan.dependencies[0].project_id, "qvIfYCYJ");
|
||||
assert!(plan.skipped.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn iris_metadata_gap_injects_missing_sodium_dependency() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![
|
||||
version(
|
||||
"Cjwm9s3i",
|
||||
"YL57xq9U",
|
||||
"2023-12-13T02:48:08Z",
|
||||
&["1.20.2"],
|
||||
&["fabric"],
|
||||
vec![],
|
||||
),
|
||||
version(
|
||||
"pmgeU5yX",
|
||||
"AANobbMI",
|
||||
"2023-12-09T04:07:46Z",
|
||||
&["1.20.2"],
|
||||
&["fabric"],
|
||||
vec![],
|
||||
),
|
||||
]);
|
||||
let mut request = request("YL57xq9U");
|
||||
request.target.game_versions = vec!["1.20.2".to_string()];
|
||||
|
||||
let plan = resolve_content(provider, request).await.unwrap();
|
||||
|
||||
assert_eq!(plan.dependencies.len(), 1);
|
||||
assert_eq!(plan.dependencies[0].project_id, "AANobbMI");
|
||||
assert_eq!(plan.dependencies[0].version_id, "pmgeU5yX");
|
||||
assert!(plan.skipped.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn iris_metadata_gap_is_not_duplicated_once_upstream_is_fixed() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![
|
||||
version(
|
||||
"Cjwm9s3i",
|
||||
"YL57xq9U",
|
||||
"2023-12-13T02:48:08Z",
|
||||
&["1.20.2"],
|
||||
&["fabric"],
|
||||
vec![required_project_dependency("AANobbMI")],
|
||||
),
|
||||
version(
|
||||
"pmgeU5yX",
|
||||
"AANobbMI",
|
||||
"2023-12-09T04:07:46Z",
|
||||
&["1.20.2"],
|
||||
&["fabric"],
|
||||
vec![],
|
||||
),
|
||||
]);
|
||||
let mut request = request("YL57xq9U");
|
||||
request.target.game_versions = vec!["1.20.2".to_string()];
|
||||
|
||||
let plan = resolve_content(provider, request).await.unwrap();
|
||||
|
||||
assert_eq!(plan.dependencies.len(), 1);
|
||||
assert_eq!(plan.dependencies[0].version_id, "pmgeU5yX");
|
||||
assert!(plan.skipped.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn iris_metadata_gap_does_not_apply_to_other_versions() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![version(
|
||||
"other",
|
||||
"YL57xq9U",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.2"],
|
||||
&["fabric"],
|
||||
vec![],
|
||||
)]);
|
||||
let mut request = request("YL57xq9U");
|
||||
request.target.game_versions = vec!["1.20.2".to_string()];
|
||||
|
||||
let plan = resolve_content(provider, request).await.unwrap();
|
||||
|
||||
assert!(plan.dependencies.is_empty());
|
||||
assert!(plan.skipped.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mods_reject_datapack_versions_when_a_loader_is_requested() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![version(
|
||||
"p1v1",
|
||||
"p1",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["datapack"],
|
||||
vec![],
|
||||
)]);
|
||||
|
||||
assert!(matches!(
|
||||
resolve_content(provider, request("p1")).await,
|
||||
Err(Error::NoCompatibleVersion(project_id)) if project_id == "p1"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn neoforge_rejects_neo_loader_alias() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![version(
|
||||
"p1v1",
|
||||
"p1",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["neo"],
|
||||
vec![],
|
||||
)]);
|
||||
let mut request = request("p1");
|
||||
request.target.loaders = vec!["neoforge".to_string()];
|
||||
|
||||
assert!(matches!(
|
||||
resolve_content(provider, request).await,
|
||||
Err(Error::NoCompatibleVersion(project_id)) if project_id == "p1"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paper_rejects_bukkit_loader_alias() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![version(
|
||||
"p1v1",
|
||||
"p1",
|
||||
"2024-01-01T00:00:00Z",
|
||||
&["1.20.1"],
|
||||
&["bukkit"],
|
||||
vec![],
|
||||
)]);
|
||||
let mut request = request("p1");
|
||||
request.content_type = ContentType::Plugin;
|
||||
request.target.loaders = vec!["paper".to_string()];
|
||||
|
||||
assert!(matches!(
|
||||
resolve_content(provider, request).await,
|
||||
Err(Error::NoCompatibleVersion(project_id)) if project_id == "p1"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn modpack_embedded_dependencies_are_not_installed() {
|
||||
let provider = MemoryProvider::default()
|
||||
.with_versions(vec![version(
|
||||
"Ur9uoHH5",
|
||||
"shFhR8Vx",
|
||||
"2025-01-19T06:39:41.349487Z",
|
||||
&["1.20.1"],
|
||||
&["fabric"],
|
||||
vec![
|
||||
dependency(
|
||||
Some("embedded-a"),
|
||||
None,
|
||||
DependencyType::Embedded,
|
||||
),
|
||||
dependency(
|
||||
Some("embedded-b"),
|
||||
None,
|
||||
DependencyType::Embedded,
|
||||
),
|
||||
dependency(
|
||||
Some("embedded-c"),
|
||||
None,
|
||||
DependencyType::Embedded,
|
||||
),
|
||||
],
|
||||
)])
|
||||
.with_project_versions("better-mc-fabric-bmc2", &["Ur9uoHH5"]);
|
||||
|
||||
let plan = resolve_content(
|
||||
provider,
|
||||
ResolveContentRequest {
|
||||
project_id: "better-mc-fabric-bmc2".to_string(),
|
||||
version_id: None,
|
||||
content_type: ContentType::ModPack,
|
||||
selected: ResolutionPreferences::default(),
|
||||
target: ResolutionPreferences::default(),
|
||||
existing_project_ids: Vec::new(),
|
||||
excluded_project_ids: Vec::new(),
|
||||
force_project_ids: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(plan.primary.project_id, "shFhR8Vx");
|
||||
assert_eq!(plan.primary.version_id, "Ur9uoHH5");
|
||||
assert!(plan.dependencies.is_empty());
|
||||
assert!(plan.skipped.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn required_dependencies_resolve_transitively() {
|
||||
let provider = MemoryProvider::default().with_versions(vec![
|
||||
version(
|
||||
"pKFEfjEB",
|
||||
"DdAlVT8M",
|
||||
"2026-06-07T21:21:24.772638Z",
|
||||
&["1.21.1", "1.21.2"],
|
||||
&["neoforge"],
|
||||
vec![
|
||||
dependency(
|
||||
Some("LNytGWDc"),
|
||||
Some("UjX6dr61"),
|
||||
DependencyType::Required,
|
||||
),
|
||||
dependency(
|
||||
Some("oWaK0Q19"),
|
||||
Some("YhZLrAFC"),
|
||||
DependencyType::Required,
|
||||
),
|
||||
],
|
||||
),
|
||||
version(
|
||||
"UjX6dr61",
|
||||
"LNytGWDc",
|
||||
"2026-04-21T22:20:03.579201Z",
|
||||
&["1.21.1"],
|
||||
&["neoforge"],
|
||||
vec![],
|
||||
),
|
||||
version(
|
||||
"YhZLrAFC",
|
||||
"oWaK0Q19",
|
||||
"2026-05-21T22:20:03.579201Z",
|
||||
&["1.21.1"],
|
||||
&["neoforge"],
|
||||
vec![
|
||||
dependency(
|
||||
Some("T9PomCSv"),
|
||||
None,
|
||||
DependencyType::Required,
|
||||
),
|
||||
dependency(
|
||||
Some("LNytGWDc"),
|
||||
Some("UjX6dr61"),
|
||||
DependencyType::Required,
|
||||
),
|
||||
],
|
||||
),
|
||||
version(
|
||||
"hyQUls27",
|
||||
"T9PomCSv",
|
||||
"2026-06-17T04:32:50.469591Z",
|
||||
&["1.21.1"],
|
||||
&["fabric"],
|
||||
vec![],
|
||||
),
|
||||
version(
|
||||
"1L6XJqnY",
|
||||
"T9PomCSv",
|
||||
"2026-06-17T04:32:49.279302Z",
|
||||
&["1.21.1"],
|
||||
&["neoforge"],
|
||||
vec![],
|
||||
),
|
||||
]);
|
||||
|
||||
let plan = resolve_content(
|
||||
provider,
|
||||
ResolveContentRequest {
|
||||
project_id: "DdAlVT8M".to_string(),
|
||||
version_id: Some("pKFEfjEB".to_string()),
|
||||
content_type: ContentType::Mod,
|
||||
selected: ResolutionPreferences::default(),
|
||||
target: ResolutionPreferences {
|
||||
game_versions: vec![
|
||||
"1.21.1".to_string(),
|
||||
"1.21.2".to_string(),
|
||||
],
|
||||
loaders: vec!["neoforge".to_string()],
|
||||
},
|
||||
existing_project_ids: Vec::new(),
|
||||
excluded_project_ids: Vec::new(),
|
||||
force_project_ids: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(plan.primary.project_id, "DdAlVT8M");
|
||||
assert_eq!(plan.primary.version_id, "pKFEfjEB");
|
||||
assert_eq!(plan.dependencies.len(), 3);
|
||||
assert!(plan.dependencies.iter().any(|dependency| {
|
||||
dependency.project_id == "LNytGWDc"
|
||||
&& dependency.version_id == "UjX6dr61"
|
||||
&& dependency.dependent_on_version_id.as_deref()
|
||||
== Some("pKFEfjEB")
|
||||
}));
|
||||
assert!(plan.dependencies.iter().any(|dependency| {
|
||||
dependency.project_id == "oWaK0Q19"
|
||||
&& dependency.version_id == "YhZLrAFC"
|
||||
&& dependency.dependent_on_version_id.as_deref()
|
||||
== Some("pKFEfjEB")
|
||||
}));
|
||||
assert!(plan.dependencies.iter().any(|dependency| {
|
||||
dependency.project_id == "T9PomCSv"
|
||||
&& dependency.version_id == "1L6XJqnY"
|
||||
&& dependency.dependent_on_version_id.as_deref()
|
||||
== Some("YhZLrAFC")
|
||||
}));
|
||||
assert_eq!(plan.skipped.len(), 1);
|
||||
assert_eq!(
|
||||
plan.skipped[0].reason,
|
||||
modrinth_content_management::SkippedReason::DuplicateProject
|
||||
);
|
||||
assert_eq!(plan.skipped[0].project_id, "LNytGWDc");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user