diff --git a/packages/oneclient_app/src/components/splash_curtain.rs b/packages/oneclient_app/src/components/splash_curtain.rs index bf04733c..8b5be9c4 100644 --- a/packages/oneclient_app/src/components/splash_curtain.rs +++ b/packages/oneclient_app/src/components/splash_curtain.rs @@ -3,7 +3,6 @@ use std::time::Duration; use freya::animation::{AnimNum, Ease, Function, use_animation}; use freya::prelude::*; -use crate::AppAssets; use crate::hooks::use_splash; use crate::theme::colors; @@ -37,8 +36,6 @@ impl Component for CurtainFade { let splash = use_splash(); let ready = *splash.home_ready.read(); - let logo = use_memo(|| AppAssets::get_bytes("logo.svg").unwrap_or_default()); - let fade = use_animation(|_| { AnimNum::new(1.0, 0.0) .time(FADE_MS) @@ -81,27 +78,6 @@ impl Component for CurtainFade { .opacity(opacity) .background(colors::page()) .window_drag() - .child( - rect() - .width(Size::fill()) - .height(Size::fill()) - .center() - .vertical() - .spacing(14.) - .child( - SvgViewer::new(("logo.svg", logo.read().cloned())) - .show_loader(false) - .width(Size::px(288.)) - .height(Size::px(60.)) - .color(colors::fg_primary()), - ) - .child( - label() - .text("The only Minecraft launcher you'll need.") - .font_size(15.) - .color(colors::fg_primary().with_a(140)), - ), - ) .into_element() } } diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index 5f1ada78..634e47d2 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -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, diff --git a/packages/oneclient_app/src/install.rs b/packages/oneclient_app/src/install.rs index d66b0acb..2b2aa314 100644 --- a/packages/oneclient_app/src/install.rs +++ b/packages/oneclient_app/src/install.rs @@ -161,6 +161,42 @@ pub async fn combined_cluster_update_spec( pub struct PackageInstall { pub session_id: Option, pub result: anyhow::Result, + /// Required dependencies pulled in alongside the package, by display name. + pub dependencies: Vec, + /// Required dependencies that couldn't be resolved or downloaded. The + /// package still installs; the caller says so in the notification. + pub missing_dependencies: Vec, +} + +/// "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, } } diff --git a/packages/oneclient_content/src/packages/dependencies.rs b/packages/oneclient_content/src/packages/dependencies.rs new file mode 100644 index 00000000..64644499 --- /dev/null +++ b/packages/oneclient_content/src/packages/dependencies.rs @@ -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, + /// 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, +} + +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 { + 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> { + // 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> { + 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, + cluster: &ClusterRow, + loader: GameLoader, +) -> Option { + 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> { + 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, + 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()); + } +} diff --git a/packages/oneclient_content/src/packages/metadata_cache.rs b/packages/oneclient_content/src/packages/metadata_cache.rs index 41d00935..ac248b42 100644 --- a/packages/oneclient_content/src/packages/metadata_cache.rs +++ b/packages/oneclient_content/src/packages/metadata_cache.rs @@ -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(), }) } diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs index 1e390bf8..06a7a2bd 100644 --- a/packages/oneclient_content/src/packages/mod.rs +++ b/packages/oneclient_content/src/packages/mod.rs @@ -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}; diff --git a/packages/oneclient_content/src/packages/provider/curseforge.rs b/packages/oneclient_content/src/packages/provider/curseforge.rs index 46001966..3c3d4493 100644 --- a/packages/oneclient_content/src/packages/provider/curseforge.rs +++ b/packages/oneclient_content/src/packages/provider/curseforge.rs @@ -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, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfDependency { + mod_id: u32, + relation_type: u8, +} + +impl From 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 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 { _ => 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::(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); + } +} diff --git a/packages/oneclient_content/src/packages/provider/modrinth.rs b/packages/oneclient_content/src/packages/provider/modrinth.rs index 58e05b3f..3c851fc8 100644 --- a/packages/oneclient_content/src/packages/provider/modrinth.rs +++ b/packages/oneclient_content/src/packages/provider/modrinth.rs @@ -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, #[serde(default)] version_type: String, + #[serde(default)] + dependencies: Vec, +} + +#[derive(Clone, Deserialize)] +struct ModrinthDependency { + #[serde(default)] + project_id: Option, + #[serde(default)] + version_id: Option, + #[serde(default)] + dependency_type: String, +} + +impl From 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 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::(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); + } +} diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index 30b45cff..56dc4964 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -493,6 +493,7 @@ mod tests { published: Utc::now(), downloads: 0, files: Vec::new(), + dependencies: Vec::new(), } } diff --git a/packages/oneclient_content/src/packages/types.rs b/packages/oneclient_content/src/packages/types.rs index 88be6bff..33b47558 100644 --- a/packages/oneclient_content/src/packages/types.rs +++ b/packages/oneclient_content/src/packages/types.rs @@ -142,6 +142,29 @@ pub struct VersionDetail { pub published: DateTime, pub downloads: u64, pub files: Vec, + /// What the provider says this version needs alongside it. Only + /// [`DependencyKind::Required`] entries are pulled in automatically. + #[serde(default)] + pub dependencies: Vec, +} + +#[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, + pub version_id: Option, + pub kind: DependencyKind, } #[derive(Debug, Clone, Serialize, Deserialize)]