//! Parse mod metadata files from inside JAR archives to extract mod identity //! information (mod ID, name, version, authors, etc.) when Modrinth API //! lookups fail or have no match. //! //! Supported formats: //! - Fabric: `fabric.mod.json` (JSON) //! - Quilt: `quilt.mod.json` (JSON, same shape wrapped under `quilt_loader`) //! - Forge: `META-INF/mods.toml` (TOML) //! - NeoForge: `META-INF/neoforge.mods.toml` (TOML) //! - Legacy Forge: `mcmod.info` (JSON array) mod fabric; pub mod icon; pub mod manifest; mod mcmod_info; mod toml_mod; use bytes::Bytes; use serde::{Deserialize, Serialize}; /// Unified local mod metadata extracted from inside a JAR. /// /// Only `mod_id` is required; all other fields are best-effort. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalModMetadata { /// Unique mod identifier (e.g. "sodium", "minecraft") pub mod_id: String, /// Human-readable display name pub name: Option, /// Mod version string pub version: Option, /// Author list #[serde(default)] pub authors: Vec, /// Short description pub description: Option, /// Website or project URL pub url: Option, /// Path to icon inside the JAR (e.g. "icon.png" or "assets/.../icon.png") pub icon_path: Option, /// Supported Minecraft version range (e.g. ">=1.20", "[1.20,1.21)", "1.12.2") pub minecraft_version: Option, /// Required loader version (e.g. ">=0.15.0", "[52,)") pub loader_version: Option, /// Loader type (e.g. "fabric", "forge", "neoforge", "quilt") pub loader: Option, /// Required dependencies declared in the embedded metadata (Fabric /// `depends`, Quilt `depends`, Forge mandatory dependencies). /// /// `None` marks JSON written before dependency extraction existed and /// triggers a one-time re-extraction of the file. #[serde(default)] pub dependencies: Option>, } /// One required dependency declared in embedded mod metadata. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalModDependency { pub mod_id: String, #[serde(default)] pub version_range: Option, } /// Dependency ids that refer to the runtime environment instead of content /// that can be linked against installed files. pub(crate) fn is_env_dependency_id(id: &str) -> bool { matches!( id, "minecraft" | "java" | "fabricloader" | "quilt_loader" | "forge" | "neoforge" | "fml" ) } /// Try to extract `LocalModMetadata` from raw JAR bytes. /// /// Returns `None` when the JAR does not contain any known mod metadata file /// or when none of the supported formats can be successfully parsed. pub fn extract_mod_metadata(bytes: &Bytes) -> Option { let cursor = std::io::Cursor::new(&**bytes); let mut archive = zip::ZipArchive::new(cursor).ok()?; // Try each known metadata path in priority order. if let Some(meta) = try_fabric(&mut archive) { return Some(meta); } if let Some(meta) = try_quilt(&mut archive) { return Some(meta); } if let Some(meta) = try_toml_path(&mut archive, "META-INF/neoforge.mods.toml") { return Some(meta); } if let Some(meta) = try_toml_path(&mut archive, "META-INF/mods.toml") { return Some(meta); } if let Some(meta) = try_mcmod_info(&mut archive) { return Some(meta); } None } // ── format-specific parsers ──────────────────────────────────────────────── fn try_fabric( archive: &mut zip::ZipArchive>, ) -> Option { let mut file = archive.by_name("fabric.mod.json").ok()?; let parsed: fabric::FabricModJson = serde_json::from_reader(&mut file).ok()?; let authors = merge_authors(&parsed.authors, &parsed.contributors); // A mod without an id cannot be identified; skip it rather than // fabricating a shared placeholder that would make mods collide. let mod_id = parsed.id.clone()?; Some(LocalModMetadata { mod_id, name: parsed.name, version: parsed.version, authors, description: parsed.description, url: extract_contact_url(&parsed._contact), icon_path: parsed.icon.as_ref().and_then(|icon| icon.resolve()), minecraft_version: fabric::fabric_dep_value( &parsed.depends, "minecraft", ), loader_version: fabric::fabric_dep_value( &parsed.depends, "fabricloader", ), loader: Some("fabric".into()), dependencies: Some(fabric::fabric_dependencies(&parsed.depends)), }) } fn try_quilt( archive: &mut zip::ZipArchive>, ) -> Option { let mut file = archive.by_name("quilt.mod.json").ok()?; let parsed: fabric::QuiltModJson = serde_json::from_reader(&mut file).ok()?; let inner = parsed.quilt_loader; let authors = merge_authors(&inner.authors, &inner.contributors); let mod_id = inner.id.clone()?; Some(LocalModMetadata { mod_id, name: inner.name, version: inner.version, authors, description: inner.description, url: extract_contact_url(&inner._contact), icon_path: inner.icon.as_ref().and_then(|icon| icon.resolve()), minecraft_version: fabric::quilt_dep_value(&inner.depends, "minecraft"), loader_version: fabric::quilt_dep_value(&inner.depends, "quilt_loader"), loader: Some("quilt".into()), dependencies: Some(fabric::quilt_dependencies(&inner.depends)), }) } fn try_toml_path( archive: &mut zip::ZipArchive>, path: &str, ) -> Option { let mut content = String::new(); { let mut file = archive.by_name(path).ok()?; std::io::Read::read_to_string(&mut file, &mut content).ok()?; } let parsed: toml_mod::ModsToml = toml::from_str(&content).ok()?; // A mod jar may declare several [[mods]] entries (bundled mods); report // the first entry that carries an ID. An id-less entry (e.g. the // "minecraft" marker used by some packs) must not discard the metadata // of the real mod that follows. let entry = parsed .mods? .into_iter() .find(|entry| entry.mod_id.is_some())?; let mod_id = entry.mod_id.clone()?; let authors: Vec = entry .authors .as_deref() .map(|s| { s.split(',') .map(|a| a.trim().to_string()) .filter(|a| !a.is_empty()) .collect() }) .unwrap_or_default(); // Determine loader type from the file path. let is_neoforge = path.contains("neoforge"); let loader = if is_neoforge { Some("neoforge".into()) } else { Some("forge".into()) }; // The root `loaderVersion` IS the Forge/NeoForge loader version. let loader_version = parsed.loader_version.clone(); // Look up dependencies for this mod's modId. let minecraft_version = parsed .dependencies .as_ref() .and_then(|deps| deps.get(&mod_id)) .and_then(|entries| { entries .iter() .find(|dep| dep.mod_id.as_deref() == Some("minecraft")) .and_then(|dep| dep.version_range.clone()) }); let dependencies = parsed .dependencies .as_ref() .and_then(|deps| deps.get(&mod_id)) .map(|entries| { entries .iter() .filter(|dep| dep.mandatory.unwrap_or(true)) .filter_map(|dep| { let dep_id = dep.mod_id.clone()?; if is_env_dependency_id(&dep_id) { return None; } Some(LocalModDependency { mod_id: dep_id, version_range: dep.version_range.clone(), }) }) .collect::>() }) .unwrap_or_default(); // Forge `mods.toml` commonly stores the version as a Gradle placeholder // (e.g. `${file.jarVersion}`) that the loader resolves at runtime from the // JAR manifest's `Implementation-Version`. Resolve it here so the // placeholder never surfaces as a version in the UI. let version = resolve_toml_version(entry.version.clone(), archive); Some(LocalModMetadata { mod_id, name: entry.display_name, version, authors, description: entry.description, url: entry.display_url, icon_path: entry.logo_file, minecraft_version, loader_version, loader, dependencies: Some(dependencies), }) } fn try_mcmod_info( archive: &mut zip::ZipArchive>, ) -> Option { let mut file = archive.by_name("mcmod.info").ok()?; let entries: Vec = serde_json::from_reader(&mut file).ok()?; let entry = entries.into_iter().next()?; let mod_id = entry.modid.clone()?; Some(LocalModMetadata { mod_id, name: entry.name, version: entry.version, authors: entry.authors.unwrap_or_default(), description: entry.description, url: entry.url, icon_path: entry.logo_file, minecraft_version: entry.mcversion, loader_version: None, loader: Some("forge".into()), dependencies: Some(Vec::new()), }) } // ── helpers ──────────────────────────────────────────────────────────────── fn merge_authors( primary: &[fabric::FabricAuthorOrArray], contributors: &[fabric::FabricAuthorOrArray], ) -> Vec { primary .iter() .chain(contributors.iter()) .filter_map(|author| match author { fabric::FabricAuthorOrArray::Plain(s) => Some(s.clone()), fabric::FabricAuthorOrArray::Object { name } => name.clone(), }) .collect() } /// Extract a URL from Fabric's `contact` object (often has `"homepage"`, `"sources"`, etc.). fn extract_contact_url(contact: &Option) -> Option { let obj = contact.as_ref()?.as_object()?; // Prefer homepage, then sources, then any string value. if let Some(homepage) = obj.get("homepage").and_then(|v| v.as_str()) { return Some(homepage.to_string()); } if let Some(sources) = obj.get("sources").and_then(|v| v.as_str()) { return Some(sources.to_string()); } // Fallback: return the first string field found. obj.values().find_map(|v| v.as_str().map(String::from)) } /// Resolve a Forge `mods.toml` version string. /// /// Gradle builds often write an unresolved placeholder (e.g. /// `${file.jarVersion}`) which the loader substitutes from the JAR manifest at /// runtime; surface the real `Implementation-Version` from the manifest when /// present, falling back to the original value otherwise. fn resolve_toml_version( version: Option, archive: &mut zip::ZipArchive>, ) -> Option { let placeholder = version.clone()?; if !placeholder.starts_with("${") { return Some(placeholder); } Some( manifest::archive_manifest(archive) .and_then(|manifest| manifest.implementation_version) .filter(|resolved| !resolved.trim().is_empty()) .unwrap_or(placeholder), ) } #[cfg(test)] mod tests { use std::io::Write; fn build_jar(entries: &[(&str, &str)]) -> bytes::Bytes { let mut buffer = std::io::Cursor::new(Vec::new()); { let mut archive = zip::ZipWriter::new(&mut buffer); let options = zip::write::FileOptions::<()>::default(); for (name, content) in entries { archive.start_file(*name, options).expect("start zip entry"); archive .write_all(content.as_bytes()) .expect("write zip entry"); } archive.finish().expect("finish zip"); } bytes::Bytes::from(buffer.into_inner()) } #[test] fn forge_placeholder_version_resolves_from_manifest() { let jar = build_jar(&[ ( "META-INF/MANIFEST.MF", "Manifest-Version: 1.0\nImplementation-Title: Example\nImplementation-Version: 1.2.3\n", ), ( "META-INF/mods.toml", "modLoader = \"javafml\"\nloaderVersion = \"[4,)\"\n\n[[mods]]\nmodId = \"example\"\ndisplayName = \"Example\"\nversion = \"${file.jarVersion}\"\n", ), ]); let meta = super::extract_mod_metadata(&jar).expect("mod metadata"); assert_eq!(meta.mod_id, "example"); assert_eq!(meta.version.as_deref(), Some("1.2.3")); } #[test] fn resolved_mods_toml_version_is_kept_as_is() { let jar = build_jar(&[ ( "META-INF/MANIFEST.MF", "Manifest-Version: 1.0\nImplementation-Version: 9.9.9\n", ), ( "META-INF/mods.toml", "[[mods]]\nmodId = \"example\"\nversion = \"1.0.0\"\n", ), ]); let meta = super::extract_mod_metadata(&jar).expect("mod metadata"); assert_eq!(meta.version.as_deref(), Some("1.0.0")); } #[test] fn placeholder_version_without_manifest_attribute_is_kept() { let jar = build_jar(&[( "META-INF/mods.toml", "[[mods]]\nmodId = \"example\"\nversion = \"${file.jarVersion}\"\n", )]); let meta = super::extract_mod_metadata(&jar).expect("mod metadata"); assert_eq!(meta.version.as_deref(), Some("${file.jarVersion}")); } }