feat(core): package downloading with dependencies actually downloads required dependencies now
This commit is contained in:
parent
7b300ca5a5
commit
8e82c1e26a
9 changed files with 666 additions and 11 deletions
|
|
@ -698,7 +698,11 @@ impl Actions {
|
|||
let spec = match &install.result {
|
||||
Ok(name) => NotificationSpec {
|
||||
title: "Installed".to_string(),
|
||||
body: format!("Added {name}"),
|
||||
body: crate::install::install_body(
|
||||
name,
|
||||
&install.dependencies,
|
||||
&install.missing_dependencies,
|
||||
),
|
||||
level: Level::Info,
|
||||
icon: Some(IconType::Download01),
|
||||
progress: None,
|
||||
|
|
|
|||
|
|
@ -161,6 +161,42 @@ pub async fn combined_cluster_update_spec(
|
|||
pub struct PackageInstall {
|
||||
pub session_id: Option<uuid::Uuid>,
|
||||
pub result: anyhow::Result<String>,
|
||||
/// Required dependencies pulled in alongside the package, by display name.
|
||||
pub dependencies: Vec<String>,
|
||||
/// Required dependencies that couldn't be resolved or downloaded. The
|
||||
/// package still installs; the caller says so in the notification.
|
||||
pub missing_dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
/// "Added Sodium with 2 dependencies. Could not add: Fabric API."
|
||||
pub fn install_body(name: &str, dependencies: &[String], missing: &[String]) -> String {
|
||||
let mut body = format!("Added {name}");
|
||||
|
||||
if !dependencies.is_empty() {
|
||||
body.push_str(&format!(
|
||||
" with {} dependenc{}",
|
||||
dependencies.len(),
|
||||
if dependencies.len() == 1 { "y" } else { "ies" }
|
||||
));
|
||||
}
|
||||
body.push('.');
|
||||
|
||||
if !missing.is_empty() {
|
||||
body.push_str(&format!(" Could not add: {}.", missing.join(", ")));
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
impl PackageInstall {
|
||||
fn failed(err: anyhow::Error) -> Self {
|
||||
Self {
|
||||
session_id: None,
|
||||
result: Err(err),
|
||||
dependencies: Vec::new(),
|
||||
missing_dependencies: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn install_package(
|
||||
|
|
@ -184,19 +220,88 @@ pub async fn install_package(
|
|||
|
||||
let (project, version) = match lookup {
|
||||
Ok(found) => found,
|
||||
Err(err) => {
|
||||
return PackageInstall {
|
||||
session_id: None,
|
||||
result: Err(err),
|
||||
};
|
||||
}
|
||||
Err(err) => return PackageInstall::failed(err),
|
||||
};
|
||||
|
||||
// Worked out before the session starts so its children can be announced up
|
||||
// front; a failure here leaves the package itself installable.
|
||||
let mut resolution = oneclient_content::packages::DependencyResolution::default();
|
||||
if oneclient_content::packages::resolves_dependencies(project.content_type) {
|
||||
match oneclient_content::packages::resolve_required(
|
||||
provider,
|
||||
&version,
|
||||
cluster_id,
|
||||
&state.services.content(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resolved) => resolution = resolved,
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "dependency resolution failed, installing package alone");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let session = oneclient_events::GroupedProgressSession::start(
|
||||
&state.services.events,
|
||||
format!("Installing {}", project.name),
|
||||
);
|
||||
|
||||
let size = version.primary_file().map(|f| f.size).unwrap_or(0);
|
||||
let dependency_bytes: u64 = resolution
|
||||
.install
|
||||
.iter()
|
||||
.map(|dep| dep.version.primary_file().map(|f| f.size).unwrap_or(0))
|
||||
.sum();
|
||||
session.expect(
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
1 + resolution.install.len() as u64,
|
||||
size + dependency_bytes,
|
||||
);
|
||||
|
||||
let mut installed_dependencies = Vec::new();
|
||||
let mut missing_dependencies = resolution.unresolved;
|
||||
|
||||
// Dependencies go in first so the package is never sitting in a cluster
|
||||
// without them, however the run ends.
|
||||
for dependency in &resolution.install {
|
||||
let child = session.child(
|
||||
dependency.project.name.clone(),
|
||||
dependency
|
||||
.version
|
||||
.primary_file()
|
||||
.map(|f| f.size)
|
||||
.unwrap_or(0),
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
);
|
||||
|
||||
let result = PackageStore::install_to_cluster(
|
||||
provider,
|
||||
&dependency.project,
|
||||
&dependency.version,
|
||||
cluster_id,
|
||||
false,
|
||||
false,
|
||||
Some(&child),
|
||||
&state.services.content(),
|
||||
)
|
||||
.await;
|
||||
|
||||
child.finish();
|
||||
|
||||
match result {
|
||||
Ok(_) => installed_dependencies.push(dependency.project.name.clone()),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
dependency = %dependency.project.name,
|
||||
%err,
|
||||
"failed to install dependency"
|
||||
);
|
||||
missing_dependencies.push(dependency.project.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let child = session.child(
|
||||
project.name.clone(),
|
||||
size,
|
||||
|
|
@ -220,5 +325,7 @@ pub async fn install_package(
|
|||
PackageInstall {
|
||||
session_id: Some(session.detach()),
|
||||
result: result.map(|_| project.name).map_err(anyhow::Error::from),
|
||||
dependencies: installed_dependencies,
|
||||
missing_dependencies,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
352
packages/oneclient_content/src/packages/dependencies.rs
Normal file
352
packages/oneclient_content/src/packages/dependencies.rs
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
use std::collections::{HashSet, VecDeque};
|
||||
|
||||
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
|
||||
use oneclient_db::models::ClusterRow;
|
||||
|
||||
use crate::ctx::ContentCtx;
|
||||
use crate::error::ContentResult;
|
||||
use crate::packages::provider::PackageProvider;
|
||||
use crate::packages::store::PackageStore;
|
||||
use crate::packages::types::{
|
||||
DependencyKind, ProjectDetail, ReleaseType, VersionDependency, VersionDetail, VersionSummary,
|
||||
};
|
||||
|
||||
/// How far the transitive walk goes. Real graphs are two or three deep; the cap
|
||||
/// is only here so a provider serving a cycle can't spin forever.
|
||||
const MAX_DEPTH: usize = 4;
|
||||
|
||||
/// How many of a project's versions to consider when picking one. The providers
|
||||
/// already filter by game version and loader, so the answer is near the top.
|
||||
const VERSION_WINDOW: usize = 50;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedDependency {
|
||||
pub project: ProjectDetail,
|
||||
pub version: VersionDetail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DependencyResolution {
|
||||
/// Dependencies that need installing, ordered breadth-first from the root.
|
||||
/// Already-present ones are filtered out.
|
||||
pub install: Vec<ResolvedDependency>,
|
||||
/// Required dependencies that couldn't be resolved, by whatever id the
|
||||
/// provider gave for them. The caller decides how loud to be about these.
|
||||
pub unresolved: Vec<String>,
|
||||
}
|
||||
|
||||
impl DependencyResolution {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.install.is_empty() && self.unresolved.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects the required dependencies of `root` that `cluster_id` doesn't
|
||||
/// already have, transitively.
|
||||
#[tracing::instrument(level = "debug", skip(root, ctx), fields(project_id = %root.project_id, version_id = %root.version_id))]
|
||||
pub async fn resolve_required(
|
||||
provider_id: ProviderId,
|
||||
root: &VersionDetail,
|
||||
cluster_id: i64,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<DependencyResolution> {
|
||||
let mut resolution = DependencyResolution::default();
|
||||
if !root.dependencies.iter().any(is_required) {
|
||||
return Ok(resolution);
|
||||
}
|
||||
|
||||
let provider = ctx.providers.get(provider_id)?;
|
||||
let cluster = PackageStore::get_cluster(cluster_id, ctx).await?;
|
||||
|
||||
// Seeded with what the cluster already has so an installed library is never
|
||||
// fetched twice, plus the root itself against self-referencing graphs.
|
||||
let mut seen = installed_project_ids(provider_id, cluster_id, ctx).await?;
|
||||
seen.insert(root.project_id.clone());
|
||||
|
||||
let mut queue: VecDeque<(VersionDependency, usize)> = root
|
||||
.dependencies
|
||||
.iter()
|
||||
.filter(|dep| is_required(dep))
|
||||
.map(|dep| (dep.clone(), 1))
|
||||
.collect();
|
||||
|
||||
while let Some((dep, depth)) = queue.pop_front() {
|
||||
if let Some(project_id) = &dep.project_id
|
||||
&& seen.contains(project_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolved = match resolve_one(provider, &dep, &cluster, ctx).await {
|
||||
Ok(Some(resolved)) => resolved,
|
||||
Ok(None) => {
|
||||
tracing::warn!(?dep, "no compatible version for dependency");
|
||||
resolution.unresolved.push(dependency_label(&dep));
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(?dep, %err, "failed to resolve dependency");
|
||||
resolution.unresolved.push(dependency_label(&dep));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// A version-pinned dependency only reveals its project once fetched, so
|
||||
// the second dedupe check has to happen here rather than up front.
|
||||
if !seen.insert(resolved.version.project_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if depth < MAX_DEPTH {
|
||||
for next in resolved.version.dependencies.iter().filter(|d| is_required(d)) {
|
||||
queue.push_back((next.clone(), depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
resolution.install.push(resolved);
|
||||
}
|
||||
|
||||
Ok(resolution)
|
||||
}
|
||||
|
||||
fn is_required(dep: &VersionDependency) -> bool {
|
||||
dep.kind == DependencyKind::Required
|
||||
}
|
||||
|
||||
fn dependency_label(dep: &VersionDependency) -> String {
|
||||
dep.project_id
|
||||
.clone()
|
||||
.or_else(|| dep.version_id.clone())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
async fn resolve_one(
|
||||
provider: &dyn PackageProvider,
|
||||
dep: &VersionDependency,
|
||||
cluster: &ClusterRow,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<Option<ResolvedDependency>> {
|
||||
// A pinned version wins outright: the author named that exact file, so it is
|
||||
// used even when a newer one exists. Both providers ignore the project id
|
||||
// argument when the version id is known.
|
||||
let version = match &dep.version_id {
|
||||
Some(version_id) => {
|
||||
provider
|
||||
.get_version(
|
||||
dep.project_id.as_deref().unwrap_or_default(),
|
||||
version_id,
|
||||
ctx,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
let Some(project_id) = dep.project_id.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(pick) = pick_version(provider, project_id, cluster, ctx).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
provider.get_version(project_id, &pick.version_id, ctx).await?
|
||||
}
|
||||
};
|
||||
|
||||
let project = provider.get_project(&version.project_id, ctx).await?;
|
||||
Ok(Some(ResolvedDependency { project, version }))
|
||||
}
|
||||
|
||||
/// Picks the newest release of `project_id` that fits the cluster, falling back
|
||||
/// to the newest prerelease when that's all there is.
|
||||
async fn pick_version(
|
||||
provider: &dyn PackageProvider,
|
||||
project_id: &str,
|
||||
cluster: &ClusterRow,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<Option<VersionSummary>> {
|
||||
let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Vanilla);
|
||||
let loader_filter = loader.is_modded().then_some(loader);
|
||||
|
||||
let mut candidates = provider
|
||||
.list_versions(
|
||||
project_id,
|
||||
Some(&cluster.mc_version),
|
||||
loader_filter,
|
||||
0,
|
||||
VERSION_WINDOW,
|
||||
ctx,
|
||||
)
|
||||
.await?
|
||||
.items;
|
||||
|
||||
// Resource packs, shaders and datapacks declare no loader, so a
|
||||
// loader-filtered query comes back empty even though they fit the cluster.
|
||||
if candidates.is_empty() && loader_filter.is_some() {
|
||||
candidates = provider
|
||||
.list_versions(project_id, Some(&cluster.mc_version), None, 0, VERSION_WINDOW, ctx)
|
||||
.await?
|
||||
.items;
|
||||
}
|
||||
|
||||
Ok(choose_version(candidates, cluster, loader))
|
||||
}
|
||||
|
||||
fn choose_version(
|
||||
mut candidates: Vec<VersionSummary>,
|
||||
cluster: &ClusterRow,
|
||||
loader: GameLoader,
|
||||
) -> Option<VersionSummary> {
|
||||
candidates.retain(|version| fits_cluster(version, cluster, loader));
|
||||
// Neither provider promises an order, so sort rather than trust the page.
|
||||
candidates.sort_by_key(|version| std::cmp::Reverse(version.published));
|
||||
|
||||
candidates
|
||||
.iter()
|
||||
.find(|version| matches!(version.release_type, ReleaseType::Release))
|
||||
.or_else(|| candidates.first())
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Mirrors the compatibility check the store applies at install time, so a
|
||||
/// version that would be rejected there is never picked here.
|
||||
fn fits_cluster(version: &VersionSummary, cluster: &ClusterRow, loader: GameLoader) -> bool {
|
||||
if !version.game_versions.is_empty()
|
||||
&& !version
|
||||
.game_versions
|
||||
.iter()
|
||||
.any(|v| cluster.mc_version.contains(v))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
version.loaders.is_empty() || version.loaders.iter().any(|l| loader.compatible_with(*l))
|
||||
}
|
||||
|
||||
async fn installed_project_ids(
|
||||
provider_id: ProviderId,
|
||||
cluster_id: i64,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<HashSet<String>> {
|
||||
Ok(PackageStore::list_linked_artifacts(cluster_id, ctx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|linked| linked.provider == Some(provider_id))
|
||||
.filter_map(|linked| linked.project_id)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Dependencies only make sense for content the loader reads out of the cluster;
|
||||
/// a modpack brings its own file list.
|
||||
pub fn resolves_dependencies(content_type: ContentType) -> bool {
|
||||
!matches!(content_type, ContentType::Modpack)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn summary(
|
||||
version_id: &str,
|
||||
release_type: ReleaseType,
|
||||
day: u32,
|
||||
loaders: Vec<GameLoader>,
|
||||
game_versions: Vec<&str>,
|
||||
) -> VersionSummary {
|
||||
VersionSummary {
|
||||
version_id: version_id.into(),
|
||||
project_id: "p".into(),
|
||||
name: version_id.into(),
|
||||
version_number: version_id.into(),
|
||||
published: Utc.with_ymd_and_hms(2025, 1, day, 0, 0, 0).unwrap(),
|
||||
release_type,
|
||||
game_versions: game_versions.into_iter().map(Into::into).collect(),
|
||||
loaders,
|
||||
downloads: 0,
|
||||
file_size: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn cluster(loader: GameLoader, mc_version: &str) -> ClusterRow {
|
||||
ClusterRow {
|
||||
id: 1,
|
||||
name: "c".into(),
|
||||
folder_name: "c".into(),
|
||||
setting_profile_name: None,
|
||||
mc_version: mc_version.into(),
|
||||
mc_loader: loader as i64,
|
||||
stage: 0,
|
||||
mc_loader_version: None,
|
||||
created_at: None,
|
||||
last_played: None,
|
||||
overall_played: None,
|
||||
linked_modpack_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_the_newest_release_over_a_newer_prerelease() {
|
||||
let cluster = cluster(GameLoader::Fabric, "1.21.4");
|
||||
let candidates = vec![
|
||||
summary("old", ReleaseType::Release, 1, vec![GameLoader::Fabric], vec!["1.21.4"]),
|
||||
summary("beta", ReleaseType::Beta, 3, vec![GameLoader::Fabric], vec!["1.21.4"]),
|
||||
summary("new", ReleaseType::Release, 2, vec![GameLoader::Fabric], vec!["1.21.4"]),
|
||||
];
|
||||
|
||||
let pick = choose_version(candidates, &cluster, GameLoader::Fabric);
|
||||
assert_eq!(pick.unwrap().version_id, "new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_a_prerelease_when_no_release_fits() {
|
||||
let cluster = cluster(GameLoader::Fabric, "1.21.4");
|
||||
let candidates = vec![
|
||||
summary("alpha", ReleaseType::Alpha, 1, vec![GameLoader::Fabric], vec!["1.21.4"]),
|
||||
summary("beta", ReleaseType::Beta, 2, vec![GameLoader::Fabric], vec!["1.21.4"]),
|
||||
];
|
||||
|
||||
let pick = choose_version(candidates, &cluster, GameLoader::Fabric);
|
||||
assert_eq!(pick.unwrap().version_id, "beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_versions_the_store_would_reject() {
|
||||
let cluster = cluster(GameLoader::Fabric, "1.21.4");
|
||||
let candidates = vec![
|
||||
summary("forge", ReleaseType::Release, 3, vec![GameLoader::Forge], vec!["1.21.4"]),
|
||||
summary("old-mc", ReleaseType::Release, 2, vec![GameLoader::Fabric], vec!["1.20.1"]),
|
||||
summary("fits", ReleaseType::Release, 1, vec![GameLoader::Fabric], vec!["1.21.4"]),
|
||||
];
|
||||
|
||||
let pick = choose_version(candidates, &cluster, GameLoader::Fabric);
|
||||
assert_eq!(pick.unwrap().version_id, "fits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_loaderless_content_for_a_modded_cluster() {
|
||||
let cluster = cluster(GameLoader::Fabric, "1.21.4");
|
||||
let candidates = vec![summary(
|
||||
"pack",
|
||||
ReleaseType::Release,
|
||||
1,
|
||||
Vec::new(),
|
||||
vec!["1.21.4"],
|
||||
)];
|
||||
|
||||
let pick = choose_version(candidates, &cluster, GameLoader::Fabric);
|
||||
assert_eq!(pick.unwrap().version_id, "pack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_compatible_version_resolves_to_nothing() {
|
||||
let cluster = cluster(GameLoader::Fabric, "1.21.4");
|
||||
let candidates = vec![summary(
|
||||
"forge",
|
||||
ReleaseType::Release,
|
||||
1,
|
||||
vec![GameLoader::Forge],
|
||||
vec!["1.21.4"],
|
||||
)];
|
||||
|
||||
assert!(choose_version(candidates, &cluster, GameLoader::Fabric).is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -159,6 +159,9 @@ async fn cached_version_detail(
|
|||
size: artifact.size_bytes.unwrap_or(0) as u64,
|
||||
fingerprint: None,
|
||||
}],
|
||||
// Not persisted with the release row; callers that need the dependency
|
||||
// graph go to the provider.
|
||||
dependencies: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod dependencies;
|
||||
pub mod error;
|
||||
pub mod metadata_cache;
|
||||
pub mod modpack;
|
||||
|
|
@ -14,6 +15,9 @@ pub use metadata_cache::{
|
|||
CachedPackageMeta, cached_project_detail, fetch_package_meta, get_version_cached,
|
||||
read_cached_package_meta,
|
||||
};
|
||||
pub use dependencies::{
|
||||
DependencyResolution, ResolvedDependency, resolve_required, resolves_dependencies,
|
||||
};
|
||||
pub use file_identity::{curseforge_fingerprint, FileIdentity};
|
||||
pub use error::{PackageError, PackageResult};
|
||||
pub use provider::{PackageProvider, PackageProviderRegistry};
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ use oneclient_common::constants::{CURSEFORGE_API_URL, CURSEFORGE_GAME_ID};
|
|||
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
|
||||
use crate::packages::file_identity::FileIdentity;
|
||||
use crate::packages::types::{
|
||||
GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary, ReleaseType,
|
||||
SearchFilters, VersionDetail, VersionFile, VersionLookup, VersionSummary,
|
||||
DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary,
|
||||
ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup,
|
||||
VersionSummary,
|
||||
};
|
||||
use crate::ctx::ContentCtx;
|
||||
|
||||
|
|
@ -460,6 +461,32 @@ struct CfFile {
|
|||
file_fingerprint: u32,
|
||||
download_url: String,
|
||||
file_length: u64,
|
||||
#[serde(default)]
|
||||
dependencies: Vec<CfDependency>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CfDependency {
|
||||
mod_id: u32,
|
||||
relation_type: u8,
|
||||
}
|
||||
|
||||
impl From<CfDependency> for VersionDependency {
|
||||
fn from(d: CfDependency) -> Self {
|
||||
Self {
|
||||
project_id: Some(d.mod_id.to_string()),
|
||||
version_id: None,
|
||||
// 1 embedded library, 2 optional, 3 required, 4 tool, 5 incompatible,
|
||||
// 6 include.
|
||||
kind: match d.relation_type {
|
||||
3 => DependencyKind::Required,
|
||||
5 => DependencyKind::Incompatible,
|
||||
1 | 6 => DependencyKind::Embedded,
|
||||
_ => DependencyKind::Optional,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Copy)]
|
||||
|
|
@ -627,6 +654,7 @@ impl From<CfFile> for VersionDetail {
|
|||
size: f.file_length,
|
||||
fingerprint: Some(f.file_fingerprint.to_string()),
|
||||
}],
|
||||
dependencies: f.dependencies.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -698,3 +726,52 @@ fn cf_loader_type(loader: GameLoader) -> Option<u8> {
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Guards the camelCase contract; the relation type is a bare number, so a
|
||||
/// mismap here quietly turns required libraries into optional ones.
|
||||
#[test]
|
||||
fn file_carries_its_dependencies() {
|
||||
let raw = serde_json::json!({
|
||||
"id": 1,
|
||||
"modId": 2,
|
||||
"displayName": "Sodium",
|
||||
"fileName": "sodium.jar",
|
||||
"releaseType": 1,
|
||||
"fileDate": "2025-01-01T00:00:00Z",
|
||||
"downloadCount": 10,
|
||||
"gameVersions": ["1.21.4"],
|
||||
"modLoaders": [],
|
||||
"hashes": [],
|
||||
"fileFingerprint": 123,
|
||||
"downloadUrl": "https://example.invalid/sodium.jar",
|
||||
"fileLength": 100,
|
||||
"dependencies": [
|
||||
{"modId": 10, "relationType": 3},
|
||||
{"modId": 11, "relationType": 2},
|
||||
{"modId": 12, "relationType": 5},
|
||||
{"modId": 13, "relationType": 1}
|
||||
]
|
||||
});
|
||||
|
||||
let version: VersionDetail = serde_json::from_value::<CfFile>(raw)
|
||||
.expect("curseforge file")
|
||||
.into();
|
||||
|
||||
assert_eq!(
|
||||
version.dependencies[0],
|
||||
VersionDependency {
|
||||
project_id: Some("10".into()),
|
||||
version_id: None,
|
||||
kind: DependencyKind::Required,
|
||||
}
|
||||
);
|
||||
assert_eq!(version.dependencies[1].kind, DependencyKind::Optional);
|
||||
assert_eq!(version.dependencies[2].kind, DependencyKind::Incompatible);
|
||||
assert_eq!(version.dependencies[3].kind, DependencyKind::Embedded);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ use oneclient_net::{RequestClient, RequestError};
|
|||
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
|
||||
use crate::packages::file_identity::FileIdentity;
|
||||
use crate::packages::types::{
|
||||
GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary, ReleaseType,
|
||||
SearchFilters, VersionDetail, VersionFile, VersionLookup, VersionSummary,
|
||||
DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary,
|
||||
ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup,
|
||||
VersionSummary,
|
||||
};
|
||||
use crate::ctx::ContentCtx;
|
||||
|
||||
|
|
@ -550,6 +551,33 @@ struct ModrinthVersion {
|
|||
files: Vec<ModrinthFile>,
|
||||
#[serde(default)]
|
||||
version_type: String,
|
||||
#[serde(default)]
|
||||
dependencies: Vec<ModrinthDependency>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
struct ModrinthDependency {
|
||||
#[serde(default)]
|
||||
project_id: Option<String>,
|
||||
#[serde(default)]
|
||||
version_id: Option<String>,
|
||||
#[serde(default)]
|
||||
dependency_type: String,
|
||||
}
|
||||
|
||||
impl From<ModrinthDependency> for VersionDependency {
|
||||
fn from(d: ModrinthDependency) -> Self {
|
||||
Self {
|
||||
project_id: d.project_id.filter(|id| !id.is_empty()),
|
||||
version_id: d.version_id.filter(|id| !id.is_empty()),
|
||||
kind: match d.dependency_type.as_str() {
|
||||
"required" => DependencyKind::Required,
|
||||
"incompatible" => DependencyKind::Incompatible,
|
||||
"embedded" => DependencyKind::Embedded,
|
||||
_ => DependencyKind::Optional,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
|
|
@ -585,6 +613,7 @@ impl From<ModrinthVersion> for VersionDetail {
|
|||
published: v.date_published,
|
||||
downloads: v.downloads,
|
||||
files: v.files.into_iter().map(Into::into).collect(),
|
||||
dependencies: v.dependencies.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -648,3 +677,58 @@ fn parse_release_type(s: &str) -> ReleaseType {
|
|||
_ => ReleaseType::Release,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Guards the field names against the shape Modrinth actually serves; a
|
||||
/// rename here silently empties the dependency list.
|
||||
#[test]
|
||||
fn version_carries_its_dependencies() {
|
||||
let raw = serde_json::json!({
|
||||
"id": "abc",
|
||||
"project_id": "sodium",
|
||||
"name": "Sodium 0.6",
|
||||
"version_number": "0.6",
|
||||
"changelog": null,
|
||||
"game_versions": ["1.21.4"],
|
||||
"loaders": ["fabric"],
|
||||
"date_published": "2025-01-01T00:00:00Z",
|
||||
"downloads": 10,
|
||||
"version_type": "release",
|
||||
"files": [],
|
||||
"dependencies": [
|
||||
{"project_id": "fabric-api", "version_id": null, "dependency_type": "required"},
|
||||
{"project_id": null, "version_id": "pinned", "dependency_type": "required"},
|
||||
{"project_id": "sodium-extra", "version_id": null, "dependency_type": "optional"},
|
||||
{"project_id": "optifine", "version_id": null, "dependency_type": "incompatible"},
|
||||
],
|
||||
});
|
||||
|
||||
let version: VersionDetail = serde_json::from_value::<ModrinthVersion>(raw)
|
||||
.expect("modrinth version")
|
||||
.into();
|
||||
|
||||
assert_eq!(version.dependencies.len(), 4);
|
||||
assert_eq!(
|
||||
version.dependencies[0],
|
||||
VersionDependency {
|
||||
project_id: Some("fabric-api".into()),
|
||||
version_id: None,
|
||||
kind: DependencyKind::Required,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
version.dependencies[1],
|
||||
VersionDependency {
|
||||
project_id: None,
|
||||
version_id: Some("pinned".into()),
|
||||
kind: DependencyKind::Required,
|
||||
}
|
||||
);
|
||||
assert_eq!(version.dependencies[2].kind, DependencyKind::Optional);
|
||||
assert_eq!(version.dependencies[3].kind, DependencyKind::Incompatible);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -493,6 +493,7 @@ mod tests {
|
|||
published: Utc::now(),
|
||||
downloads: 0,
|
||||
files: Vec::new(),
|
||||
dependencies: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -142,6 +142,29 @@ pub struct VersionDetail {
|
|||
pub published: DateTime<Utc>,
|
||||
pub downloads: u64,
|
||||
pub files: Vec<VersionFile>,
|
||||
/// What the provider says this version needs alongside it. Only
|
||||
/// [`DependencyKind::Required`] entries are pulled in automatically.
|
||||
#[serde(default)]
|
||||
pub dependencies: Vec<VersionDependency>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DependencyKind {
|
||||
Required,
|
||||
Optional,
|
||||
Incompatible,
|
||||
Embedded,
|
||||
}
|
||||
|
||||
/// A provider-declared relation to another project. At least one of the two ids
|
||||
/// is set: Modrinth pins either a project, a specific version, or both, while
|
||||
/// CurseForge only ever names the project.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VersionDependency {
|
||||
pub project_id: Option<String>,
|
||||
pub version_id: Option<String>,
|
||||
pub kind: DependencyKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue