1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4
5use serde::Deserialize;
6use tokio::io::{AsyncBufReadExt, BufReader};
7use tokio::sync::mpsc::Sender;
8
9use crate::error::LoaderError;
10use crate::launcher::events::LaunchEvent;
11use crate::launcher::options::LaunchOptions;
12use crate::loader::forge_patcher::{ForgePatcher, PatchConfig};
13use crate::models::loader::{ForgeProfile, ForgeVersionSection, InstallerInfo, LoaderLibrary, LoaderType};
14use crate::models::minecraft::AssetItem;
15use crate::net::downloader::{DownloadItem, Downloader};
16use crate::utils::archive::{get_file_from_archive, ArchiveQueryResult};
17use crate::utils::paths::get_path_libraries;
18
19const META_URL: &str =
22 "https://files.minecraftforge.net/net/minecraftforge/forge/maven-metadata.json";
23const PROMOTIONS_URL: &str =
24 "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json";
25const MAVEN_BASE: &str = "https://maven.minecraftforge.net/net/minecraftforge/forge";
26
27static FALLBACK_META: &[u8] =
28 include_bytes!("../../assets/forge/maven-metadata.json");
29
30#[derive(Deserialize)]
31struct Promotions {
32 promos: HashMap<String, String>,
33}
34
35pub struct ForgeMC;
38
39impl ForgeMC {
40 pub fn new() -> Self {
41 Self
42 }
43
44 pub async fn install(
49 &self,
50 options: &LaunchOptions,
51 mc_version: &str,
52 java_path: &str,
53 mc_jar: &str,
54 mc_json: &str,
55 build: &str,
56 client: &reqwest::Client,
57 event_tx: &Sender<LaunchEvent>,
58 ) -> Result<(String, Option<String>, Vec<AssetItem>, Vec<String>, Vec<String>), LoaderError> {
59 let loader_base = options.loader_dir("forge");
60 tokio::fs::create_dir_all(&loader_base).await?;
61
62 let installer = self
64 .download_installer(options, mc_version, build, client, event_tx)
65 .await?;
66
67 let version_id = read_installer_version_id(&installer.file_path).await?;
69 let version_json_path = loader_base
70 .join("versions")
71 .join(&version_id)
72 .join(format!("{version_id}.json"));
73
74 if !version_json_path.exists() {
76 let used_patcher = try_patcher_install(
77 &installer.file_path,
78 &loader_base,
79 &version_json_path,
80 mc_jar,
81 mc_json,
82 java_path,
83 &options.path,
84 options,
85 LoaderType::Forge,
86 false, event_tx,
88 )
89 .await;
90
91 if !used_patcher {
92 prepare_install_dir(&loader_base, mc_version, mc_jar, mc_json).await?;
93 run_installer(java_path, &installer.file_path, &loader_base, event_tx).await?;
94 }
95
96 if !version_json_path.exists() {
97 return Err(LoaderError::ApiError(format!(
98 "Forge install finished but no version JSON found at {}",
99 version_json_path.display()
100 )));
101 }
102 }
103
104 let version_json = read_version_json(&version_json_path).await?;
106 let libraries = build_library_assets(&loader_base, &version_json);
107 let extra_game_args = extract_game_args(&version_json);
108 let extra_jvm_args = extract_jvm_args(&loader_base, &version_id, &version_json);
109 let main_class = version_json.main_class;
110
111 Ok((version_id, main_class, libraries, extra_game_args, extra_jvm_args))
112 }
113
114 pub async fn download_installer(
116 &self,
117 options: &LaunchOptions,
118 mc_version: &str,
119 build: &str,
120 client: &reqwest::Client,
121 event_tx: &Sender<LaunchEvent>,
122 ) -> Result<InstallerInfo, LoaderError> {
123 let all_versions: HashMap<String, Vec<String>> =
124 match client.get(META_URL).send().await {
125 Ok(r) if r.status().is_success() => r.json().await?,
126 _ => serde_json::from_slice(FALLBACK_META)?,
127 };
128
129 let versions = all_versions.get(mc_version).ok_or_else(|| {
130 LoaderError::VersionNotFound(format!("Forge doesn't support Minecraft {mc_version}"))
131 })?;
132
133 let forge_build = resolve_forge_build(build, mc_version, versions, client).await?;
134
135 if !versions.iter().any(|v| v == &forge_build) {
136 let available = versions.join(", ");
137 return Err(LoaderError::VersionNotFound(format!(
138 "Forge build {forge_build} not found for {mc_version}. Available: {available}"
139 )));
140 }
141
142 let installer_name = format!("forge-{forge_build}-installer.jar");
143 let installer_folder = options
144 .loader_dir("forge")
145 .join("installer");
146 let installer_path = installer_folder.join(&installer_name);
147
148 if !installer_path.exists() {
149 let url = format!("{MAVEN_BASE}/{forge_build}/{installer_name}");
150 let item = DownloadItem {
151 url: url.clone(),
152 path: installer_path.clone(),
153 folder: installer_folder.clone(),
154 name: installer_name.clone(),
155 size: 0,
156 r#type: Some("forge".into()),
157 sha1: None,
158 };
159 let downloader = Downloader::new(options.timeout_secs, 1);
160 downloader
161 .download_multiple(vec![item], event_tx.clone())
162 .await
163 .map_err(|e| LoaderError::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())))?;
164 }
165
166 Ok(InstallerInfo {
167 file_path: installer_path.to_string_lossy().into_owned(),
168 meta_data: forge_build.clone(),
169 ext: "jar".into(),
170 id: format!("forge-{forge_build}"),
171 old_api: false,
172 })
173 }
174}
175
176impl Default for ForgeMC {
177 fn default() -> Self {
178 Self::new()
179 }
180}
181
182async fn read_installer_version_id(installer_path: &str) -> Result<String, LoaderError> {
188 let result = get_file_from_archive(
189 PathBuf::from(installer_path),
190 Some("install_profile.json".into()),
191 None,
192 false,
193 )
194 .await
195 .map_err(|e| LoaderError::Archive(e.to_string()))?;
196
197 let bytes = match result {
198 ArchiveQueryResult::FileData(b) => b,
199 _ => return Err(LoaderError::ProfileNotFound),
200 };
201
202 let raw: serde_json::Value = serde_json::from_slice(&bytes)?;
203
204 if let Some(v) = raw.get("version").and_then(|v| v.as_str()) {
206 return Ok(v.to_owned());
207 }
208 if let Some(v) = raw
210 .get("install")
211 .and_then(|i| i.get("version"))
212 .and_then(|v| v.as_str())
213 {
214 return Ok(v.to_owned());
215 }
216 if let Some(v) = raw
217 .get("versionInfo")
218 .and_then(|i| i.get("id"))
219 .and_then(|v| v.as_str())
220 {
221 return Ok(v.to_owned());
222 }
223
224 Err(LoaderError::ApiError(
225 "Could not determine version ID from install_profile.json".into(),
226 ))
227}
228
229async fn prepare_install_dir(
232 loader_base: &Path,
233 mc_version: &str,
234 mc_jar: &str,
235 mc_json: &str,
236) -> Result<(), LoaderError> {
237 let profiles_path = loader_base.join("launcher_profiles.json");
238 if !profiles_path.exists() {
239 tokio::fs::write(&profiles_path, b"{\"profiles\":{}}\n").await?;
240 }
241
242 let dest_dir = loader_base.join("versions").join(mc_version);
243 tokio::fs::create_dir_all(&dest_dir).await?;
244
245 let dest_jar = dest_dir.join(format!("{mc_version}.jar"));
246 if !dest_jar.exists() {
247 tokio::fs::copy(mc_jar, &dest_jar).await?;
248 }
249 let dest_json = dest_dir.join(format!("{mc_version}.json"));
250 if !dest_json.exists() {
251 tokio::fs::copy(mc_json, &dest_json).await?;
252 }
253
254 Ok(())
255}
256
257async fn run_installer(
260 java_path: &str,
261 installer_path: &str,
262 loader_base: &Path,
263 event_tx: &Sender<LaunchEvent>,
264) -> Result<(), LoaderError> {
265 let _ = event_tx
266 .send(LaunchEvent::Patch(format!(
267 "Running Forge installer: {}",
268 installer_path
269 )))
270 .await;
271
272 let mut child = tokio::process::Command::new(java_path)
273 .arg("-jar")
274 .arg(installer_path)
275 .arg("--installClient")
276 .arg(loader_base.as_os_str())
277 .stdout(Stdio::piped())
278 .stderr(Stdio::piped())
279 .spawn()
280 .map_err(LoaderError::Io)?;
281
282 if let Some(stdout) = child.stdout.take() {
283 let tx = event_tx.clone();
284 let mut lines = BufReader::new(stdout).lines();
285 tokio::spawn(async move {
286 while let Ok(Some(line)) = lines.next_line().await {
287 let _ = tx.send(LaunchEvent::Patch(line)).await;
288 }
289 });
290 }
291 if let Some(stderr) = child.stderr.take() {
292 let tx = event_tx.clone();
293 let mut lines = BufReader::new(stderr).lines();
294 tokio::spawn(async move {
295 while let Ok(Some(line)) = lines.next_line().await {
296 let _ = tx.send(LaunchEvent::Patch(line)).await;
297 }
298 });
299 }
300
301 let status = child.wait().await.map_err(LoaderError::Io)?;
302 if !status.success() {
303 let _ = event_tx
307 .send(LaunchEvent::Patch(format!(
308 "Forge installer exited with code {:?} (checking for version JSON)",
309 status.code()
310 )))
311 .await;
312 }
313 Ok(())
314}
315
316async fn read_version_json(path: &Path) -> Result<ForgeVersionSection, LoaderError> {
317 let bytes = tokio::fs::read(path).await?;
318 let version: ForgeVersionSection = serde_json::from_slice(&bytes)?;
319 Ok(version)
320}
321
322fn extract_game_args(version: &ForgeVersionSection) -> Vec<String> {
325 let mut args: Vec<String> = Vec::new();
326 if let Some(mc_args) = &version.minecraft_arguments {
327 for token in mc_args.split_whitespace() {
328 args.push(token.to_owned());
329 }
330 }
331 if let Some(forge_args) = &version.arguments {
332 for entry in &forge_args.game {
333 if let Some(s) = entry.as_str() {
334 args.push(s.to_owned());
335 }
336 }
337 }
338 args
339}
340
341fn extract_jvm_args(loader_base: &Path, version_id: &str, version: &ForgeVersionSection) -> Vec<String> {
347 let lib_dir = loader_base.join("libraries").to_string_lossy().into_owned();
348 let sep = if cfg!(target_os = "windows") { ";" } else { ":" };
349 let mut args = Vec::new();
350 if let Some(forge_args) = &version.arguments {
351 for entry in &forge_args.jvm {
352 if let Some(s) = entry.as_str() {
353 args.push(
354 s.replace("${library_directory}", &lib_dir)
355 .replace("${classpath_separator}", sep)
356 .replace("${version_name}", version_id),
357 );
358 }
359 }
360 }
361 args
362}
363
364fn build_library_assets(loader_base: &Path, version: &ForgeVersionSection) -> Vec<AssetItem> {
366 let libs = version.libraries.as_deref().unwrap_or(&[]);
367 let mut items: Vec<AssetItem> = Vec::with_capacity(libs.len());
368
369 for lib in libs {
370 if lib.rules.is_some() {
371 continue;
372 }
373
374 let (path, sha1, size, url) = resolve_library_entry(loader_base, lib);
375 items.push(AssetItem::Asset { path, sha1, size, url });
376 }
377
378 items
379}
380
381fn resolve_library_entry(
382 loader_base: &Path,
383 lib: &LoaderLibrary,
384) -> (String, String, u64, String) {
385 let libs_dir = loader_base.join("libraries");
386
387 let artifact = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref());
388
389 let rel_path = artifact
390 .and_then(|a| a.path.clone())
391 .or_else(|| {
392 get_path_libraries(&lib.name, None, None)
393 .ok()
394 .map(|info| format!("{}/{}", info.path, info.name))
395 })
396 .unwrap_or_default();
397
398 let abs_path = libs_dir.join(&rel_path);
399
400 let sha1 = artifact.and_then(|a| a.sha1.clone()).unwrap_or_default();
401 let size = artifact.and_then(|a| a.size).unwrap_or(0);
402 let url = artifact.map(|a| a.url.clone()).unwrap_or_default();
403
404 (abs_path.to_string_lossy().into_owned(), sha1, size, url)
405}
406
407async fn resolve_forge_build(
410 build: &str,
411 mc_version: &str,
412 versions: &[String],
413 client: &reqwest::Client,
414) -> Result<String, LoaderError> {
415 match build {
416 "latest" => {
417 if let Ok(promos) = client.get(PROMOTIONS_URL).send().await {
418 if let Ok(p) = promos.json::<Promotions>().await {
419 let key = format!("{mc_version}-latest");
420 if let Some(ver) = p.promos.get(&key) {
421 return Ok(format!("{mc_version}-{ver}"));
422 }
423 }
424 }
425 versions
426 .last()
427 .cloned()
428 .ok_or_else(|| LoaderError::VersionNotFound(format!("No Forge builds for {mc_version}")))
429 }
430 "recommended" => {
431 if let Ok(promos) = client.get(PROMOTIONS_URL).send().await {
432 if let Ok(p) = promos.json::<Promotions>().await {
433 let rec_key = format!("{mc_version}-recommended");
434 let lat_key = format!("{mc_version}-latest");
435 let ver = p.promos.get(&rec_key).or_else(|| p.promos.get(&lat_key));
436 if let Some(v) = ver {
437 return Ok(format!("{mc_version}-{v}"));
438 }
439 }
440 }
441 versions
442 .last()
443 .cloned()
444 .ok_or_else(|| LoaderError::VersionNotFound(format!("No Forge builds for {mc_version}")))
445 }
446 specific => Ok(specific.to_owned()),
447 }
448}
449
450pub(crate) async fn try_patcher_install(
458 installer_path: &str,
459 loader_base: &Path,
460 version_json_path: &Path,
461 mc_jar: &str,
462 mc_json: &str,
463 java_path: &str,
464 game_path: &Path,
465 options: &LaunchOptions,
466 loader_type: LoaderType,
467 neo_forge_old: bool,
468 event_tx: &Sender<LaunchEvent>,
469) -> bool {
470 match try_patcher_install_inner(
471 installer_path,
472 loader_base,
473 version_json_path,
474 mc_jar,
475 mc_json,
476 java_path,
477 game_path,
478 options,
479 loader_type,
480 neo_forge_old,
481 event_tx,
482 )
483 .await
484 {
485 Ok(result) => result,
486 Err(e) => {
487 let _ = event_tx
488 .send(LaunchEvent::Patch(format!(
489 "[patcher] Manual patch failed ({e}); falling back to --installClient"
490 )))
491 .await;
492 false
493 }
494 }
495}
496
497async fn try_patcher_install_inner(
498 installer_path: &str,
499 loader_base: &Path,
500 version_json_path: &Path,
501 mc_jar: &str,
502 mc_json: &str,
503 java_path: &str,
504 game_path: &Path,
505 options: &LaunchOptions,
506 loader_type: LoaderType,
507 neo_forge_old: bool,
508 event_tx: &Sender<LaunchEvent>,
509) -> Result<bool, LoaderError> {
510 let profile = read_install_profile(installer_path).await?;
512
513 let has_processors = profile.processors.as_ref().map_or(false, |p| !p.is_empty());
516 if !has_processors {
517 return Ok(false);
518 }
519
520 if !version_json_path.exists() {
522 extract_version_json(installer_path, version_json_path).await?;
523 }
524
525 let libs_dir = loader_base.join("libraries");
527 extract_maven_entries(installer_path, &libs_dir).await?;
528
529 download_profile_libraries(&profile, &libs_dir, options, event_tx).await?;
531
532 extract_data_files(installer_path, &profile, &libs_dir, &loader_type, neo_forge_old).await?;
534
535 let patcher = ForgePatcher::new(loader_base.to_path_buf(), loader_type);
537 if patcher.check(&profile) {
538 let _ = event_tx
539 .send(LaunchEvent::Patch("[patcher] Already patched, skipping".into()))
540 .await;
541 return Ok(true);
542 }
543
544 let config = PatchConfig {
546 java_path,
547 minecraft_jar: mc_jar,
548 minecraft_json: mc_json,
549 game_path,
550 };
551 patcher.patch(&profile, &config, neo_forge_old, event_tx).await?;
552 Ok(true)
553}
554
555async fn read_install_profile(installer_path: &str) -> Result<ForgeProfile, LoaderError> {
562 let result = get_file_from_archive(
563 PathBuf::from(installer_path),
564 Some("install_profile.json".into()),
565 None,
566 false,
567 )
568 .await
569 .map_err(|e| LoaderError::Archive(e.to_string()))?;
570
571 let bytes = match result {
572 ArchiveQueryResult::FileData(b) => b,
573 _ => return Err(LoaderError::ProfileNotFound),
574 };
575
576 let mut raw: serde_json::Value = serde_json::from_slice(&bytes)?;
577 if let Some(obj) = raw.as_object_mut() {
578 if obj.get("version").and_then(|v| v.as_str()).is_some() {
579 obj.remove("version");
580 }
581 }
582
583 let profile: ForgeProfile = serde_json::from_value(raw)?;
584 Ok(profile)
585}
586
587async fn extract_version_json(installer_path: &str, dest_path: &Path) -> Result<(), LoaderError> {
589 let result = get_file_from_archive(
590 PathBuf::from(installer_path),
591 Some("version.json".into()),
592 None,
593 false,
594 )
595 .await
596 .map_err(|e| LoaderError::Archive(e.to_string()))?;
597
598 let bytes = match result {
599 ArchiveQueryResult::FileData(b) => b,
600 _ => {
601 return Err(LoaderError::ApiError(
602 "version.json not found in installer JAR".into(),
603 ))
604 }
605 };
606
607 if let Some(parent) = dest_path.parent() {
608 tokio::fs::create_dir_all(parent).await?;
609 }
610 tokio::fs::write(dest_path, &bytes).await?;
611 Ok(())
612}
613
614async fn extract_maven_entries(installer_path: &str, libs_dir: &Path) -> Result<(), LoaderError> {
619 let installer = PathBuf::from(installer_path);
620
621 let names =
622 match get_file_from_archive(installer.clone(), None, Some("maven/".into()), false)
623 .await
624 .map_err(|e| LoaderError::Archive(e.to_string()))?
625 {
626 ArchiveQueryResult::Names(n) => n,
627 _ => return Ok(()),
628 };
629
630 for name in names {
631 let rel = match name.strip_prefix("maven/") {
632 Some(r) if !r.is_empty() => r.to_owned(),
633 _ => continue,
634 };
635
636 let dest = libs_dir.join(&rel);
637 if dest.exists() {
638 continue;
639 }
640
641 let bytes =
642 match get_file_from_archive(installer.clone(), Some(name), None, false)
643 .await
644 .map_err(|e| LoaderError::Archive(e.to_string()))?
645 {
646 ArchiveQueryResult::FileData(b) => b,
647 _ => continue,
648 };
649
650 if let Some(parent) = dest.parent() {
651 tokio::fs::create_dir_all(parent).await?;
652 }
653 tokio::fs::write(&dest, &bytes).await?;
654 }
655
656 Ok(())
657}
658
659async fn download_profile_libraries(
664 profile: &ForgeProfile,
665 libs_dir: &Path,
666 options: &LaunchOptions,
667 event_tx: &Sender<LaunchEvent>,
668) -> Result<(), LoaderError> {
669 let libs = match profile.libraries.as_deref() {
670 Some(l) if !l.is_empty() => l,
671 _ => return Ok(()),
672 };
673
674 let mut items: Vec<DownloadItem> = Vec::new();
675
676 for lib in libs {
677 let artifact = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref());
678 let url = match artifact {
679 Some(a) if !a.url.is_empty() => a.url.clone(),
680 _ => continue,
681 };
682
683 let rel_path = artifact
684 .and_then(|a| a.path.clone())
685 .or_else(|| {
686 get_path_libraries(&lib.name, None, None)
687 .ok()
688 .map(|info| format!("{}/{}", info.path, info.name))
689 })
690 .unwrap_or_default();
691
692 if rel_path.is_empty() {
693 continue;
694 }
695
696 let dest = libs_dir.join(&rel_path);
697 if dest.exists() {
698 continue;
699 }
700
701 let folder = dest.parent().unwrap_or(libs_dir).to_path_buf();
702 let name = dest
703 .file_name()
704 .map(|n| n.to_string_lossy().into_owned())
705 .unwrap_or_default();
706
707 items.push(DownloadItem {
708 url,
709 path: dest,
710 folder,
711 name,
712 size: artifact.and_then(|a| a.size).unwrap_or(0),
713 r#type: Some("forge-lib".into()),
714 sha1: artifact.and_then(|a| a.sha1.clone()),
715 });
716 }
717
718 if !items.is_empty() {
719 let downloader = Downloader::new(options.timeout_secs, options.download_concurrency);
720 downloader
721 .download_multiple(items, event_tx.clone())
722 .await
723 .map_err(|e| {
724 LoaderError::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
725 })?;
726 }
727
728 Ok(())
729}
730
731async fn extract_data_files(
737 installer_path: &str,
738 profile: &ForgeProfile,
739 libs_dir: &Path,
740 loader_type: &LoaderType,
741 neo_forge_old: bool,
742) -> Result<(), LoaderError> {
743 let data = match &profile.data {
744 Some(d) => d,
745 None => return Ok(()),
746 };
747
748 let universal_name: Option<String> = profile.libraries.as_deref().and_then(|libs| {
749 libs.iter()
750 .find(|lib| match loader_type {
751 LoaderType::Forge => lib.name.starts_with("net.minecraftforge:forge"),
752 LoaderType::NeoForge => {
753 if neo_forge_old {
754 lib.name.starts_with("net.neoforged:forge")
755 } else {
756 lib.name.starts_with("net.neoforged:neoforge")
757 }
758 }
759 _ => false,
760 })
761 .map(|lib| lib.name.clone())
762 });
763
764 for (key, entry) in data {
765 let client_val = entry.client.trim();
766
767 if !client_val.starts_with('/') {
769 continue;
770 }
771 let in_jar_path = &client_val[1..]; let dest: PathBuf = if key == "BINPATCH" {
774 let coord = profile
775 .path
776 .as_deref()
777 .or_else(|| profile.install.as_ref().and_then(|i| i.path.as_deref()))
778 .or(universal_name.as_deref())
779 .unwrap_or("");
780
781 if coord.is_empty() {
782 continue;
783 }
784
785 let info = match get_path_libraries(coord, None, None) {
786 Ok(i) => i,
787 Err(_) => continue,
788 };
789 let lzma_name = info.name.replace(".jar", "-clientdata.lzma");
790 libs_dir.join(&info.path).join(lzma_name)
791 } else {
792 libs_dir.join(in_jar_path)
793 };
794
795 if dest.exists() {
796 continue;
797 }
798
799 let result = get_file_from_archive(
800 PathBuf::from(installer_path),
801 Some(in_jar_path.to_owned()),
802 None,
803 false,
804 )
805 .await
806 .map_err(|e| LoaderError::Archive(e.to_string()))?;
807
808 let bytes = match result {
809 ArchiveQueryResult::FileData(b) => b,
810 _ => continue,
811 };
812
813 if let Some(parent) = dest.parent() {
814 tokio::fs::create_dir_all(parent).await?;
815 }
816 tokio::fs::write(&dest, &bytes).await?;
817 }
818
819 Ok(())
820}
821
822#[cfg(test)]
825mod tests {
826 use super::*;
827
828 #[test]
829 fn fallback_metadata_is_valid_json() {
830 let parsed: serde_json::Value = serde_json::from_slice(FALLBACK_META).unwrap();
831 assert!(parsed.is_object(), "forge metadata should be a JSON object");
832 }
833
834 #[test]
835 fn fallback_metadata_contains_versions() {
836 let parsed: HashMap<String, Vec<String>> =
837 serde_json::from_slice(FALLBACK_META).unwrap();
838 assert!(!parsed.is_empty());
839 }
840
841 #[test]
842 fn forge_mc_constructs() {
843 let _f = ForgeMC::new();
844 }
845
846 #[test]
847 fn build_library_assets_uses_explicit_artifact_path() {
848 let version = ForgeVersionSection {
849 id: Some("1.20.1-forge-47.4.20".into()),
850 libraries: Some(vec![LoaderLibrary {
851 name: "cpw.mods:bootstraplauncher:1.1.2".into(),
852 url: None,
853 downloads: Some(crate::models::loader::LoaderLibraryDownloads {
854 artifact: Some(crate::models::loader::LoaderArtifact {
855 sha1: Some("abc".into()),
856 size: Some(123),
857 path: Some("cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar".into()),
858 url: "https://example.com/x.jar".into(),
859 }),
860 }),
861 rules: None,
862 }]),
863 main_class: None,
864 minecraft_arguments: None,
865 arguments: None,
866 extra: HashMap::new(),
867 };
868 let base = PathBuf::from("/mc/loader/forge");
869 let items = build_library_assets(&base, &version);
870 assert_eq!(items.len(), 1);
871 match &items[0] {
872 AssetItem::Asset { path, .. } => {
873 assert!(path.ends_with("bootstraplauncher-1.1.2.jar"), "got {path}");
874 assert!(path.contains("loader/forge/libraries/cpw/mods"));
875 }
876 _ => panic!("expected Asset"),
877 }
878 }
879
880 #[test]
881 fn build_library_assets_skips_rule_restricted() {
882 let version = ForgeVersionSection {
883 id: None,
884 libraries: Some(vec![LoaderLibrary {
885 name: "x:y:1".into(),
886 url: None,
887 downloads: None,
888 rules: Some(vec![serde_json::json!({"action":"disallow"})]),
889 }]),
890 main_class: None,
891 minecraft_arguments: None,
892 arguments: None,
893 extra: HashMap::new(),
894 };
895 let items = build_library_assets(Path::new("/mc/loader/forge"), &version);
896 assert!(items.is_empty());
897 }
898}