feat: instances v2 (#6431)

* feat: base of instances v2

* feat: use old profiles with compat layer

* prototype: instances v2

* fix: install_from using profile

* fix: skins migration fix

* fix: frontend still using profile path

* fix: add update proj multiselect guard

* fix: cargo fmt

* fix: content missing fields

* feat: break up app-lib/api/instance.rs

* fix: check_content_updates mismatch

* fix: updater modal cleanup w/new structure

* feat: better update all handling

* fix: remove preview_update_all

* fix: feedback on bulk update + lint

* fix: rem transitions

* fix: change to jsonb

* feat: app db backup after update

* fix: lint

* fix: sqlx prepare + use sqlx macros

* fix: lint

* fix: bugs

* feat: defuck the installing process up

* fix: bug of hell

* fix: shear

* fix: fmt

* fix: install progress spacing + change mc/content/overrides to bytes

* fix: lint

* fix: prepr

* fix: navtabs anim not working in app

* fix: worlds.vue improvements + browse page fixes

* feat: optimise queries + adapter fns

* fix: lint

* fix: lint

* feat: shared modrinth-content-management crate (#6469)

* feat: disable warnings setting

* feat: add instances shortcuts (#6329)

* Add modrinth://launch deep link to start a profile

Support external profile launching via modrinth://launch/{profile_path} for integrations such as Stream Deck.

* Change route to /launch/profile/{id} for future extensibility

* fix: ensure profile path is url decoded

* fix: URL-decode profile path from deep link

* fix: use urlencoding crate for URL decoding

* feat: implement app instance shortcuts

* feat: change windows shortcut creation to use windows api instead

* feat: implement creating a shortcut launching world/server

* format

* fmt

* fix multiline inline tables

* pnpm prepr

* feat: move create shortcut to last item

* refactor: split up shortcuts.rs for individual platforms

* refactor: turn profile launch url into url type

* use string literal and add safety comment

* pt2

* refactor: rename anything that's profile into instance

* update mac shortcut

---------

Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>

---------

Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-06-25 21:19:29 +00:00
committed by GitHub
co-authored by DJCheesusReal Truman Gao
parent ef4044534f
commit 734720e11e
353 changed files with 24745 additions and 9771 deletions
@@ -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
@@ -0,0 +1 @@
Content management logic shared between Modrinth App & Labrinth
@@ -0,0 +1,354 @@
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;
// 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<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,
};
let mut resolver = InstallResolver::new(provider, &request);
resolver
.resolve_dependencies_for_version(primary_version)
.await?;
Ok(ResolveContentPlan {
primary,
dependencies: resolver.dependencies,
skipped: resolver.skipped,
})
}
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>,
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(),
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];
while let Some(version) = stack.pop() {
if !self.visited_versions.insert(version.id.clone()) {
continue;
}
for dependency in &version.dependencies {
if !matches!(
dependency.dependency_type,
DependencyType::Required
) {
continue;
}
if should_skip_quilt_fabric_api(dependency, self.target) {
self.skipped.push(SkippedContent {
project_id: QUILT_FABRIC_API_EXCEPTION_PROJECT_ID
.to_string(),
version_id: dependency.version_id.clone(),
dependent_on_version_id: Some(version.id.clone()),
reason: SkippedReason::QuiltFabricApi,
});
continue;
}
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());
if self.existing_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)
{
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()),
});
stack.push(dependency_version);
}
}
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 select_newest_matching_version(
mut versions: Vec<Version>,
content_type: ContentType,
selected: &ResolutionPreferences,
target: &ResolutionPreferences,
) -> Option<Version> {
versions.sort_by_key(|version| 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()
}
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;
}
let direct_match = preferences.loaders.iter().any(|loader| {
version
.loaders
.iter()
.any(|candidate| loaders_match(loader, candidate))
});
if direct_match {
return true;
}
content_type == ContentType::Mod
&& version.loaders.iter().any(|loader| loader == "datapack")
}
fn loaders_match(expected: &str, candidate: &str) -> bool {
let expected = expected.to_lowercase();
let candidate = candidate.to_lowercase();
expected == candidate
|| loader_aliases(&expected).contains(&candidate.as_str())
|| loader_aliases(&candidate).contains(&expected.as_str())
}
fn loader_aliases(loader: &str) -> &'static [&'static str] {
match loader {
"neoforge" => &["neo"],
"neo" => &["neoforge"],
"paper" | "purpur" | "spigot" | "bukkit" => {
&["paper", "purpur", "spigot", "bukkit"]
}
_ => &[],
}
}
fn should_skip_quilt_fabric_api(
dependency: &Dependency,
target: &ResolutionPreferences,
) -> bool {
dependency.project_id.as_deref()
== Some(QUILT_FABRIC_API_EXCEPTION_PROJECT_ID)
&& target
.loaders
.iter()
.any(|loader| loaders_match(loader, "quilt"))
}
@@ -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;
@@ -0,0 +1,114 @@
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>,
}
#[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>,
}
#[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,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Version {
pub id: String,
pub project_id: String,
pub date_published: DateTime<Utc>,
#[serde(default)]
pub dependencies: Vec<Dependency>,
#[serde(default)]
pub game_versions: Vec<String>,
#[serde(default)]
pub loaders: Vec<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,
}
@@ -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>;
}
@@ -0,0 +1,575 @@
#[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 {
id: id.to_string(),
project_id: project_id.to_string(),
date_published: DateTime::parse_from_rfc3339(date)
.unwrap()
.with_timezone(&Utc),
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(),
}
}
#[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 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 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_is_used_even_when_target_mismatches() {
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[0].version_id, "depv1");
}
#[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_skip_fabric_api_dependency() {
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,
)],
)]);
let mut request = request("p1");
request.target.loaders = vec!["quilt".to_string()];
let plan = resolve_content(provider, request).await.unwrap();
assert!(plan.dependencies.is_empty());
assert_eq!(plan.skipped[0].reason, SkippedReason::QuiltFabricApi);
}
#[tokio::test]
async fn mods_can_fall_back_to_datapack_versions() {
let provider = MemoryProvider::default().with_versions(vec![version(
"p1v1",
"p1",
"2024-01-01T00:00:00Z",
&["1.20.1"],
&["datapack"],
vec![],
)]);
let plan = resolve_content(provider, request("p1")).await.unwrap();
assert_eq!(plan.primary.version_id, "p1v1");
}
#[tokio::test]
async fn neoforge_matches_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()];
let plan = resolve_content(provider, request).await.unwrap();
assert_eq!(plan.primary.version_id, "p1v1");
}
#[tokio::test]
async fn paper_matches_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()];
let plan = resolve_content(provider, request).await.unwrap();
assert_eq!(plan.primary.version_id, "p1v1");
}
#[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(),
},
)
.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(),
},
)
.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");
}
}