1#![deny(missing_docs)]
47
48pub mod cache;
50pub mod launch;
52pub mod layout;
54pub mod modlist;
56pub mod portability;
58pub mod target;
60
61use crate::client::ThunderstoreClient;
62use crate::ecosystem::Ecosystem;
63use crate::error::{Error, Result};
64use crate::extract::parse_package_filename;
65use crate::install::{
66 apply_install, clear_state_files, plan_install, remove_delisted, remove_mod_folders,
67 remove_state_file, remove_state_payload, state_tracker_from_plan, write_state_file,
68};
69use crate::models::{DependencyGraph, PackageIndex, Version};
70use modlist::{ProfileMod, SemVer};
71use std::collections::{BTreeSet, HashMap};
72use std::path::{Path, PathBuf};
73
74pub fn mod_entry_from_version(
79 full_name: &str,
80 owner: &str,
81 version: &Version,
82 installed_at_time: u64,
83) -> ProfileMod {
84 let version_number = version
85 .version_number
86 .as_deref()
87 .map(SemVer::parse)
88 .unwrap_or(SemVer {
89 major: 0,
90 minor: 0,
91 patch: 0,
92 });
93
94 ProfileMod {
95 manifest_version: 1,
96 name: full_name.to_string(),
97 author_name: owner.to_string(),
98 website_url: version.website_url.clone().unwrap_or_default(),
99 display_name: version
100 .name
101 .clone()
102 .unwrap_or_else(|| full_name.to_string()),
103 description: version.description.clone().unwrap_or_default(),
104 game_version: "0".to_string(),
105 network_mode: "both".to_string(),
106 package_type: "other".to_string(),
107 install_mode: "managed".to_string(),
108 installed_at_time,
109 loaders: Vec::new(),
110 dependencies: version.dependencies.clone(),
111 incompatibilities: Vec::new(),
112 optional_dependencies: Vec::new(),
113 version_number,
114 enabled: true,
115 icon: version.icon.clone().unwrap_or_default(),
116 online_source: true,
117 trusted_package: false,
118 }
119}
120
121#[derive(Debug, Default)]
127#[non_exhaustive]
128pub struct BatchOutcome {
129 pub succeeded: Vec<String>,
141 pub unchanged: Vec<String>,
146 pub failed: Vec<(String, Error)>,
148}
149
150impl BatchOutcome {
151 pub fn is_ok(&self) -> bool {
153 self.failed.is_empty()
154 }
155}
156
157#[derive(Debug, Clone, Default)]
160#[non_exhaustive]
161pub struct InstallBatch {
162 pub desired: Vec<String>,
165 pub protect_disabled: Vec<String>,
171 pub enable_requested: Vec<String>,
178 pub force: bool,
182}
183
184impl InstallBatch {
185 pub fn new(desired: Vec<String>, protect_disabled: Vec<String>) -> Self {
189 Self {
190 desired,
191 protect_disabled,
192 ..Self::default()
193 }
194 }
195}
196
197pub fn plan_install_batch(
205 target_dir: &Path,
206 desired: &[String],
207 explicit: &[String],
208) -> Result<InstallBatch> {
209 let recorded = modlist::read(target_dir)?;
210
211 let (enable_requested, protect_disabled): (Vec<String>, Vec<String>) = recorded
212 .iter()
213 .filter(|entry| !entry.enabled)
214 .map(|entry| entry.name.clone())
215 .partition(|name| {
216 explicit
217 .iter()
218 .any(|requested| crate::util::full_name_prefix(requested) == name.as_str())
219 });
220
221 Ok(InstallBatch {
222 desired: desired.to_vec(),
223 protect_disabled,
224 enable_requested,
225 force: false,
226 })
227}
228
229pub fn reapply_batch_disabled(
238 target_dir: &Path,
239 eco: &Ecosystem,
240 game: &str,
241 batch: &InstallBatch,
242) -> Result<()> {
243 let recorded = modlist::read(target_dir)?;
244
245 for full_name in &batch.protect_disabled {
246 if !recorded.iter().any(|entry| &entry.name == full_name) {
247 continue;
248 }
249
250 set_enabled_in(target_dir, eco, game, full_name, false)?;
251 }
252
253 Ok(())
254}
255
256#[allow(clippy::too_many_arguments)]
270pub fn install_extracted_in(
271 target_dir: &Path,
272 eco: &Ecosystem,
273 game: &str,
274 full_name: &str,
275 owner: &str,
276 version: &Version,
277 extracted_dir: &Path,
278 archive: Option<&Path>,
279 installed_at_time: u64,
280) -> Result<()> {
281 let game_profile = eco
282 .game(game)
283 .and_then(|g| g.profile())
284 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
285
286 let plan = plan_install(eco, game_profile, full_name, extracted_dir, archive)?;
287
288 clear_state_files(target_dir, full_name)?;
294 remove_mod_folders(game_profile, target_dir, full_name)?;
295 apply_install(&plan, target_dir)?;
296
297 match state_tracker_from_plan(&plan, full_name) {
298 Some(tracker) => write_state_file(target_dir, &tracker)?,
299 None => remove_state_file(target_dir, full_name)?,
300 }
301
302 let mut mods = modlist::read(target_dir)?;
303
304 modlist::upsert(
305 &mut mods,
306 mod_entry_from_version(full_name, owner, version, installed_at_time),
307 );
308
309 modlist::write(target_dir, &mods)
310}
311
312#[allow(clippy::too_many_arguments)]
315pub fn install_extracted(
316 base: &Path,
317 eco: &Ecosystem,
318 game: &str,
319 profile_name: &str,
320 full_name: &str,
321 owner: &str,
322 version: &Version,
323 extracted_dir: &Path,
324 archive: Option<&Path>,
325 installed_at_time: u64,
326) -> Result<()> {
327 install_extracted_in(
328 &layout::profile_dir(base, game, profile_name),
329 eco,
330 game,
331 full_name,
332 owner,
333 version,
334 extracted_dir,
335 archive,
336 installed_at_time,
337 )
338}
339
340#[derive(Debug)]
355pub enum NotRemovable {
356 RecordAbsent {
359 name: String,
361 path: PathBuf,
363 },
364 RecordUnreadable {
367 name: String,
369 path: PathBuf,
371 source: Error,
373 },
374}
375
376impl std::fmt::Display for NotRemovable {
377 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378 match self {
379 Self::RecordAbsent { name, path } => write!(
380 f,
381 "{name} is a mod loader with no install record at {}, so its files \
382 cannot be removed exactly. Reinstall it, then uninstall it again, or \
383 remove its files by hand.",
384 path.display()
385 ),
386 Self::RecordUnreadable { name, path, source } => write!(
387 f,
388 "{name} has an install record at {} that cannot be read, so its files \
389 cannot be removed exactly: {source}",
390 path.display()
391 ),
392 }
393 }
394}
395
396impl From<NotRemovable> for Error {
397 fn from(value: NotRemovable) -> Self {
398 Error::Profile(value.to_string())
399 }
400}
401
402pub fn ensure_removable(
435 target_dir: &Path,
436 eco: &Ecosystem,
437 full_name: &str,
438) -> std::result::Result<(), NotRemovable> {
439 match crate::install::read_state_file(target_dir, full_name) {
440 Ok(Some(_)) => return Ok(()),
441 Ok(None) => {}
442 Err(source) => {
443 return Err(NotRemovable::RecordUnreadable {
444 name: full_name.to_string(),
445 path: crate::install::state_file_path(target_dir, full_name),
446 source,
447 });
448 }
449 }
450
451 if eco.modloader_package(full_name).is_none() {
452 return Ok(());
453 }
454
455 Err(NotRemovable::RecordAbsent {
456 name: full_name.to_string(),
457 path: crate::install::state_file_path(target_dir, full_name),
458 })
459}
460
461pub fn uninstall_mod_in(
480 target_dir: &Path,
481 eco: &Ecosystem,
482 game: &str,
483 full_name: &str,
484) -> Result<()> {
485 let game_profile = eco
486 .game(game)
487 .and_then(|g| g.profile())
488 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
489
490 ensure_removable(target_dir, eco, full_name)?;
495
496 let mut mods = modlist::read(target_dir)?;
497
498 mods.retain(|m| m.name != full_name);
499
500 let keep: BTreeSet<String> = mods.iter().map(|m| m.name.clone()).collect();
501
502 remove_delisted(game_profile, target_dir, &keep)?;
503 remove_state_payload(target_dir, full_name)?;
504
505 modlist::write(target_dir, &mods)
506}
507
508pub fn uninstall_mod(
510 base: &Path,
511 eco: &Ecosystem,
512 game: &str,
513 profile_name: &str,
514 full_name: &str,
515) -> Result<()> {
516 uninstall_mod_in(
517 &layout::profile_dir(base, game, profile_name),
518 eco,
519 game,
520 full_name,
521 )
522}
523
524pub fn set_enabled_in(
532 target_dir: &Path,
533 eco: &Ecosystem,
534 game: &str,
535 full_name: &str,
536 enabled: bool,
537) -> Result<()> {
538 let game_profile = eco
539 .game(game)
540 .and_then(|g| g.profile())
541 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
542
543 let mut mods = modlist::read(target_dir)?;
544
545 let entry = mods
546 .iter_mut()
547 .find(|m| m.name == full_name)
548 .ok_or_else(|| Error::Profile(format!("{full_name} is not installed")))?;
549
550 if eco.modloader_package(full_name).is_some() {
551 return Ok(());
552 }
553
554 if enabled {
555 crate::install::enable_mod(game_profile, target_dir, full_name)?;
556 } else {
557 crate::install::disable_mod(game_profile, target_dir, full_name)?;
558 }
559
560 entry.enabled = enabled;
561
562 modlist::write(target_dir, &mods)
563}
564
565pub fn set_enabled(
568 base: &Path,
569 eco: &Ecosystem,
570 game: &str,
571 profile_name: &str,
572 full_name: &str,
573 enabled: bool,
574) -> Result<()> {
575 set_enabled_in(
576 &layout::profile_dir(base, game, profile_name),
577 eco,
578 game,
579 full_name,
580 enabled,
581 )
582}
583
584pub fn enable_mod_in(
586 target_dir: &Path,
587 eco: &Ecosystem,
588 game: &str,
589 full_name: &str,
590) -> Result<()> {
591 set_enabled_in(target_dir, eco, game, full_name, true)
592}
593
594pub fn disable_mod_in(
596 target_dir: &Path,
597 eco: &Ecosystem,
598 game: &str,
599 full_name: &str,
600) -> Result<()> {
601 set_enabled_in(target_dir, eco, game, full_name, false)
602}
603
604pub fn enable_mod(
606 base: &Path,
607 eco: &Ecosystem,
608 game: &str,
609 profile_name: &str,
610 full_name: &str,
611) -> Result<()> {
612 set_enabled(base, eco, game, profile_name, full_name, true)
613}
614
615pub fn disable_mod(
617 base: &Path,
618 eco: &Ecosystem,
619 game: &str,
620 profile_name: &str,
621 full_name: &str,
622) -> Result<()> {
623 set_enabled(base, eco, game, profile_name, full_name, false)
624}
625
626#[allow(clippy::too_many_arguments)]
643async fn install_one(
644 target_dir: &Path,
645 cache_base: &Path,
646 eco: &Ecosystem,
647 index: &PackageIndex,
648 client: &ThunderstoreClient,
649 game: &str,
650 full_name: &str,
651 pins: &[String],
652 force: bool,
653 installed_at_time: u64,
654) -> Result<(Vec<String>, Vec<String>)> {
655 let resolution = DependencyGraph::new(vec![full_name.to_string()])
656 .pins(pins.iter().cloned())
657 .resolve_report(index);
658
659 if let [unresolved] = resolution.unresolved.as_slice()
662 && unresolved == full_name
663 && let Some((name, version)) = crate::util::split_version(full_name)
664 {
665 return Err(Error::Profile(format!(
666 "cannot install {full_name}: {name} has no version {version}"
667 )));
668 }
669
670 if !resolution.unresolved.is_empty() {
671 return Err(Error::Profile(format!(
672 "cannot install {full_name}: could not resolve {}",
673 resolution.unresolved.join(", ")
674 )));
675 }
676
677 let recorded = modlist::read(target_dir)?;
678 let forced = force.then(|| crate::util::full_name_prefix(full_name));
679 let downloads = client.cache_dir().join("downloads");
680 let mut urls = HashMap::new();
681 let mut unchanged = Vec::new();
682
683 for (filename, url) in resolution.urls {
684 let (pkg_full_name, version_str) = parse_package_filename(&filename)
685 .ok_or_else(|| Error::Profile(format!("unparseable package filename {filename:?}")))?;
686
687 let version = SemVer::parse(&version_str);
688 let current = forced != Some(pkg_full_name.as_str())
689 && recorded
690 .iter()
691 .find(|entry| entry.name == pkg_full_name && entry.version_number == version)
692 .is_some_and(|entry| {
693 recorded_files_present(
694 target_dir,
695 cache_base,
696 eco,
697 game,
698 entry,
699 &version_str,
700 &downloads.join(&filename),
701 )
702 });
703
704 if current {
705 unchanged.push(pkg_full_name);
706 } else {
707 urls.insert(filename, url);
708 }
709 }
710
711 client.download_files(urls.clone()).await?;
712
713 let mut installed = Vec::new();
714
715 for filename in urls.keys() {
716 let (pkg_full_name, version_str) = parse_package_filename(filename)
717 .ok_or_else(|| Error::Profile(format!("unparseable package filename {filename:?}")))?;
718
719 let archive = downloads.join(filename);
720
721 let extracted =
722 cache::ensure_extracted(cache_base, game, &pkg_full_name, &version_str, &archive)?;
723
724 let package = index
725 .get_package_by_full_name(&pkg_full_name)
726 .ok_or_else(|| Error::Profile(format!("{pkg_full_name} missing from index")))?;
727
728 let owner = package.owner.clone().unwrap_or_default();
729 let version = package
730 .versions
731 .iter()
732 .find(|v| v.version_number.as_deref() == Some(version_str.as_str()))
733 .or_else(|| package.latest_version())
734 .ok_or_else(|| Error::Profile(format!("{pkg_full_name} has no usable version")))?;
735
736 install_extracted_in(
737 target_dir,
738 eco,
739 game,
740 &pkg_full_name,
741 &owner,
742 version,
743 &extracted,
744 Some(&archive),
745 installed_at_time,
746 )?;
747
748 installed.push(pkg_full_name);
749 }
750
751 installed.sort();
752 unchanged.sort();
753
754 Ok((installed, unchanged))
755}
756
757fn recorded_files_present(
762 target_dir: &Path,
763 cache_base: &Path,
764 eco: &Ecosystem,
765 game: &str,
766 entry: &ProfileMod,
767 version: &str,
768 archive: &Path,
769) -> bool {
770 let extracted = cache::package_cache_dir(cache_base, game, &entry.name, version);
771
772 if !extracted.is_dir() {
773 return false;
774 }
775
776 let Some(game_profile) = eco.game(game).and_then(|g| g.profile()) else {
777 return false;
778 };
779
780 let archive = archive.exists().then_some(archive);
781
782 plan_install(eco, game_profile, &entry.name, &extracted, archive).is_ok_and(|plan| {
783 crate::install::plan_is_on_disk(&plan, target_dir, &entry.name, entry.enabled)
784 })
785}
786
787#[allow(clippy::too_many_arguments)]
799pub async fn install_batch(
800 target_dir: &Path,
801 cache_base: &Path,
802 eco: &Ecosystem,
803 index: &PackageIndex,
804 client: &ThunderstoreClient,
805 game: &str,
806 batch: &InstallBatch,
807 installed_at_time: u64,
808) -> Result<BatchOutcome> {
809 let mut outcome = BatchOutcome::default();
810
811 for full_name in &batch.desired {
812 match install_one(
813 target_dir,
814 cache_base,
815 eco,
816 index,
817 client,
818 game,
819 full_name,
820 &batch.desired,
821 batch.force,
822 installed_at_time,
823 )
824 .await
825 {
826 Ok((installed, unchanged)) => {
827 outcome.succeeded.extend(installed);
828 outcome.unchanged.extend(unchanged);
829 }
830 Err(error) => outcome.failed.push((full_name.clone(), error)),
831 }
832 }
833
834 reapply_batch_disabled(target_dir, eco, game, batch)?;
835
836 outcome.succeeded.sort();
837 outcome.succeeded.dedup();
838 outcome.unchanged.sort();
839 outcome.unchanged.dedup();
840 outcome
841 .unchanged
842 .retain(|name| outcome.succeeded.binary_search(name).is_err());
843
844 let recorded = modlist::read(target_dir)?;
847 let disabled: BTreeSet<&str> = recorded
848 .iter()
849 .filter(|entry| !entry.enabled)
850 .map(|entry| entry.name.as_str())
851 .collect();
852
853 for full_name in &batch.enable_requested {
854 if disabled.contains(full_name.as_str())
855 && outcome.unchanged.binary_search(full_name).is_ok()
856 && let Err(error) = set_enabled_in(target_dir, eco, game, full_name, true)
857 {
858 outcome.failed.push((full_name.clone(), error));
859 }
860 }
861
862 let failed: BTreeSet<&String> = outcome.failed.iter().map(|(name, _)| name).collect();
864
865 outcome.unchanged.retain(|name| !failed.contains(name));
866
867 Ok(outcome)
868}
869
870#[allow(clippy::too_many_arguments)]
929pub async fn install_mod_in(
930 target_dir: &Path,
931 cache_base: &Path,
932 eco: &Ecosystem,
933 index: &PackageIndex,
934 client: &ThunderstoreClient,
935 game: &str,
936 full_name: &str,
937 installed_at_time: u64,
938) -> Result<Vec<String>> {
939 install_named_in(
940 target_dir,
941 cache_base,
942 eco,
943 index,
944 client,
945 game,
946 full_name,
947 false,
948 installed_at_time,
949 )
950 .await
951}
952
953#[allow(clippy::too_many_arguments)]
958pub(crate) async fn install_named_in(
959 target_dir: &Path,
960 cache_base: &Path,
961 eco: &Ecosystem,
962 index: &PackageIndex,
963 client: &ThunderstoreClient,
964 game: &str,
965 full_name: &str,
966 force: bool,
967 installed_at_time: u64,
968) -> Result<Vec<String>> {
969 let named = [full_name.to_string()];
970 let mut batch = plan_install_batch(target_dir, &named, &named)?;
971
972 batch.force = force;
973
974 let outcome = install_batch(
975 target_dir,
976 cache_base,
977 eco,
978 index,
979 client,
980 game,
981 &batch,
982 installed_at_time,
983 )
984 .await?;
985
986 match outcome.failed.into_iter().next() {
987 Some((_, error)) => Err(error),
988 None => Ok(outcome.succeeded),
989 }
990}
991
992#[allow(clippy::too_many_arguments)]
997pub async fn install_mod(
998 base: &Path,
999 eco: &Ecosystem,
1000 index: &PackageIndex,
1001 client: &ThunderstoreClient,
1002 game: &str,
1003 profile_name: &str,
1004 full_name: &str,
1005 installed_at_time: u64,
1006) -> Result<Vec<String>> {
1007 install_mod_in(
1008 &layout::profile_dir(base, game, profile_name),
1009 base,
1010 eco,
1011 index,
1012 client,
1013 game,
1014 full_name,
1015 installed_at_time,
1016 )
1017 .await
1018}
1019
1020#[derive(Debug, Clone, Default)]
1028#[non_exhaustive]
1029pub struct UninstallBatch {
1030 pub mods: Vec<String>,
1032 pub loaders: Vec<String>,
1034 pub untracked: Vec<PathBuf>,
1037}
1038
1039pub fn check_removable_batch(
1046 target_dir: &Path,
1047 eco: &Ecosystem,
1048 names: &[String],
1049) -> Vec<(String, NotRemovable)> {
1050 names
1051 .iter()
1052 .filter_map(|name| match ensure_removable(target_dir, eco, name) {
1053 Ok(()) => None,
1054 Err(refusal) => Some((name.clone(), refusal)),
1055 })
1056 .collect()
1057}
1058
1059pub fn plan_uninstall_batch(
1066 target_dir: &Path,
1067 eco: &Ecosystem,
1068 game: &str,
1069 names: Option<&[String]>,
1070) -> Result<UninstallBatch> {
1071 let game_profile = eco
1072 .game(game)
1073 .and_then(|g| g.profile())
1074 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
1075
1076 let recorded = modlist::read(target_dir)?;
1077
1078 let subjects: Vec<String> = match names {
1079 Some(names) => {
1080 for name in names {
1081 if modlist::find(&recorded, name).is_none() {
1082 return Err(Error::Profile(format!("{name} is not installed")));
1083 }
1084 }
1085
1086 names.to_vec()
1087 }
1088 None => recorded.iter().map(|entry| entry.name.clone()).collect(),
1089 };
1090
1091 if let Some((name, refusal)) = check_removable_batch(target_dir, eco, &subjects)
1092 .into_iter()
1093 .next()
1094 {
1095 return Err(Error::Profile(format!(
1096 "{name} cannot be removed: {refusal}"
1097 )));
1098 }
1099
1100 let mut mods: Vec<String> = subjects
1101 .iter()
1102 .filter(|name| eco.modloader_package(name).is_none())
1103 .cloned()
1104 .collect();
1105 let mut loaders: Vec<String> = subjects
1106 .iter()
1107 .filter(|name| eco.modloader_package(name).is_some())
1108 .cloned()
1109 .collect();
1110
1111 mods.sort();
1112 loaders.sort();
1113
1114 let keep: BTreeSet<String> = recorded.iter().map(|entry| entry.name.clone()).collect();
1115 let untracked = crate::install::plan_delisted(game_profile, target_dir, &keep)?;
1116
1117 Ok(UninstallBatch {
1118 mods,
1119 loaders,
1120 untracked,
1121 })
1122}
1123
1124pub fn uninstall_batch(
1130 target_dir: &Path,
1131 eco: &Ecosystem,
1132 game: &str,
1133 batch: &UninstallBatch,
1134) -> Result<BatchOutcome> {
1135 let mut outcome = BatchOutcome::default();
1136
1137 for full_name in batch.mods.iter().chain(batch.loaders.iter()) {
1138 match uninstall_mod_in(target_dir, eco, game, full_name) {
1139 Ok(()) => outcome.succeeded.push(full_name.clone()),
1140 Err(error) => outcome.failed.push((full_name.clone(), error)),
1141 }
1142 }
1143
1144 Ok(outcome)
1145}
1146
1147#[derive(Debug, Clone, Default)]
1154#[non_exhaustive]
1155pub struct ToggleBatch {
1156 pub changing: Vec<String>,
1158 pub already: Vec<String>,
1160 pub loaders: Vec<String>,
1162 pub enabled: bool,
1167}
1168
1169pub fn plan_set_enabled_batch(
1177 target_dir: &Path,
1178 eco: &Ecosystem,
1179 names: Option<&[String]>,
1180 enabled: bool,
1181) -> Result<ToggleBatch> {
1182 let recorded = modlist::read(target_dir)?;
1183
1184 if let Some(names) = names {
1185 for name in names {
1186 if modlist::find(&recorded, name).is_none() {
1187 return Err(Error::Profile(format!("{name} is not installed")));
1188 }
1189 }
1190 }
1191
1192 let mut batch = ToggleBatch {
1193 enabled,
1194 ..ToggleBatch::default()
1195 };
1196
1197 for entry in &recorded {
1198 if let Some(names) = names
1199 && !names.iter().any(|name| name == &entry.name)
1200 {
1201 continue;
1202 }
1203
1204 if eco.modloader_package(&entry.name).is_some() {
1205 batch.loaders.push(entry.name.clone());
1206 } else if entry.enabled == enabled {
1207 batch.already.push(entry.name.clone());
1208 } else {
1209 batch.changing.push(entry.name.clone());
1210 }
1211 }
1212
1213 batch.changing.sort();
1214 batch.already.sort();
1215 batch.loaders.sort();
1216
1217 Ok(batch)
1218}
1219
1220pub fn set_enabled_batch(
1227 target_dir: &Path,
1228 eco: &Ecosystem,
1229 game: &str,
1230 batch: &ToggleBatch,
1231) -> Result<BatchOutcome> {
1232 let mut outcome = BatchOutcome::default();
1233
1234 for full_name in &batch.changing {
1235 match set_enabled_in(target_dir, eco, game, full_name, batch.enabled) {
1236 Ok(()) => outcome.succeeded.push(full_name.clone()),
1237 Err(error) => outcome.failed.push((full_name.clone(), error)),
1238 }
1239 }
1240
1241 Ok(outcome)
1242}
1243
1244#[cfg(test)]
1245mod tests {
1246 use super::*;
1247 use crate::ecosystem::Ecosystem;
1248 use crate::install::state_file_path;
1249 use crate::models::Version;
1250 use std::io::{Cursor, Write};
1251 use tempfile::tempdir;
1252 use time::OffsetDateTime;
1253 use zip::write::{FileOptions, ZipWriter};
1254
1255 fn write_file(path: &std::path::Path, bytes: &[u8]) {
1256 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1257 std::fs::File::create(path)
1258 .unwrap()
1259 .write_all(bytes)
1260 .unwrap();
1261 }
1262
1263 fn version(full_name: &str) -> Version {
1264 Version {
1265 name: Some("Mod".to_string()),
1266 full_name: Some(full_name.to_string()),
1267 description: Some("desc".to_string()),
1268 icon: Some("icon.png".to_string()),
1269 version_number: Some("1.2.3".to_string()),
1270 dependencies: vec!["Other-Dep-1.0.0".to_string()],
1271 download_url: Some("https://example.com/dl".to_string()),
1272 downloads: Some(1),
1273 date_created: OffsetDateTime::UNIX_EPOCH,
1274 website_url: Some("https://example.com".to_string()),
1275 is_active: Some(true),
1276 uuid4: Some("u".to_string()),
1277 file_size: Some(1),
1278 }
1279 }
1280
1281 #[test]
1282 fn mod_entry_maps_version_with_r2modman_defaults() {
1283 let entry = mod_entry_from_version("Author-Mod", "Author", &version("Author-Mod"), 123);
1284
1285 assert_eq!(entry.name, "Author-Mod");
1286 assert_eq!(entry.author_name, "Author");
1287 assert_eq!(entry.network_mode, "both");
1288 assert_eq!(entry.package_type, "other");
1289 assert_eq!(entry.install_mode, "managed");
1290 assert_eq!(entry.game_version, "0");
1291 assert_eq!(entry.manifest_version, 1);
1292 assert_eq!(
1293 entry.version_number,
1294 crate::profile::modlist::SemVer {
1295 major: 1,
1296 minor: 2,
1297 patch: 3
1298 }
1299 );
1300 assert_eq!(entry.dependencies, vec!["Other-Dep-1.0.0".to_string()]);
1301 assert_eq!(entry.installed_at_time, 123);
1302 assert!(entry.enabled);
1303 }
1304
1305 #[test]
1306 fn install_extracted_places_files_and_records_mods_yml() {
1307 let eco = Ecosystem::bundled();
1308
1309 let dir = tempdir().unwrap();
1310 let base = dir.path();
1311
1312 let extracted = tempdir().unwrap();
1313 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1314 write_file(&extracted.path().join("manifest.json"), b"{}");
1315
1316 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1317
1318 install_extracted(
1319 base,
1320 &eco,
1321 "valheim",
1322 "Main",
1323 "Author-Mod",
1324 "Author",
1325 &version("Author-Mod"),
1326 extracted.path(),
1327 None,
1328 123,
1329 )
1330 .unwrap();
1331
1332 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1333
1334 assert!(
1335 profile_dir
1336 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1337 .exists()
1338 );
1339
1340 let mods = crate::profile::modlist::read(&profile_dir).unwrap();
1341
1342 assert_eq!(mods.len(), 1);
1343 assert_eq!(mods[0].name, "Author-Mod");
1344 assert!(mods[0].enabled);
1345 }
1346
1347 #[test]
1348 fn uninstall_removes_files_and_mods_yml_entry() {
1349 let eco = Ecosystem::bundled();
1350
1351 let dir = tempdir().unwrap();
1352 let base = dir.path();
1353
1354 let extracted = tempdir().unwrap();
1355 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1356
1357 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1358
1359 install_extracted(
1360 base,
1361 &eco,
1362 "valheim",
1363 "Main",
1364 "Author-Mod",
1365 "Author",
1366 &version("Author-Mod"),
1367 extracted.path(),
1368 None,
1369 1,
1370 )
1371 .unwrap();
1372
1373 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1374
1375 assert!(
1376 profile_dir
1377 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1378 .exists()
1379 );
1380
1381 uninstall_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap();
1382
1383 assert!(!profile_dir.join("BepInEx/plugins/Author-Mod").exists());
1384 assert_eq!(
1385 crate::profile::modlist::read(&profile_dir).unwrap().len(),
1386 0
1387 );
1388 }
1389
1390 #[test]
1391 fn uninstall_refuses_a_mod_whose_install_record_cannot_be_read() {
1392 let eco = Ecosystem::bundled();
1393
1394 let dir = tempdir().unwrap();
1395 let base = dir.path();
1396
1397 let extracted = tempdir().unwrap();
1398 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1399
1400 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1401 install_extracted(
1402 base,
1403 &eco,
1404 "valheim",
1405 "Main",
1406 "Author-Mod",
1407 "Author",
1408 &version("Author-Mod"),
1409 extracted.path(),
1410 None,
1411 1,
1412 )
1413 .unwrap();
1414
1415 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1416
1417 write_file(&state_file_path(&profile_dir, "Author-Mod"), b"not: [valid");
1421
1422 let error = uninstall_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap_err();
1423
1424 assert!(
1429 profile_dir
1430 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1431 .exists(),
1432 "the mod's files must survive a refusal; got error: {error}"
1433 );
1434 assert_eq!(
1435 crate::profile::modlist::read(&profile_dir).unwrap().len(),
1436 1
1437 );
1438 }
1439
1440 #[test]
1441 fn disable_then_enable_updates_disk_and_mods_yml() {
1442 let eco = Ecosystem::bundled();
1443
1444 let dir = tempdir().unwrap();
1445 let base = dir.path();
1446
1447 let extracted = tempdir().unwrap();
1448 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1449
1450 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1451 install_extracted(
1452 base,
1453 &eco,
1454 "valheim",
1455 "Main",
1456 "Author-Mod",
1457 "Author",
1458 &version("Author-Mod"),
1459 extracted.path(),
1460 None,
1461 1,
1462 )
1463 .unwrap();
1464
1465 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1466
1467 disable_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap();
1468
1469 assert!(
1470 profile_dir
1471 .join("BepInEx/plugins/Author-Mod/Mod.dll.old")
1472 .exists()
1473 );
1474 assert!(!crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1475
1476 enable_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap();
1477
1478 assert!(
1479 profile_dir
1480 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1481 .exists()
1482 );
1483 assert!(crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1484 }
1485
1486 #[test]
1487 fn disable_leaves_files_alone_when_the_install_record_cannot_be_read() {
1488 let eco = Ecosystem::bundled();
1489
1490 let dir = tempdir().unwrap();
1491 let base = dir.path();
1492
1493 let extracted = tempdir().unwrap();
1494 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1495
1496 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1497 install_extracted(
1498 base,
1499 &eco,
1500 "valheim",
1501 "Main",
1502 "Author-Mod",
1503 "Author",
1504 &version("Author-Mod"),
1505 extracted.path(),
1506 None,
1507 1,
1508 )
1509 .unwrap();
1510
1511 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1512
1513 write_file(&state_file_path(&profile_dir, "Author-Mod"), b"not: [valid");
1514
1515 let error = disable_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap_err();
1516
1517 assert!(
1522 profile_dir
1523 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1524 .exists(),
1525 "the mod's files must be left as they were; got error: {error}"
1526 );
1527 assert!(
1528 !profile_dir
1529 .join("BepInEx/plugins/Author-Mod/Mod.dll.old")
1530 .exists()
1531 );
1532 assert!(crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1533 }
1534
1535 #[test]
1536 fn disable_mod_is_noop_for_modloader_pack() {
1537 let eco = Ecosystem::bundled();
1538
1539 let dir = tempdir().unwrap();
1540 let base = dir.path();
1541 let full_name = "denikson-BepInExPack_Valheim";
1542
1543 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1544
1545 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1546 let loader_file = profile_dir.join("winhttp.dll");
1547
1548 write_file(&loader_file, b"dll");
1549
1550 let mut mods = Vec::new();
1551
1552 modlist::upsert(
1553 &mut mods,
1554 mod_entry_from_version(full_name, "denikson", &version(full_name), 1),
1555 );
1556 modlist::write(&profile_dir, &mods).unwrap();
1557
1558 let tracker = crate::install::ModFileTracker {
1559 mod_name: full_name.to_string(),
1560 files: vec![("unused-cache-source".to_string(), "winhttp.dll".to_string())],
1561 };
1562
1563 write_state_file(&profile_dir, &tracker).unwrap();
1564
1565 disable_mod(base, &eco, "valheim", "Main", full_name).unwrap();
1566
1567 assert!(loader_file.exists());
1568 assert!(crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1569 }
1570
1571 #[test]
1572 fn install_mod_fails_on_unresolved_dependency() {
1573 let eco = Ecosystem::bundled();
1574
1575 let package = crate::models::Package {
1576 name: Some("Mod".to_string()),
1577 full_name: Some("Author-Mod".to_string()),
1578 owner: Some("Author".to_string()),
1579 package_url: Some("https://example.com/Author-Mod".to_string()),
1580 date_created: OffsetDateTime::UNIX_EPOCH,
1581 date_updated: OffsetDateTime::UNIX_EPOCH,
1582 uuid4: Some("u".to_string()),
1583 rating_score: Some(0),
1584 is_pinned: Some(false),
1585 is_deprecated: Some(false),
1586 has_nsfw_content: Some(false),
1587 categories: vec![],
1588 versions: vec![Version {
1589 dependencies: vec!["Missing-Dep-1.0.0".to_string()],
1590 ..version("Author-Mod")
1591 }],
1592 };
1593
1594 let index: PackageIndex = vec![package].into();
1595
1596 let dir = tempdir().unwrap();
1597 let base = dir.path();
1598 let cache_dir = tempdir().unwrap();
1599
1600 let client = ThunderstoreClient::builder()
1601 .package_index_url("http://localhost/pkg/")
1602 .cache_dir(cache_dir.path())
1603 .build()
1604 .unwrap();
1605
1606 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1607
1608 let rt = tokio::runtime::Runtime::new().unwrap();
1609 let result = rt.block_on(install_mod(
1610 base,
1611 &eco,
1612 &index,
1613 &client,
1614 "valheim",
1615 "Main",
1616 "Author-Mod",
1617 1,
1618 ));
1619
1620 let err = result.unwrap_err().to_string();
1621
1622 assert!(err.contains("Missing-Dep"), "unexpected error: {err}");
1623 }
1624
1625 #[test]
1626 fn server_mode_lifecycle_against_bare_target_dir() {
1627 let eco = Ecosystem::bundled();
1628
1629 let dir = tempdir().unwrap();
1630 let target_dir = dir.path();
1631
1632 let extracted = tempdir().unwrap();
1633 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1634
1635 install_extracted_in(
1636 target_dir,
1637 &eco,
1638 "valheim",
1639 "Author-Mod",
1640 "Author",
1641 &version("Author-Mod"),
1642 extracted.path(),
1643 None,
1644 123,
1645 )
1646 .unwrap();
1647
1648 assert!(
1649 target_dir
1650 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1651 .exists()
1652 );
1653 assert!(target_dir.join("mods.yml").exists());
1654
1655 let mods = modlist::read(target_dir).unwrap();
1656
1657 assert_eq!(mods.len(), 1);
1658 assert_eq!(mods[0].name, "Author-Mod");
1659 assert!(mods[0].enabled);
1660
1661 let state_source = extracted.path().join("state-source.txt");
1668
1669 write_file(&state_source, b"state-data");
1670 write_file(&target_dir.join("state-dest.txt"), b"state-data");
1671
1672 let tracker = crate::install::ModFileTracker {
1673 mod_name: "Author-Mod".to_string(),
1674 files: vec![(
1675 state_source.to_string_lossy().to_string(),
1676 "state-dest.txt".to_string(),
1677 )],
1678 };
1679
1680 write_state_file(target_dir, &tracker).unwrap();
1681
1682 assert!(state_file_path(target_dir, "Author-Mod").exists());
1683
1684 disable_mod_in(target_dir, &eco, "valheim", "Author-Mod").unwrap();
1685
1686 assert!(
1687 target_dir
1688 .join("BepInEx/plugins/Author-Mod/Mod.dll.old")
1689 .exists()
1690 );
1691 assert!(!target_dir.join("state-dest.txt").exists());
1692 assert!(!modlist::read(target_dir).unwrap()[0].enabled);
1693
1694 enable_mod_in(target_dir, &eco, "valheim", "Author-Mod").unwrap();
1695
1696 assert!(
1697 target_dir
1698 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1699 .exists()
1700 );
1701 assert!(target_dir.join("state-dest.txt").exists());
1702 assert!(modlist::read(target_dir).unwrap()[0].enabled);
1703
1704 uninstall_mod_in(target_dir, &eco, "valheim", "Author-Mod").unwrap();
1705
1706 assert!(!target_dir.join("BepInEx/plugins/Author-Mod").exists());
1707 assert!(!target_dir.join("state-dest.txt").exists());
1711 assert!(!state_file_path(target_dir, "Author-Mod").exists());
1712 assert_eq!(modlist::read(target_dir).unwrap().len(), 0);
1713 }
1714
1715 fn build_mod_zip() -> Vec<u8> {
1716 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1717 let opts: FileOptions<'_, ()> =
1718 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
1719
1720 for (name, body) in [
1721 ("manifest.json", &b"{\"version_number\":\"1.0.0\"}"[..]),
1722 ("plugins/CoolMod.dll", &b"dll-bytes"[..]),
1723 ] {
1724 zip.start_file(name, opts).unwrap();
1725 zip.write_all(body).unwrap();
1726 }
1727
1728 zip.finish().unwrap().into_inner()
1729 }
1730
1731 fn index_json(server_url: &str) -> String {
1732 format!(
1733 r#"[{{"name":"CoolMod","full_name":"Author-CoolMod","owner":"Author","package_url":"https://example.com/CoolMod","date_created":"2024-01-01T12:00:00Z","date_updated":"2024-01-02T12:00:00Z","uuid4":"pkg","rating_score":1,"is_pinned":false,"is_deprecated":false,"has_nsfw_content":false,"categories":[],"versions":[{{"name":"CoolMod","full_name":"Author-CoolMod","description":"A cool mod","icon":"icon.png","version_number":"1.0.0","dependencies":[],"download_url":"{server_url}/dl/mod.zip","downloads":1,"date_created":"2024-01-01T12:00:00Z","website_url":"","is_active":true,"uuid4":"ver","file_size":1024}}]}}]"#
1734 )
1735 }
1736
1737 #[test]
1738 fn install_mod_in_extracts_into_cache_base_not_target_dir() {
1739 let mut server = mockito::Server::new();
1740 let zip_bytes = build_mod_zip();
1741
1742 let dl = server
1743 .mock("GET", "/dl/mod.zip")
1744 .with_status(200)
1745 .with_header("Content-Type", "application/zip")
1746 .with_body(zip_bytes)
1747 .create();
1748 let idx = server
1749 .mock("GET", "/pkg/")
1750 .with_status(200)
1751 .with_header("Content-Type", "application/json")
1752 .with_header("Last-Modified", "Wed, 21 Feb 2024 15:30:45 GMT")
1753 .with_body(index_json(&server.url()))
1754 .create();
1755
1756 let http_cache = tempdir().unwrap();
1757 let cache_base = tempdir().unwrap();
1758 let target = tempdir().unwrap();
1759
1760 let client = ThunderstoreClient::builder()
1761 .package_index_url(format!("{}/pkg/", server.url()))
1762 .cache_dir(http_cache.path())
1763 .build()
1764 .unwrap();
1765
1766 let rt = tokio::runtime::Runtime::new().unwrap();
1767 let eco = Ecosystem::bundled();
1768 let index = rt.block_on(client.get_manifest()).unwrap();
1769
1770 let installed = rt
1771 .block_on(install_mod_in(
1772 target.path(),
1773 cache_base.path(),
1774 &eco,
1775 &index,
1776 &client,
1777 "valheim",
1778 "Author-CoolMod",
1779 123,
1780 ))
1781 .unwrap();
1782
1783 assert_eq!(installed, vec!["Author-CoolMod".to_string()]);
1784
1785 assert!(
1786 target
1787 .path()
1788 .join("BepInEx/plugins/Author-CoolMod/CoolMod.dll")
1789 .exists()
1790 );
1791
1792 let cached = cache::package_cache_dir(cache_base.path(), "valheim", "Author-CoolMod", "1.0.0");
1793
1794 assert!(cached.join("plugins/CoolMod.dll").exists());
1795 assert!(!target.path().join("cache").exists());
1796
1797 dl.assert();
1798 idx.assert();
1799 }
1800
1801 fn profile_mod(name: &str) -> ProfileMod {
1805 ProfileMod {
1806 manifest_version: 1,
1807 name: name.to_string(),
1808 author_name: String::new(),
1809 website_url: String::new(),
1810 display_name: name.to_string(),
1811 description: String::new(),
1812 game_version: "0".to_string(),
1813 network_mode: "both".to_string(),
1814 package_type: "other".to_string(),
1815 install_mode: "managed".to_string(),
1816 installed_at_time: 1,
1817 loaders: Vec::new(),
1818 dependencies: Vec::new(),
1819 incompatibilities: Vec::new(),
1820 optional_dependencies: Vec::new(),
1821 version_number: SemVer::parse("1.0.0"),
1822 enabled: true,
1823 icon: String::new(),
1824 online_source: false,
1825 trusted_package: false,
1826 }
1827 }
1828
1829 #[test]
1830 fn uninstalling_a_loader_pack_without_a_state_file_is_refused() {
1831 let dir = tempdir().unwrap();
1832 let target_dir = dir.path();
1833 let eco = crate::ecosystem::Ecosystem::bundled();
1834
1835 modlist::write(target_dir, &[profile_mod("denikson-BepInExPack_Valheim")]).unwrap();
1839
1840 let result = uninstall_mod_in(target_dir, &eco, "valheim", "denikson-BepInExPack_Valheim");
1841
1842 assert!(result.is_err());
1843
1844 let message = result.unwrap_err().to_string();
1845
1846 assert!(
1847 message.contains("denikson-BepInExPack_Valheim"),
1848 "the error must name the loader; got: {message}"
1849 );
1850 assert_eq!(modlist::read(target_dir).unwrap().len(), 1);
1853 }
1854
1855 #[test]
1856 fn uninstalling_a_regular_mod_without_a_state_file_still_works() {
1857 let dir = tempdir().unwrap();
1858 let target_dir = dir.path();
1859 let eco = crate::ecosystem::Ecosystem::bundled();
1860
1861 modlist::write(target_dir, &[profile_mod("Owner-Regular")]).unwrap();
1864
1865 uninstall_mod_in(target_dir, &eco, "valheim", "Owner-Regular").unwrap();
1866
1867 assert!(modlist::read(target_dir).unwrap().is_empty());
1868 }
1869
1870 #[test]
1871 fn check_removable_batch_reports_every_unremovable_name_with_its_reason() {
1872 let dir = tempdir().unwrap();
1873 let target_dir = dir.path();
1874 let eco = crate::ecosystem::Ecosystem::bundled();
1875
1876 modlist::write(
1877 target_dir,
1878 &[
1879 profile_mod("denikson-BepInExPack_Valheim"),
1880 profile_mod("Owner-Regular"),
1881 ],
1882 )
1883 .unwrap();
1884
1885 let state_path = crate::install::state_file_path(target_dir, "Owner-Regular");
1892
1893 std::fs::create_dir_all(state_path.parent().unwrap()).unwrap();
1894 std::fs::write(&state_path, "not: [valid").unwrap();
1895
1896 let names = vec![
1897 "denikson-BepInExPack_Valheim".to_string(),
1898 "Owner-Regular".to_string(),
1899 ];
1900
1901 let blocked = check_removable_batch(target_dir, &eco, &names);
1902
1903 assert_eq!(
1904 blocked.len(),
1905 2,
1906 "both un-removable names must be reported, not just the first; got {blocked:?}"
1907 );
1908
1909 let loader_refusal = blocked
1910 .iter()
1911 .find(|(name, _)| name == "denikson-BepInExPack_Valheim")
1912 .map(|(_, refusal)| refusal)
1913 .unwrap();
1914 let loader_path = state_file_path(target_dir, "denikson-BepInExPack_Valheim");
1915
1916 assert!(
1919 matches!(loader_refusal, NotRemovable::RecordAbsent { path, .. } if path == &loader_path),
1920 "expected an absent record carrying its expected path; got: {loader_refusal:?}"
1921 );
1922
1923 let regular_refusal = blocked
1924 .iter()
1925 .find(|(name, _)| name == "Owner-Regular")
1926 .map(|(_, refusal)| refusal)
1927 .unwrap();
1928
1929 assert!(
1930 matches!(regular_refusal, NotRemovable::RecordUnreadable { path, .. } if path == &state_path),
1931 "expected an unreadable record carrying the path it could not read; got: {regular_refusal:?}"
1932 );
1933 }
1934
1935 #[test]
1936 fn a_removability_refusal_keeps_todays_wording_when_it_becomes_an_error() {
1937 let dir = tempdir().unwrap();
1938 let target_dir = dir.path();
1939 let eco = crate::ecosystem::Ecosystem::bundled();
1940
1941 modlist::write(target_dir, &[profile_mod("denikson-BepInExPack_Valheim")]).unwrap();
1942
1943 let refusal = ensure_removable(target_dir, &eco, "denikson-BepInExPack_Valheim").unwrap_err();
1944 let path = state_file_path(target_dir, "denikson-BepInExPack_Valheim");
1945
1946 assert_eq!(
1949 Error::from(refusal).to_string(),
1950 format!(
1951 "Profile error: denikson-BepInExPack_Valheim is a mod loader with no \
1952 install record at {}, so its files cannot be removed exactly. Reinstall \
1953 it, then uninstall it again, or remove its files by hand.",
1954 path.display()
1955 )
1956 );
1957 }
1958
1959 #[test]
1960 fn a_reinstall_the_caller_did_not_name_keeps_its_disabled_state() {
1961 let eco = Ecosystem::bundled();
1962 let dir = tempdir().unwrap();
1963 let base = dir.path();
1964
1965 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1966
1967 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1968 let extracted = tempdir().unwrap();
1969
1970 write_file(&extracted.path().join("plugins/Base.dll"), b"dll");
1971
1972 install_extracted(
1973 base,
1974 &eco,
1975 "valheim",
1976 "Main",
1977 "Author-Base",
1978 "Author",
1979 &version("Author-Base"),
1980 extracted.path(),
1981 None,
1982 1,
1983 )
1984 .unwrap();
1985
1986 disable_mod(base, &eco, "valheim", "Main", "Author-Base").unwrap();
1987
1988 assert!(!modlist::read(&profile_dir).unwrap()[0].enabled);
1989 assert!(
1990 profile_dir
1991 .join("BepInEx/plugins/Author-Base/Base.dll.old")
1992 .exists(),
1993 "the disable must have suffixed the file, or this test proves nothing"
1994 );
1995
1996 let batch = plan_install_batch(&profile_dir, &["Author-Base".to_string()], &[]).unwrap();
1999
2000 assert_eq!(batch.protect_disabled, vec!["Author-Base".to_string()]);
2001
2002 install_extracted(
2003 base,
2004 &eco,
2005 "valheim",
2006 "Main",
2007 "Author-Base",
2008 "Author",
2009 &version("Author-Base"),
2010 extracted.path(),
2011 None,
2012 2,
2013 )
2014 .unwrap();
2015
2016 reapply_batch_disabled(&profile_dir, &eco, "valheim", &batch).unwrap();
2017
2018 let mods = modlist::read(&profile_dir).unwrap();
2019
2020 assert!(
2021 !mods[0].enabled,
2022 "a disabled dependency must not be re-enabled by an incidental reinstall"
2023 );
2024 assert!(
2025 profile_dir
2026 .join("BepInEx/plugins/Author-Base/Base.dll.old")
2027 .exists(),
2028 "the file must be left suffixed, not rewritten un-suffixed and orphaned"
2029 );
2030 }
2031
2032 #[test]
2033 fn planning_an_uninstall_batch_partitions_loaders_and_lists_untracked() {
2034 let eco = Ecosystem::bundled();
2035 let dir = tempdir().unwrap();
2036 let base = dir.path();
2037
2038 crate::profile::layout::create(base, "valheim", "Main").unwrap();
2039
2040 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2041 let extracted = tempdir().unwrap();
2042
2043 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2044 install_extracted(
2045 base,
2046 &eco,
2047 "valheim",
2048 "Main",
2049 "Author-Mod",
2050 "Author",
2051 &version("Author-Mod"),
2052 extracted.path(),
2053 None,
2054 1,
2055 )
2056 .unwrap();
2057
2058 write_file(
2060 &profile_dir.join("BepInEx/plugins/Other-ByHand/x.dll"),
2061 b"dll",
2062 );
2063 write_file(&profile_dir.join("BepInEx/plugins/MMHOOK/h.dll"), b"dll");
2065
2066 let batch = plan_uninstall_batch(&profile_dir, &eco, "valheim", None).unwrap();
2067
2068 assert_eq!(batch.mods, vec!["Author-Mod".to_string()]);
2069 assert!(batch.loaders.is_empty());
2070 assert_eq!(
2071 batch.untracked,
2072 vec![std::path::PathBuf::from("BepInEx/plugins/Other-ByHand")]
2073 );
2074
2075 let outcome = uninstall_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2076
2077 assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2078 assert_eq!(outcome.succeeded, vec!["Author-Mod".to_string()]);
2079 assert!(modlist::read(&profile_dir).unwrap().is_empty());
2080 assert!(profile_dir.join("BepInEx/plugins/MMHOOK/h.dll").exists());
2081 }
2082
2083 #[test]
2084 fn uninstalling_a_batch_removes_the_loader_after_ordinary_mods_and_its_own_files() {
2085 let eco = Ecosystem::bundled();
2086 let dir = tempdir().unwrap();
2087 let base = dir.path();
2088 let loader = "denikson-BepInExPack_Valheim";
2089
2090 crate::profile::layout::create(base, "valheim", "Main").unwrap();
2091
2092 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2093 let extracted = tempdir().unwrap();
2094
2095 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2096 install_extracted(
2097 base,
2098 &eco,
2099 "valheim",
2100 "Main",
2101 "Author-Mod",
2102 "Author",
2103 &version("Author-Mod"),
2104 extracted.path(),
2105 None,
2106 1,
2107 )
2108 .unwrap();
2109
2110 let winhttp = profile_dir.join("winhttp.dll");
2116 let core_dll = profile_dir.join("BepInEx/core/BepInEx.dll");
2117
2118 write_file(&winhttp, b"proxy");
2119 write_file(&core_dll, b"core");
2120
2121 let mut mods = modlist::read(&profile_dir).unwrap();
2122
2123 modlist::upsert(
2124 &mut mods,
2125 mod_entry_from_version(loader, "denikson", &version(loader), 1),
2126 );
2127 modlist::write(&profile_dir, &mods).unwrap();
2128
2129 let tracker = crate::install::ModFileTracker {
2130 mod_name: loader.to_string(),
2131 files: vec![
2132 (
2133 "unused-cache-source-1".to_string(),
2134 "winhttp.dll".to_string(),
2135 ),
2136 (
2137 "unused-cache-source-2".to_string(),
2138 "BepInEx/core/BepInEx.dll".to_string(),
2139 ),
2140 ],
2141 };
2142
2143 write_state_file(&profile_dir, &tracker).unwrap();
2144
2145 let batch = plan_uninstall_batch(&profile_dir, &eco, "valheim", None).unwrap();
2146
2147 assert_eq!(batch.mods, vec!["Author-Mod".to_string()]);
2148 assert_eq!(batch.loaders, vec![loader.to_string()]);
2149
2150 let outcome = uninstall_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2151
2152 assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2153 assert_eq!(
2161 outcome.succeeded,
2162 vec!["Author-Mod".to_string(), loader.to_string()]
2163 );
2164 assert!(modlist::read(&profile_dir).unwrap().is_empty());
2165 assert!(!winhttp.exists());
2168 assert!(!core_dll.exists());
2169 }
2170
2171 #[test]
2172 fn planning_an_uninstall_batch_sorts_and_removes_two_recorded_loaders() {
2173 let eco = Ecosystem::bundled();
2174 let dir = tempdir().unwrap();
2175 let base = dir.path();
2176 let first_loader = "denikson-BepInExPack_Valheim";
2177 let second_loader = "1F31A-BepInEx_Valheim_Full";
2183
2184 crate::profile::layout::create(base, "valheim", "Main").unwrap();
2185
2186 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2187
2188 let mut mods = Vec::new();
2189
2190 for loader in [first_loader, second_loader] {
2191 modlist::upsert(
2192 &mut mods,
2193 mod_entry_from_version(loader, "owner", &version(loader), 1),
2194 );
2195
2196 write_state_file(
2200 &profile_dir,
2201 &crate::install::ModFileTracker {
2202 mod_name: loader.to_string(),
2203 files: Vec::new(),
2204 },
2205 )
2206 .unwrap();
2207 }
2208
2209 modlist::write(&profile_dir, &mods).unwrap();
2210
2211 let batch = plan_uninstall_batch(&profile_dir, &eco, "valheim", None).unwrap();
2212
2213 assert!(batch.mods.is_empty());
2214 assert_eq!(
2216 batch.loaders,
2217 vec![second_loader.to_string(), first_loader.to_string()]
2218 );
2219
2220 let outcome = uninstall_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2221
2222 assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2223 assert!(modlist::read(&profile_dir).unwrap().is_empty());
2224 }
2225
2226 #[test]
2227 fn planning_a_toggle_batch_splits_changing_from_already_and_excludes_loaders() {
2228 let eco = Ecosystem::bundled();
2229 let dir = tempdir().unwrap();
2230 let base = dir.path();
2231 let loader = "denikson-BepInExPack_Valheim";
2232
2233 crate::profile::layout::create(base, "valheim", "Main").unwrap();
2234
2235 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2236 let extracted = tempdir().unwrap();
2237
2238 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2239
2240 for name in ["Author-One", "Author-Two"] {
2241 install_extracted(
2242 base,
2243 &eco,
2244 "valheim",
2245 "Main",
2246 name,
2247 "Author",
2248 &version(name),
2249 extracted.path(),
2250 None,
2251 1,
2252 )
2253 .unwrap();
2254 }
2255
2256 let mut mods = modlist::read(&profile_dir).unwrap();
2258
2259 mods.push(mod_entry_from_version(
2260 loader,
2261 "denikson",
2262 &version(loader),
2263 1,
2264 ));
2265 modlist::write(&profile_dir, &mods).unwrap();
2266
2267 disable_mod(base, &eco, "valheim", "Main", "Author-One").unwrap();
2268
2269 let batch = plan_set_enabled_batch(&profile_dir, &eco, None, false).unwrap();
2270
2271 assert_eq!(batch.changing, vec!["Author-Two".to_string()]);
2272 assert_eq!(batch.already, vec!["Author-One".to_string()]);
2273 assert_eq!(batch.loaders, vec![loader.to_string()]);
2274
2275 let outcome = set_enabled_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2276
2277 assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2278 assert_eq!(outcome.succeeded, vec!["Author-Two".to_string()]);
2279
2280 let after = modlist::read(&profile_dir).unwrap();
2281
2282 assert!(
2283 !after
2284 .iter()
2285 .find(|m| m.name == "Author-Two")
2286 .unwrap()
2287 .enabled
2288 );
2289 assert!(after.iter().find(|m| m.name == loader).unwrap().enabled);
2291 }
2292
2293 #[test]
2294 fn plan_set_enabled_batch_refuses_an_unrecorded_name() {
2295 let eco = Ecosystem::bundled();
2296 let dir = tempdir().unwrap();
2297 let base = dir.path();
2298
2299 crate::profile::layout::create(base, "valheim", "Main").unwrap();
2300
2301 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2302 let extracted = tempdir().unwrap();
2303
2304 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2305
2306 install_extracted(
2307 base,
2308 &eco,
2309 "valheim",
2310 "Main",
2311 "Author-One",
2312 "Author",
2313 &version("Author-One"),
2314 extracted.path(),
2315 None,
2316 1,
2317 )
2318 .unwrap();
2319
2320 let names = vec!["Author-Gone".to_string()];
2321 let error = plan_set_enabled_batch(&profile_dir, &eco, Some(&names), false).unwrap_err();
2322
2323 assert!(error.to_string().contains("Author-Gone"));
2324 assert!(error.to_string().contains("is not installed"));
2325 }
2326
2327 mod reinstall {
2331 use super::*;
2332 use crate::models::Package;
2333
2334 const MOD: &str = "Author-Mod";
2335 const DEP: &str = "Author-Dep";
2336 const LOADER: &str = "denikson-BepInExPack_Valheim";
2337
2338 struct Pkg<'a> {
2340 full_name: &'a str,
2341 version: &'a str,
2342 deps: &'a [&'a str],
2343 files: &'a [(&'a str, &'a [u8])],
2344 }
2345
2346 struct Repo {
2347 server: mockito::ServerGuard,
2348 mocks: Vec<mockito::Mock>,
2349 client: ThunderstoreClient,
2350 _http_cache: tempfile::TempDir,
2351 cache_base: tempfile::TempDir,
2352 target: tempfile::TempDir,
2353 eco: Ecosystem,
2354 rt: tokio::runtime::Runtime,
2355 clock: u64,
2356 }
2357
2358 impl Repo {
2359 fn new() -> Self {
2360 let server = mockito::Server::new();
2361 let http_cache = tempdir().unwrap();
2362 let client = ThunderstoreClient::builder()
2363 .package_index_url(format!("{}/pkg/", server.url()))
2364 .cache_dir(http_cache.path())
2365 .build()
2366 .unwrap();
2367
2368 Self {
2369 server,
2370 mocks: Vec::new(),
2371 client,
2372 _http_cache: http_cache,
2373 cache_base: tempdir().unwrap(),
2374 target: tempdir().unwrap(),
2375 eco: Ecosystem::bundled(),
2376 rt: tokio::runtime::Runtime::new().unwrap(),
2377 clock: 0,
2378 }
2379 }
2380
2381 fn publish(&mut self, pkgs: &[Pkg<'_>]) -> PackageIndex {
2384 let mut packages: Vec<serde_json::Value> = Vec::new();
2385
2386 for (day, pkg) in pkgs.iter().enumerate() {
2387 let path = format!("/dl/{}-{}.zip", pkg.full_name, pkg.version);
2388 let mock = self
2389 .server
2390 .mock("GET", path.as_str())
2391 .with_status(200)
2392 .with_header("Content-Type", "application/zip")
2393 .with_body(zip_of(pkg.files))
2394 .create();
2395
2396 self.mocks.push(mock);
2397
2398 let (owner, name) = pkg.full_name.split_once('-').unwrap();
2399 let created = format!("2024-01-{:02}T12:00:00Z", day + 1);
2400 let version = serde_json::json!({
2401 "name": name, "full_name": pkg.full_name, "description": "d", "icon": "i",
2402 "version_number": pkg.version, "dependencies": pkg.deps,
2403 "download_url": format!("{}{path}", self.server.url()), "downloads": 1,
2404 "date_created": created, "website_url": "", "is_active": true,
2405 "uuid4": "ver", "file_size": 1
2406 });
2407
2408 match packages
2409 .iter_mut()
2410 .find(|p| p["full_name"] == pkg.full_name)
2411 {
2412 Some(package) => package["versions"].as_array_mut().unwrap().push(version),
2413 None => packages.push(serde_json::json!({
2414 "name": name, "full_name": pkg.full_name, "owner": owner,
2415 "package_url": "https://example.com", "date_created": "2024-01-01T12:00:00Z",
2416 "date_updated": "2024-01-02T12:00:00Z", "uuid4": "pkg", "rating_score": 1,
2417 "is_pinned": false, "is_deprecated": false, "has_nsfw_content": false,
2418 "categories": [], "versions": [version]
2419 })),
2420 }
2421 }
2422
2423 let packages = packages
2424 .into_iter()
2425 .map(|json| serde_json::from_value::<Package>(json).unwrap())
2426 .collect::<Vec<_>>();
2427
2428 PackageIndex::from(packages)
2429 }
2430
2431 fn install(
2433 &mut self,
2434 index: &PackageIndex,
2435 desired: &[&str],
2436 explicit: &[&str],
2437 force: bool,
2438 ) -> BatchOutcome {
2439 let outcome = self.try_install(index, desired, explicit, force);
2440
2441 assert!(outcome.is_ok(), "batch failed: {:?}", outcome.failed);
2442
2443 outcome
2444 }
2445
2446 fn try_install(
2449 &mut self,
2450 index: &PackageIndex,
2451 desired: &[&str],
2452 explicit: &[&str],
2453 force: bool,
2454 ) -> BatchOutcome {
2455 let desired: Vec<String> = desired.iter().map(|s| s.to_string()).collect();
2456 let explicit: Vec<String> = explicit.iter().map(|s| s.to_string()).collect();
2457 let mut batch = plan_install_batch(self.target.path(), &desired, &explicit).unwrap();
2458
2459 batch.force = force;
2460 self.clock += 1;
2461
2462 self
2463 .rt
2464 .block_on(install_batch(
2465 self.target.path(),
2466 self.cache_base.path(),
2467 &self.eco,
2468 index,
2469 &self.client,
2470 "valheim",
2471 &batch,
2472 self.clock,
2473 ))
2474 .unwrap()
2475 }
2476
2477 fn path(&self, rel: &str) -> PathBuf {
2478 self.target.path().join(rel)
2479 }
2480
2481 fn entry(&self, name: &str) -> ProfileMod {
2482 modlist::read(self.target.path())
2483 .unwrap()
2484 .into_iter()
2485 .find(|m| m.name == name)
2486 .unwrap()
2487 }
2488 }
2489
2490 fn zip_of(files: &[(&str, &[u8])]) -> Vec<u8> {
2491 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
2492 let opts: FileOptions<'_, ()> =
2493 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
2494
2495 for (name, body) in files {
2496 zip.start_file(*name, opts).unwrap();
2497 zip.write_all(body).unwrap();
2498 }
2499
2500 zip.finish().unwrap().into_inner()
2501 }
2502
2503 fn names(list: &[&str]) -> Vec<String> {
2504 list.iter().map(|s| s.to_string()).collect()
2505 }
2506
2507 #[test]
2508 fn an_install_at_the_recorded_version_is_skipped() {
2509 let mut repo = Repo::new();
2510 let index = repo.publish(&[Pkg {
2511 full_name: MOD,
2512 version: "1.0.0",
2513 deps: &[],
2514 files: &[("plugins/Mod.dll", b"v1")],
2515 }]);
2516
2517 repo.install(&index, &[MOD], &[MOD], false);
2518
2519 let user_file = repo.path("BepInEx/plugins/Author-Mod/user.txt");
2520
2521 write_file(&user_file, b"mine");
2522
2523 let installed_at = repo.entry(MOD).installed_at_time;
2524 let outcome = repo.install(&index, &[MOD], &[MOD], false);
2525
2526 assert!(outcome.succeeded.is_empty());
2527 assert_eq!(outcome.unchanged, names(&[MOD]));
2528 assert!(user_file.exists(), "a skipped install must not touch files");
2529 assert_eq!(repo.entry(MOD).installed_at_time, installed_at);
2530 }
2531
2532 #[test]
2533 fn a_version_change_removes_files_only_the_old_version_had() {
2534 let mut repo = Repo::new();
2535 let v1 = repo.publish(&[Pkg {
2536 full_name: MOD,
2537 version: "1.0.0",
2538 deps: &[],
2539 files: &[
2540 ("plugins/Old.dll", b"old"),
2541 ("plugins/german.json", b"flat"),
2542 ],
2543 }]);
2544
2545 repo.install(&v1, &[MOD], &[MOD], false);
2546
2547 assert!(repo.path("BepInEx/plugins/Author-Mod/german.json").exists());
2548
2549 let v2 = repo.publish(&[Pkg {
2550 full_name: MOD,
2551 version: "2.0.0",
2552 deps: &[],
2553 files: &[
2554 ("plugins/Mod.dll", b"new"),
2555 ("plugins/Translations/German/german.json", b"nested"),
2556 ],
2557 }]);
2558 let outcome = repo.install(&v2, &[MOD], &[MOD], false);
2559
2560 assert_eq!(outcome.succeeded, names(&[MOD]));
2561 assert!(!repo.path("BepInEx/plugins/Author-Mod/Old.dll").exists());
2562 assert!(!repo.path("BepInEx/plugins/Author-Mod/german.json").exists());
2563 assert!(
2564 repo
2565 .path("BepInEx/plugins/Author-Mod/Translations/German/german.json")
2566 .exists()
2567 );
2568 assert_eq!(repo.entry(MOD).version_number.to_string(), "2.0.0");
2569 }
2570
2571 #[test]
2572 fn force_reinstalls_the_recorded_version_from_a_clean_folder() {
2573 let mut repo = Repo::new();
2574 let index = repo.publish(&[Pkg {
2575 full_name: MOD,
2576 version: "1.0.0",
2577 deps: &[],
2578 files: &[("plugins/Translations/German/german.json", b"nested")],
2579 }]);
2580
2581 repo.install(&index, &[MOD], &[MOD], false);
2582
2583 let stale = repo.path("BepInEx/plugins/Author-Mod/german.json");
2585
2586 write_file(&stale, b"flat");
2587
2588 let outcome = repo.install(&index, &[MOD], &[MOD], true);
2589
2590 assert_eq!(outcome.succeeded, names(&[MOD]));
2591 assert!(outcome.unchanged.is_empty());
2592 assert!(!stale.exists());
2593 assert!(
2594 repo
2595 .path("BepInEx/plugins/Author-Mod/Translations/German/german.json")
2596 .exists()
2597 );
2598 }
2599
2600 #[test]
2601 fn force_reinstalls_only_the_named_mod_not_its_dependencies() {
2602 let mut repo = Repo::new();
2603 let index = repo.publish(&[
2604 Pkg {
2605 full_name: MOD,
2606 version: "1.0.0",
2607 deps: &["Author-Dep-1.0.0"],
2608 files: &[("plugins/Mod.dll", b"mod")],
2609 },
2610 Pkg {
2611 full_name: DEP,
2612 version: "1.0.0",
2613 deps: &[],
2614 files: &[("plugins/Dep.dll", b"dep")],
2615 },
2616 ]);
2617
2618 repo.install(&index, &[MOD], &[MOD], false);
2619
2620 let dep_marker = repo.path("BepInEx/plugins/Author-Dep/user.txt");
2621
2622 write_file(&dep_marker, b"mine");
2623
2624 let outcome = repo.install(&index, &[MOD], &[MOD], true);
2625
2626 assert_eq!(outcome.succeeded, names(&[MOD]));
2627 assert_eq!(outcome.unchanged, names(&[DEP]));
2628 assert!(
2629 dep_marker.exists(),
2630 "the dependency must not be reinstalled"
2631 );
2632 }
2633
2634 #[test]
2635 fn a_disabled_mod_stays_disabled_through_force_and_an_update() {
2636 let mut repo = Repo::new();
2637 let v1 = repo.publish(&[Pkg {
2638 full_name: MOD,
2639 version: "1.0.0",
2640 deps: &[],
2641 files: &[("plugins/Mod.dll", b"v1"), ("plugins/Old.dll", b"v1")],
2642 }]);
2643
2644 repo.install(&v1, &[MOD], &[MOD], false);
2645
2646 set_enabled_in(repo.target.path(), &repo.eco, "valheim", MOD, false).unwrap();
2647
2648 repo.install(&v1, &[MOD], &[], true);
2650
2651 assert!(!repo.entry(MOD).enabled);
2652 assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll.old").exists());
2653 assert!(!repo.path("BepInEx/plugins/Author-Mod/Mod.dll").exists());
2654
2655 let v2 = repo.publish(&[Pkg {
2656 full_name: MOD,
2657 version: "2.0.0",
2658 deps: &[],
2659 files: &[("plugins/Mod.dll", b"v2")],
2660 }]);
2661
2662 repo.install(&v2, &[MOD], &[], false);
2663
2664 assert!(!repo.entry(MOD).enabled);
2665 assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll.old").exists());
2666 assert!(!repo.path("BepInEx/plugins/Author-Mod/Mod.dll").exists());
2667 assert!(!repo.path("BepInEx/plugins/Author-Mod/Old.dll.old").exists());
2668 }
2669
2670 #[test]
2671 fn naming_a_disabled_mod_at_its_recorded_version_enables_it() {
2672 let mut repo = Repo::new();
2673 let index = repo.publish(&[Pkg {
2674 full_name: MOD,
2675 version: "1.0.0",
2676 deps: &[],
2677 files: &[("plugins/Mod.dll", b"v1")],
2678 }]);
2679
2680 repo.install(&index, &[MOD], &[MOD], false);
2681
2682 set_enabled_in(repo.target.path(), &repo.eco, "valheim", MOD, false).unwrap();
2683
2684 let outcome = repo.install(&index, &[MOD], &[MOD], false);
2685
2686 assert_eq!(outcome.unchanged, names(&[MOD]));
2687 assert!(repo.entry(MOD).enabled);
2688 assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll").exists());
2689 assert!(!repo.path("BepInEx/plugins/Author-Mod/Mod.dll.old").exists());
2690 }
2691
2692 #[test]
2693 fn shared_config_survives_force_and_an_update() {
2694 let mut repo = Repo::new();
2695 let v1 = repo.publish(&[Pkg {
2696 full_name: MOD,
2697 version: "1.0.0",
2698 deps: &[],
2699 files: &[("plugins/Mod.dll", b"v1"), ("config/Mod.cfg", b"default")],
2700 }]);
2701
2702 repo.install(&v1, &[MOD], &[MOD], false);
2703
2704 let cfg = repo.path("BepInEx/config/Mod.cfg");
2705
2706 write_file(&cfg, b"edited");
2707 repo.install(&v1, &[MOD], &[MOD], true);
2708
2709 assert_eq!(std::fs::read(&cfg).unwrap(), b"edited");
2710
2711 let v2 = repo.publish(&[Pkg {
2712 full_name: MOD,
2713 version: "2.0.0",
2714 deps: &[],
2715 files: &[("plugins/Mod.dll", b"v2"), ("config/Mod.cfg", b"default")],
2716 }]);
2717
2718 repo.install(&v2, &[MOD], &[MOD], false);
2719
2720 assert_eq!(std::fs::read(&cfg).unwrap(), b"edited");
2721 }
2722
2723 #[test]
2724 fn force_reinstalls_a_loader_pack_and_keeps_its_config() {
2725 let mut repo = Repo::new();
2726 let index = repo.publish(&[Pkg {
2727 full_name: LOADER,
2728 version: "5.4.2333",
2729 deps: &[],
2730 files: &[
2731 ("BepInExPack_Valheim/winhttp.dll", b"proxy"),
2732 ("BepInExPack_Valheim/BepInEx/core/BepInEx.dll", b"core"),
2733 ("BepInExPack_Valheim/BepInEx/config/BepInEx.cfg", b"default"),
2734 ],
2735 }]);
2736
2737 repo.install(&index, &[LOADER], &[LOADER], false);
2738
2739 let cfg = repo.path("BepInEx/config/BepInEx.cfg");
2740 let core = repo.path("BepInEx/core/BepInEx.dll");
2741
2742 write_file(&cfg, b"edited");
2743 write_file(&core, b"tampered");
2744
2745 let outcome = repo.install(&index, &[LOADER], &[LOADER], true);
2746
2747 assert_eq!(outcome.succeeded, names(&[LOADER]));
2748 assert_eq!(std::fs::read(&core).unwrap(), b"core");
2749 assert!(repo.path("winhttp.dll").exists());
2750 assert_eq!(std::fs::read(&cfg).unwrap(), b"edited");
2751 assert!(state_file_path(repo.target.path(), LOADER).exists());
2752 }
2753
2754 #[test]
2755 fn a_recorded_mod_whose_folder_is_gone_is_reinstalled() {
2756 let mut repo = Repo::new();
2757 let index = repo.publish(&[Pkg {
2758 full_name: MOD,
2759 version: "1.0.0",
2760 deps: &[],
2761 files: &[("plugins/Mod.dll", b"v1")],
2762 }]);
2763
2764 repo.install(&index, &[MOD], &[MOD], false);
2765
2766 std::fs::remove_dir_all(repo.path("BepInEx/plugins/Author-Mod")).unwrap();
2767
2768 let outcome = repo.install(&index, &[MOD], &[MOD], false);
2769
2770 assert_eq!(outcome.succeeded, names(&[MOD]));
2771 assert!(outcome.unchanged.is_empty());
2772 assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll").exists());
2773 }
2774
2775 #[test]
2776 fn a_loader_pack_missing_a_tracked_file_is_reinstalled() {
2777 let mut repo = Repo::new();
2778 let index = repo.publish(&[Pkg {
2779 full_name: LOADER,
2780 version: "5.4.2333",
2781 deps: &[],
2782 files: &[
2783 ("BepInExPack_Valheim/winhttp.dll", b"proxy"),
2784 ("BepInExPack_Valheim/BepInEx/core/BepInEx.dll", b"core"),
2785 ],
2786 }]);
2787
2788 repo.install(&index, &[LOADER], &[LOADER], false);
2789
2790 std::fs::remove_file(repo.path("winhttp.dll")).unwrap();
2791
2792 let outcome = repo.install(&index, &[LOADER], &[LOADER], false);
2793
2794 assert_eq!(outcome.succeeded, names(&[LOADER]));
2795 assert!(repo.path("winhttp.dll").exists());
2796 }
2797
2798 #[test]
2799 fn a_recorded_mod_with_no_files_to_place_is_skipped() {
2800 let mut repo = Repo::new();
2801 let index = repo.publish(&[Pkg {
2802 full_name: MOD,
2803 version: "1.0.0",
2804 deps: &[],
2805 files: &[("manifest.json", b"{}"), ("README.md", b"modpack")],
2806 }]);
2807
2808 repo.install(&index, &[MOD], &[MOD], false);
2809
2810 let outcome = repo.install(&index, &[MOD], &[MOD], false);
2811
2812 assert!(outcome.succeeded.is_empty());
2813 assert_eq!(outcome.unchanged, names(&[MOD]));
2814 }
2815
2816 #[cfg(unix)]
2817 #[test]
2818 fn a_failed_enable_is_reported_per_item_without_losing_the_batch() {
2819 use std::os::unix::fs::PermissionsExt;
2820
2821 let mut repo = Repo::new();
2822 let index = repo.publish(&[
2823 Pkg {
2824 full_name: MOD,
2825 version: "1.0.0",
2826 deps: &[],
2827 files: &[("plugins/Mod.dll", b"v1")],
2828 },
2829 Pkg {
2830 full_name: DEP,
2831 version: "1.0.0",
2832 deps: &[],
2833 files: &[("plugins/Dep.dll", b"dep")],
2834 },
2835 ]);
2836
2837 repo.install(&index, &[MOD], &[MOD], false);
2838
2839 set_enabled_in(repo.target.path(), &repo.eco, "valheim", MOD, false).unwrap();
2840
2841 let folder = repo.path("BepInEx/plugins/Author-Mod");
2843
2844 std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o555)).unwrap();
2845
2846 if std::fs::write(folder.join("probe"), b"").is_ok() {
2847 return;
2849 }
2850
2851 let desired = names(&[MOD, DEP]);
2852 let batch = plan_install_batch(repo.target.path(), &desired, &desired).unwrap();
2853 let result = repo.rt.block_on(install_batch(
2854 repo.target.path(),
2855 repo.cache_base.path(),
2856 &repo.eco,
2857 &index,
2858 &repo.client,
2859 "valheim",
2860 &batch,
2861 1,
2862 ));
2863
2864 std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o755)).unwrap();
2865
2866 let outcome = result.expect("a failed enable must not discard the batch");
2867 let failed: Vec<&str> = outcome.failed.iter().map(|(n, _)| n.as_str()).collect();
2868
2869 assert_eq!(failed, vec![MOD]);
2870 assert_eq!(outcome.succeeded, names(&[DEP]));
2871 assert!(outcome.unchanged.is_empty());
2872 }
2873
2874 #[test]
2875 fn a_disabled_mod_not_named_is_still_skipped() {
2876 let mut repo = Repo::new();
2877 let index = repo.publish(&[Pkg {
2878 full_name: MOD,
2879 version: "1.0.0",
2880 deps: &[],
2881 files: &[("plugins/Mod.dll", b"v1")],
2882 }]);
2883
2884 repo.install(&index, &[MOD], &[MOD], false);
2885
2886 set_enabled_in(repo.target.path(), &repo.eco, "valheim", MOD, false).unwrap();
2887
2888 let outcome = repo.install(&index, &[MOD], &[], false);
2889
2890 assert_eq!(outcome.unchanged, names(&[MOD]));
2891 assert!(!repo.entry(MOD).enabled);
2892 assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll.old").exists());
2893 }
2894
2895 #[test]
2896 fn a_mod_left_half_disabled_is_reinstalled() {
2897 let mut repo = Repo::new();
2898 let index = repo.publish(&[Pkg {
2899 full_name: MOD,
2900 version: "1.0.0",
2901 deps: &[],
2902 files: &[("plugins/Mod.dll", b"v1"), ("plugins/Extra.dll", b"v1")],
2903 }]);
2904
2905 repo.install(&index, &[MOD], &[MOD], false);
2906
2907 let extra = repo.path("BepInEx/plugins/Author-Mod/Extra.dll");
2910
2911 std::fs::rename(
2912 &extra,
2913 repo.path("BepInEx/plugins/Author-Mod/Extra.dll.old"),
2914 )
2915 .unwrap();
2916
2917 let outcome = repo.install(&index, &[MOD], &[MOD], false);
2918
2919 assert_eq!(outcome.succeeded, names(&[MOD]));
2920 assert!(outcome.unchanged.is_empty());
2921 assert!(extra.exists());
2922 assert!(
2923 !repo
2924 .path("BepInEx/plugins/Author-Mod/Extra.dll.old")
2925 .exists()
2926 );
2927 assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll").exists());
2928 }
2929
2930 #[cfg(unix)]
2931 #[test]
2932 fn a_failed_loader_reinstall_keeps_it_uninstallable() {
2933 let mut repo = Repo::new();
2934 let v1 = repo.publish(&[Pkg {
2935 full_name: LOADER,
2936 version: "5.4.2333",
2937 deps: &[],
2938 files: &[
2939 ("BepInExPack_Valheim/winhttp.dll", b"proxy"),
2940 ("BepInExPack_Valheim/BepInEx/core/BepInEx.dll", b"core"),
2941 ],
2942 }]);
2943
2944 repo.install(&v1, &[LOADER], &[LOADER], false);
2945
2946 let v2 = repo.publish(&[Pkg {
2947 full_name: LOADER,
2948 version: "5.4.2334",
2949 deps: &[],
2950 files: &[
2951 ("BepInExPack_Valheim/winhttp.dll", b"proxy"),
2952 ("BepInExPack_Valheim/BepInEx/core/BepInEx.dll", b"core"),
2953 ("BepInExPack_Valheim/BepInEx/core/Extra.dll", b"extra"),
2954 ],
2955 }]);
2956
2957 std::os::unix::fs::symlink(
2960 repo.path("elsewhere.dll"),
2961 repo.path("BepInEx/core/Extra.dll"),
2962 )
2963 .unwrap();
2964
2965 let desired = names(&[LOADER]);
2966 let batch = plan_install_batch(repo.target.path(), &desired, &desired).unwrap();
2967 let outcome = repo
2968 .rt
2969 .block_on(install_batch(
2970 repo.target.path(),
2971 repo.cache_base.path(),
2972 &repo.eco,
2973 &v2,
2974 &repo.client,
2975 "valheim",
2976 &batch,
2977 2,
2978 ))
2979 .unwrap();
2980 let failed: Vec<&str> = outcome.failed.iter().map(|(n, _)| n.as_str()).collect();
2981
2982 assert_eq!(failed, vec![LOADER]);
2983 assert!(state_file_path(repo.target.path(), LOADER).exists());
2984
2985 uninstall_mod_in(repo.target.path(), &repo.eco, "valheim", LOADER).unwrap();
2986
2987 assert!(modlist::read(repo.target.path()).unwrap().is_empty());
2988 assert!(!state_file_path(repo.target.path(), LOADER).exists());
2989 }
2990
2991 fn mod_history<'a>() -> [Pkg<'a>; 2] {
2992 [
2993 Pkg {
2994 full_name: MOD,
2995 version: "1.0.0",
2996 deps: &[],
2997 files: &[("plugins/Mod.dll", b"v1")],
2998 },
2999 Pkg {
3000 full_name: MOD,
3001 version: "2.0.0",
3002 deps: &[],
3003 files: &[("plugins/Mod.dll", b"v2")],
3004 },
3005 ]
3006 }
3007
3008 #[test]
3009 fn a_pinned_install_lands_the_requested_version_not_the_latest() {
3010 let mut repo = Repo::new();
3011 let index = repo.publish(&mod_history());
3012 let dll = repo.path("BepInEx/plugins/Author-Mod/Mod.dll");
3013
3014 let outcome = repo.install(&index, &["Author-Mod-1.0.0"], &["Author-Mod-1.0.0"], false);
3015
3016 assert_eq!(outcome.succeeded, names(&[MOD]));
3017 assert_eq!(repo.entry(MOD).version_number.to_string(), "1.0.0");
3018 assert_eq!(std::fs::read(&dll).unwrap(), b"v1");
3019
3020 repo.install(&index, &[MOD], &[MOD], false);
3021
3022 assert_eq!(repo.entry(MOD).version_number.to_string(), "2.0.0");
3023 assert_eq!(std::fs::read(&dll).unwrap(), b"v2");
3024
3025 repo.install(&index, &["Author-Mod-1.0.0"], &["Author-Mod-1.0.0"], false);
3026
3027 assert_eq!(repo.entry(MOD).version_number.to_string(), "1.0.0");
3028 assert_eq!(std::fs::read(&dll).unwrap(), b"v1");
3029 }
3030
3031 #[test]
3032 fn a_pin_holds_for_another_item_in_the_batch_that_depends_on_it() {
3033 let mut repo = Repo::new();
3034 let index = repo.publish(&[
3035 Pkg {
3036 full_name: DEP,
3037 version: "1.0.0",
3038 deps: &[],
3039 files: &[("plugins/Dep.dll", b"dep1")],
3040 },
3041 Pkg {
3042 full_name: DEP,
3043 version: "2.0.0",
3044 deps: &[],
3045 files: &[("plugins/Dep.dll", b"dep2")],
3046 },
3047 Pkg {
3048 full_name: MOD,
3049 version: "1.0.0",
3050 deps: &["Author-Dep-2.0.0"],
3051 files: &[("plugins/Mod.dll", b"v1")],
3052 },
3053 ]);
3054
3055 let desired = ["Author-Dep-1.0.0", MOD];
3056
3057 repo.install(&index, &desired, &desired, false);
3058
3059 assert_eq!(repo.entry(DEP).version_number.to_string(), "1.0.0");
3060 assert_eq!(repo.entry(MOD).version_number.to_string(), "1.0.0");
3061 }
3062
3063 #[test]
3064 fn a_missing_pinned_version_fails_rather_than_installing_the_latest() {
3065 let mut repo = Repo::new();
3066 let index = repo.publish(&mod_history());
3067 let outcome = repo.try_install(&index, &["Author-Mod-9.9.9"], &["Author-Mod-9.9.9"], false);
3068
3069 assert!(outcome.succeeded.is_empty());
3070 assert_eq!(outcome.failed.len(), 1);
3071 assert_eq!(outcome.failed[0].0, "Author-Mod-9.9.9");
3072 assert!(
3073 outcome.failed[0]
3074 .1
3075 .to_string()
3076 .contains("Author-Mod has no version 9.9.9"),
3077 "{}",
3078 outcome.failed[0].1
3079 );
3080 assert!(
3081 modlist::read(repo.target.path())
3082 .unwrap_or_default()
3083 .is_empty()
3084 );
3085 }
3086
3087 #[test]
3088 fn naming_a_disabled_mod_with_a_version_enables_it() {
3089 let mut repo = Repo::new();
3090 let index = repo.publish(&mod_history());
3091
3092 repo.install(&index, &["Author-Mod-1.0.0"], &["Author-Mod-1.0.0"], false);
3093 set_enabled_in(repo.target.path(), &repo.eco, "valheim", MOD, false).unwrap();
3094
3095 assert!(!repo.entry(MOD).enabled);
3096
3097 let outcome = repo.install(&index, &["Author-Mod-1.0.0"], &["Author-Mod-1.0.0"], false);
3098
3099 assert_eq!(outcome.unchanged, names(&[MOD]));
3100 assert!(repo.entry(MOD).enabled);
3101 }
3102 }
3103}