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| explicit.contains(name));
216
217 Ok(InstallBatch {
218 desired: desired.to_vec(),
219 protect_disabled,
220 enable_requested,
221 force: false,
222 })
223}
224
225pub fn reapply_batch_disabled(
232 target_dir: &Path,
233 eco: &Ecosystem,
234 game: &str,
235 batch: &InstallBatch,
236) -> Result<()> {
237 for full_name in &batch.protect_disabled {
238 set_enabled_in(target_dir, eco, game, full_name, false)?;
239 }
240
241 Ok(())
242}
243
244#[allow(clippy::too_many_arguments)]
258pub fn install_extracted_in(
259 target_dir: &Path,
260 eco: &Ecosystem,
261 game: &str,
262 full_name: &str,
263 owner: &str,
264 version: &Version,
265 extracted_dir: &Path,
266 archive: Option<&Path>,
267 installed_at_time: u64,
268) -> Result<()> {
269 let game_profile = eco
270 .game(game)
271 .and_then(|g| g.profile())
272 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
273
274 let plan = plan_install(eco, game_profile, full_name, extracted_dir, archive)?;
275
276 clear_state_files(target_dir, full_name)?;
282 remove_mod_folders(game_profile, target_dir, full_name)?;
283 apply_install(&plan, target_dir)?;
284
285 match state_tracker_from_plan(&plan, full_name) {
286 Some(tracker) => write_state_file(target_dir, &tracker)?,
287 None => remove_state_file(target_dir, full_name)?,
288 }
289
290 let mut mods = modlist::read(target_dir)?;
291
292 modlist::upsert(
293 &mut mods,
294 mod_entry_from_version(full_name, owner, version, installed_at_time),
295 );
296
297 modlist::write(target_dir, &mods)
298}
299
300#[allow(clippy::too_many_arguments)]
303pub fn install_extracted(
304 base: &Path,
305 eco: &Ecosystem,
306 game: &str,
307 profile_name: &str,
308 full_name: &str,
309 owner: &str,
310 version: &Version,
311 extracted_dir: &Path,
312 archive: Option<&Path>,
313 installed_at_time: u64,
314) -> Result<()> {
315 install_extracted_in(
316 &layout::profile_dir(base, game, profile_name),
317 eco,
318 game,
319 full_name,
320 owner,
321 version,
322 extracted_dir,
323 archive,
324 installed_at_time,
325 )
326}
327
328#[derive(Debug)]
343pub enum NotRemovable {
344 RecordAbsent {
347 name: String,
349 path: PathBuf,
351 },
352 RecordUnreadable {
355 name: String,
357 path: PathBuf,
359 source: Error,
361 },
362}
363
364impl std::fmt::Display for NotRemovable {
365 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366 match self {
367 Self::RecordAbsent { name, path } => write!(
368 f,
369 "{name} is a mod loader with no install record at {}, so its files \
370 cannot be removed exactly. Reinstall it, then uninstall it again, or \
371 remove its files by hand.",
372 path.display()
373 ),
374 Self::RecordUnreadable { name, path, source } => write!(
375 f,
376 "{name} has an install record at {} that cannot be read, so its files \
377 cannot be removed exactly: {source}",
378 path.display()
379 ),
380 }
381 }
382}
383
384impl From<NotRemovable> for Error {
385 fn from(value: NotRemovable) -> Self {
386 Error::Profile(value.to_string())
387 }
388}
389
390pub fn ensure_removable(
423 target_dir: &Path,
424 eco: &Ecosystem,
425 full_name: &str,
426) -> std::result::Result<(), NotRemovable> {
427 match crate::install::read_state_file(target_dir, full_name) {
428 Ok(Some(_)) => return Ok(()),
429 Ok(None) => {}
430 Err(source) => {
431 return Err(NotRemovable::RecordUnreadable {
432 name: full_name.to_string(),
433 path: crate::install::state_file_path(target_dir, full_name),
434 source,
435 });
436 }
437 }
438
439 if eco.modloader_package(full_name).is_none() {
440 return Ok(());
441 }
442
443 Err(NotRemovable::RecordAbsent {
444 name: full_name.to_string(),
445 path: crate::install::state_file_path(target_dir, full_name),
446 })
447}
448
449pub fn uninstall_mod_in(
468 target_dir: &Path,
469 eco: &Ecosystem,
470 game: &str,
471 full_name: &str,
472) -> Result<()> {
473 let game_profile = eco
474 .game(game)
475 .and_then(|g| g.profile())
476 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
477
478 ensure_removable(target_dir, eco, full_name)?;
483
484 let mut mods = modlist::read(target_dir)?;
485
486 mods.retain(|m| m.name != full_name);
487
488 let keep: BTreeSet<String> = mods.iter().map(|m| m.name.clone()).collect();
489
490 remove_delisted(game_profile, target_dir, &keep)?;
491 remove_state_payload(target_dir, full_name)?;
492
493 modlist::write(target_dir, &mods)
494}
495
496pub fn uninstall_mod(
498 base: &Path,
499 eco: &Ecosystem,
500 game: &str,
501 profile_name: &str,
502 full_name: &str,
503) -> Result<()> {
504 uninstall_mod_in(
505 &layout::profile_dir(base, game, profile_name),
506 eco,
507 game,
508 full_name,
509 )
510}
511
512pub fn set_enabled_in(
520 target_dir: &Path,
521 eco: &Ecosystem,
522 game: &str,
523 full_name: &str,
524 enabled: bool,
525) -> Result<()> {
526 let game_profile = eco
527 .game(game)
528 .and_then(|g| g.profile())
529 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
530
531 let mut mods = modlist::read(target_dir)?;
532
533 let entry = mods
534 .iter_mut()
535 .find(|m| m.name == full_name)
536 .ok_or_else(|| Error::Profile(format!("{full_name} is not installed")))?;
537
538 if eco.modloader_package(full_name).is_some() {
539 return Ok(());
540 }
541
542 if enabled {
543 crate::install::enable_mod(game_profile, target_dir, full_name)?;
544 } else {
545 crate::install::disable_mod(game_profile, target_dir, full_name)?;
546 }
547
548 entry.enabled = enabled;
549
550 modlist::write(target_dir, &mods)
551}
552
553pub fn set_enabled(
556 base: &Path,
557 eco: &Ecosystem,
558 game: &str,
559 profile_name: &str,
560 full_name: &str,
561 enabled: bool,
562) -> Result<()> {
563 set_enabled_in(
564 &layout::profile_dir(base, game, profile_name),
565 eco,
566 game,
567 full_name,
568 enabled,
569 )
570}
571
572pub fn enable_mod_in(
574 target_dir: &Path,
575 eco: &Ecosystem,
576 game: &str,
577 full_name: &str,
578) -> Result<()> {
579 set_enabled_in(target_dir, eco, game, full_name, true)
580}
581
582pub fn disable_mod_in(
584 target_dir: &Path,
585 eco: &Ecosystem,
586 game: &str,
587 full_name: &str,
588) -> Result<()> {
589 set_enabled_in(target_dir, eco, game, full_name, false)
590}
591
592pub fn enable_mod(
594 base: &Path,
595 eco: &Ecosystem,
596 game: &str,
597 profile_name: &str,
598 full_name: &str,
599) -> Result<()> {
600 set_enabled(base, eco, game, profile_name, full_name, true)
601}
602
603pub fn disable_mod(
605 base: &Path,
606 eco: &Ecosystem,
607 game: &str,
608 profile_name: &str,
609 full_name: &str,
610) -> Result<()> {
611 set_enabled(base, eco, game, profile_name, full_name, false)
612}
613
614#[allow(clippy::too_many_arguments)]
631async fn install_one(
632 target_dir: &Path,
633 cache_base: &Path,
634 eco: &Ecosystem,
635 index: &PackageIndex,
636 client: &ThunderstoreClient,
637 game: &str,
638 full_name: &str,
639 force: bool,
640 installed_at_time: u64,
641) -> Result<(Vec<String>, Vec<String>)> {
642 let resolution = DependencyGraph::new(vec![full_name.to_string()]).resolve_report(index);
643
644 if !resolution.unresolved.is_empty() {
645 return Err(Error::Profile(format!(
646 "cannot install {full_name}: unresolved dependencies: {}",
647 resolution.unresolved.join(", ")
648 )));
649 }
650
651 let recorded = modlist::read(target_dir)?;
652 let forced = force.then(|| crate::util::full_name_prefix(full_name));
653 let downloads = client.cache_dir().join("downloads");
654 let mut urls = HashMap::new();
655 let mut unchanged = Vec::new();
656
657 for (filename, url) in resolution.urls {
658 let (pkg_full_name, version_str) = parse_package_filename(&filename)
659 .ok_or_else(|| Error::Profile(format!("unparseable package filename {filename:?}")))?;
660
661 let version = SemVer::parse(&version_str);
662 let current = forced != Some(pkg_full_name.as_str())
663 && recorded
664 .iter()
665 .find(|entry| entry.name == pkg_full_name && entry.version_number == version)
666 .is_some_and(|entry| {
667 recorded_files_present(
668 target_dir,
669 cache_base,
670 eco,
671 game,
672 entry,
673 &version_str,
674 &downloads.join(&filename),
675 )
676 });
677
678 if current {
679 unchanged.push(pkg_full_name);
680 } else {
681 urls.insert(filename, url);
682 }
683 }
684
685 client.download_files(urls.clone()).await?;
686
687 let mut installed = Vec::new();
688
689 for filename in urls.keys() {
690 let (pkg_full_name, version_str) = parse_package_filename(filename)
691 .ok_or_else(|| Error::Profile(format!("unparseable package filename {filename:?}")))?;
692
693 let archive = downloads.join(filename);
694
695 let extracted =
696 cache::ensure_extracted(cache_base, game, &pkg_full_name, &version_str, &archive)?;
697
698 let package = index
699 .get_package_by_full_name(&pkg_full_name)
700 .ok_or_else(|| Error::Profile(format!("{pkg_full_name} missing from index")))?;
701
702 let owner = package.owner.clone().unwrap_or_default();
703 let version = package
704 .versions
705 .iter()
706 .find(|v| v.version_number.as_deref() == Some(version_str.as_str()))
707 .or_else(|| package.latest_version())
708 .ok_or_else(|| Error::Profile(format!("{pkg_full_name} has no usable version")))?;
709
710 install_extracted_in(
711 target_dir,
712 eco,
713 game,
714 &pkg_full_name,
715 &owner,
716 version,
717 &extracted,
718 Some(&archive),
719 installed_at_time,
720 )?;
721
722 installed.push(pkg_full_name);
723 }
724
725 installed.sort();
726 unchanged.sort();
727
728 Ok((installed, unchanged))
729}
730
731fn recorded_files_present(
736 target_dir: &Path,
737 cache_base: &Path,
738 eco: &Ecosystem,
739 game: &str,
740 entry: &ProfileMod,
741 version: &str,
742 archive: &Path,
743) -> bool {
744 let extracted = cache::package_cache_dir(cache_base, game, &entry.name, version);
745
746 if !extracted.is_dir() {
747 return false;
748 }
749
750 let Some(game_profile) = eco.game(game).and_then(|g| g.profile()) else {
751 return false;
752 };
753
754 let archive = archive.exists().then_some(archive);
755
756 plan_install(eco, game_profile, &entry.name, &extracted, archive).is_ok_and(|plan| {
757 crate::install::plan_is_on_disk(&plan, target_dir, &entry.name, entry.enabled)
758 })
759}
760
761#[allow(clippy::too_many_arguments)]
770pub async fn install_batch(
771 target_dir: &Path,
772 cache_base: &Path,
773 eco: &Ecosystem,
774 index: &PackageIndex,
775 client: &ThunderstoreClient,
776 game: &str,
777 batch: &InstallBatch,
778 installed_at_time: u64,
779) -> Result<BatchOutcome> {
780 let mut outcome = BatchOutcome::default();
781
782 for full_name in &batch.desired {
783 match install_one(
784 target_dir,
785 cache_base,
786 eco,
787 index,
788 client,
789 game,
790 full_name,
791 batch.force,
792 installed_at_time,
793 )
794 .await
795 {
796 Ok((installed, unchanged)) => {
797 outcome.succeeded.extend(installed);
798 outcome.unchanged.extend(unchanged);
799 }
800 Err(error) => outcome.failed.push((full_name.clone(), error)),
801 }
802 }
803
804 reapply_batch_disabled(target_dir, eco, game, batch)?;
805
806 outcome.succeeded.sort();
807 outcome.succeeded.dedup();
808 outcome.unchanged.sort();
809 outcome.unchanged.dedup();
810 outcome
811 .unchanged
812 .retain(|name| outcome.succeeded.binary_search(name).is_err());
813
814 let recorded = modlist::read(target_dir)?;
817 let disabled: BTreeSet<&str> = recorded
818 .iter()
819 .filter(|entry| !entry.enabled)
820 .map(|entry| entry.name.as_str())
821 .collect();
822
823 for full_name in &batch.enable_requested {
824 if disabled.contains(full_name.as_str())
825 && outcome.unchanged.binary_search(full_name).is_ok()
826 && let Err(error) = set_enabled_in(target_dir, eco, game, full_name, true)
827 {
828 outcome.failed.push((full_name.clone(), error));
829 }
830 }
831
832 let failed: BTreeSet<&String> = outcome.failed.iter().map(|(name, _)| name).collect();
834
835 outcome.unchanged.retain(|name| !failed.contains(name));
836
837 Ok(outcome)
838}
839
840#[allow(clippy::too_many_arguments)]
899pub async fn install_mod_in(
900 target_dir: &Path,
901 cache_base: &Path,
902 eco: &Ecosystem,
903 index: &PackageIndex,
904 client: &ThunderstoreClient,
905 game: &str,
906 full_name: &str,
907 installed_at_time: u64,
908) -> Result<Vec<String>> {
909 install_named_in(
910 target_dir,
911 cache_base,
912 eco,
913 index,
914 client,
915 game,
916 full_name,
917 false,
918 installed_at_time,
919 )
920 .await
921}
922
923#[allow(clippy::too_many_arguments)]
928pub(crate) async fn install_named_in(
929 target_dir: &Path,
930 cache_base: &Path,
931 eco: &Ecosystem,
932 index: &PackageIndex,
933 client: &ThunderstoreClient,
934 game: &str,
935 full_name: &str,
936 force: bool,
937 installed_at_time: u64,
938) -> Result<Vec<String>> {
939 let named = [full_name.to_string()];
940 let mut batch = plan_install_batch(target_dir, &named, &named)?;
941
942 batch.force = force;
943
944 let outcome = install_batch(
945 target_dir,
946 cache_base,
947 eco,
948 index,
949 client,
950 game,
951 &batch,
952 installed_at_time,
953 )
954 .await?;
955
956 match outcome.failed.into_iter().next() {
957 Some((_, error)) => Err(error),
958 None => Ok(outcome.succeeded),
959 }
960}
961
962#[allow(clippy::too_many_arguments)]
967pub async fn install_mod(
968 base: &Path,
969 eco: &Ecosystem,
970 index: &PackageIndex,
971 client: &ThunderstoreClient,
972 game: &str,
973 profile_name: &str,
974 full_name: &str,
975 installed_at_time: u64,
976) -> Result<Vec<String>> {
977 install_mod_in(
978 &layout::profile_dir(base, game, profile_name),
979 base,
980 eco,
981 index,
982 client,
983 game,
984 full_name,
985 installed_at_time,
986 )
987 .await
988}
989
990#[derive(Debug, Clone, Default)]
998#[non_exhaustive]
999pub struct UninstallBatch {
1000 pub mods: Vec<String>,
1002 pub loaders: Vec<String>,
1004 pub untracked: Vec<PathBuf>,
1007}
1008
1009pub fn check_removable_batch(
1016 target_dir: &Path,
1017 eco: &Ecosystem,
1018 names: &[String],
1019) -> Vec<(String, NotRemovable)> {
1020 names
1021 .iter()
1022 .filter_map(|name| match ensure_removable(target_dir, eco, name) {
1023 Ok(()) => None,
1024 Err(refusal) => Some((name.clone(), refusal)),
1025 })
1026 .collect()
1027}
1028
1029pub fn plan_uninstall_batch(
1036 target_dir: &Path,
1037 eco: &Ecosystem,
1038 game: &str,
1039 names: Option<&[String]>,
1040) -> Result<UninstallBatch> {
1041 let game_profile = eco
1042 .game(game)
1043 .and_then(|g| g.profile())
1044 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
1045
1046 let recorded = modlist::read(target_dir)?;
1047
1048 let subjects: Vec<String> = match names {
1049 Some(names) => {
1050 for name in names {
1051 if modlist::find(&recorded, name).is_none() {
1052 return Err(Error::Profile(format!("{name} is not installed")));
1053 }
1054 }
1055
1056 names.to_vec()
1057 }
1058 None => recorded.iter().map(|entry| entry.name.clone()).collect(),
1059 };
1060
1061 if let Some((name, refusal)) = check_removable_batch(target_dir, eco, &subjects)
1062 .into_iter()
1063 .next()
1064 {
1065 return Err(Error::Profile(format!(
1066 "{name} cannot be removed: {refusal}"
1067 )));
1068 }
1069
1070 let mut mods: Vec<String> = subjects
1071 .iter()
1072 .filter(|name| eco.modloader_package(name).is_none())
1073 .cloned()
1074 .collect();
1075 let mut loaders: Vec<String> = subjects
1076 .iter()
1077 .filter(|name| eco.modloader_package(name).is_some())
1078 .cloned()
1079 .collect();
1080
1081 mods.sort();
1082 loaders.sort();
1083
1084 let keep: BTreeSet<String> = recorded.iter().map(|entry| entry.name.clone()).collect();
1085 let untracked = crate::install::plan_delisted(game_profile, target_dir, &keep)?;
1086
1087 Ok(UninstallBatch {
1088 mods,
1089 loaders,
1090 untracked,
1091 })
1092}
1093
1094pub fn uninstall_batch(
1100 target_dir: &Path,
1101 eco: &Ecosystem,
1102 game: &str,
1103 batch: &UninstallBatch,
1104) -> Result<BatchOutcome> {
1105 let mut outcome = BatchOutcome::default();
1106
1107 for full_name in batch.mods.iter().chain(batch.loaders.iter()) {
1108 match uninstall_mod_in(target_dir, eco, game, full_name) {
1109 Ok(()) => outcome.succeeded.push(full_name.clone()),
1110 Err(error) => outcome.failed.push((full_name.clone(), error)),
1111 }
1112 }
1113
1114 Ok(outcome)
1115}
1116
1117#[derive(Debug, Clone, Default)]
1124#[non_exhaustive]
1125pub struct ToggleBatch {
1126 pub changing: Vec<String>,
1128 pub already: Vec<String>,
1130 pub loaders: Vec<String>,
1132 pub enabled: bool,
1137}
1138
1139pub fn plan_set_enabled_batch(
1147 target_dir: &Path,
1148 eco: &Ecosystem,
1149 names: Option<&[String]>,
1150 enabled: bool,
1151) -> Result<ToggleBatch> {
1152 let recorded = modlist::read(target_dir)?;
1153
1154 if let Some(names) = names {
1155 for name in names {
1156 if modlist::find(&recorded, name).is_none() {
1157 return Err(Error::Profile(format!("{name} is not installed")));
1158 }
1159 }
1160 }
1161
1162 let mut batch = ToggleBatch {
1163 enabled,
1164 ..ToggleBatch::default()
1165 };
1166
1167 for entry in &recorded {
1168 if let Some(names) = names
1169 && !names.iter().any(|name| name == &entry.name)
1170 {
1171 continue;
1172 }
1173
1174 if eco.modloader_package(&entry.name).is_some() {
1175 batch.loaders.push(entry.name.clone());
1176 } else if entry.enabled == enabled {
1177 batch.already.push(entry.name.clone());
1178 } else {
1179 batch.changing.push(entry.name.clone());
1180 }
1181 }
1182
1183 batch.changing.sort();
1184 batch.already.sort();
1185 batch.loaders.sort();
1186
1187 Ok(batch)
1188}
1189
1190pub fn set_enabled_batch(
1197 target_dir: &Path,
1198 eco: &Ecosystem,
1199 game: &str,
1200 batch: &ToggleBatch,
1201) -> Result<BatchOutcome> {
1202 let mut outcome = BatchOutcome::default();
1203
1204 for full_name in &batch.changing {
1205 match set_enabled_in(target_dir, eco, game, full_name, batch.enabled) {
1206 Ok(()) => outcome.succeeded.push(full_name.clone()),
1207 Err(error) => outcome.failed.push((full_name.clone(), error)),
1208 }
1209 }
1210
1211 Ok(outcome)
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216 use super::*;
1217 use crate::ecosystem::Ecosystem;
1218 use crate::install::state_file_path;
1219 use crate::models::Version;
1220 use std::io::{Cursor, Write};
1221 use tempfile::tempdir;
1222 use time::OffsetDateTime;
1223 use zip::write::{FileOptions, ZipWriter};
1224
1225 fn write_file(path: &std::path::Path, bytes: &[u8]) {
1226 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1227 std::fs::File::create(path)
1228 .unwrap()
1229 .write_all(bytes)
1230 .unwrap();
1231 }
1232
1233 fn version(full_name: &str) -> Version {
1234 Version {
1235 name: Some("Mod".to_string()),
1236 full_name: Some(full_name.to_string()),
1237 description: Some("desc".to_string()),
1238 icon: Some("icon.png".to_string()),
1239 version_number: Some("1.2.3".to_string()),
1240 dependencies: vec!["Other-Dep-1.0.0".to_string()],
1241 download_url: Some("https://example.com/dl".to_string()),
1242 downloads: Some(1),
1243 date_created: OffsetDateTime::UNIX_EPOCH,
1244 website_url: Some("https://example.com".to_string()),
1245 is_active: Some(true),
1246 uuid4: Some("u".to_string()),
1247 file_size: Some(1),
1248 }
1249 }
1250
1251 #[test]
1252 fn mod_entry_maps_version_with_r2modman_defaults() {
1253 let entry = mod_entry_from_version("Author-Mod", "Author", &version("Author-Mod"), 123);
1254
1255 assert_eq!(entry.name, "Author-Mod");
1256 assert_eq!(entry.author_name, "Author");
1257 assert_eq!(entry.network_mode, "both");
1258 assert_eq!(entry.package_type, "other");
1259 assert_eq!(entry.install_mode, "managed");
1260 assert_eq!(entry.game_version, "0");
1261 assert_eq!(entry.manifest_version, 1);
1262 assert_eq!(
1263 entry.version_number,
1264 crate::profile::modlist::SemVer {
1265 major: 1,
1266 minor: 2,
1267 patch: 3
1268 }
1269 );
1270 assert_eq!(entry.dependencies, vec!["Other-Dep-1.0.0".to_string()]);
1271 assert_eq!(entry.installed_at_time, 123);
1272 assert!(entry.enabled);
1273 }
1274
1275 #[test]
1276 fn install_extracted_places_files_and_records_mods_yml() {
1277 let eco = Ecosystem::bundled();
1278
1279 let dir = tempdir().unwrap();
1280 let base = dir.path();
1281
1282 let extracted = tempdir().unwrap();
1283 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1284 write_file(&extracted.path().join("manifest.json"), b"{}");
1285
1286 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1287
1288 install_extracted(
1289 base,
1290 &eco,
1291 "valheim",
1292 "Main",
1293 "Author-Mod",
1294 "Author",
1295 &version("Author-Mod"),
1296 extracted.path(),
1297 None,
1298 123,
1299 )
1300 .unwrap();
1301
1302 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1303
1304 assert!(
1305 profile_dir
1306 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1307 .exists()
1308 );
1309
1310 let mods = crate::profile::modlist::read(&profile_dir).unwrap();
1311
1312 assert_eq!(mods.len(), 1);
1313 assert_eq!(mods[0].name, "Author-Mod");
1314 assert!(mods[0].enabled);
1315 }
1316
1317 #[test]
1318 fn uninstall_removes_files_and_mods_yml_entry() {
1319 let eco = Ecosystem::bundled();
1320
1321 let dir = tempdir().unwrap();
1322 let base = dir.path();
1323
1324 let extracted = tempdir().unwrap();
1325 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1326
1327 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1328
1329 install_extracted(
1330 base,
1331 &eco,
1332 "valheim",
1333 "Main",
1334 "Author-Mod",
1335 "Author",
1336 &version("Author-Mod"),
1337 extracted.path(),
1338 None,
1339 1,
1340 )
1341 .unwrap();
1342
1343 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1344
1345 assert!(
1346 profile_dir
1347 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1348 .exists()
1349 );
1350
1351 uninstall_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap();
1352
1353 assert!(!profile_dir.join("BepInEx/plugins/Author-Mod").exists());
1354 assert_eq!(
1355 crate::profile::modlist::read(&profile_dir).unwrap().len(),
1356 0
1357 );
1358 }
1359
1360 #[test]
1361 fn uninstall_refuses_a_mod_whose_install_record_cannot_be_read() {
1362 let eco = Ecosystem::bundled();
1363
1364 let dir = tempdir().unwrap();
1365 let base = dir.path();
1366
1367 let extracted = tempdir().unwrap();
1368 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1369
1370 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1371 install_extracted(
1372 base,
1373 &eco,
1374 "valheim",
1375 "Main",
1376 "Author-Mod",
1377 "Author",
1378 &version("Author-Mod"),
1379 extracted.path(),
1380 None,
1381 1,
1382 )
1383 .unwrap();
1384
1385 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1386
1387 write_file(&state_file_path(&profile_dir, "Author-Mod"), b"not: [valid");
1391
1392 let error = uninstall_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap_err();
1393
1394 assert!(
1399 profile_dir
1400 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1401 .exists(),
1402 "the mod's files must survive a refusal; got error: {error}"
1403 );
1404 assert_eq!(
1405 crate::profile::modlist::read(&profile_dir).unwrap().len(),
1406 1
1407 );
1408 }
1409
1410 #[test]
1411 fn disable_then_enable_updates_disk_and_mods_yml() {
1412 let eco = Ecosystem::bundled();
1413
1414 let dir = tempdir().unwrap();
1415 let base = dir.path();
1416
1417 let extracted = tempdir().unwrap();
1418 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1419
1420 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1421 install_extracted(
1422 base,
1423 &eco,
1424 "valheim",
1425 "Main",
1426 "Author-Mod",
1427 "Author",
1428 &version("Author-Mod"),
1429 extracted.path(),
1430 None,
1431 1,
1432 )
1433 .unwrap();
1434
1435 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1436
1437 disable_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap();
1438
1439 assert!(
1440 profile_dir
1441 .join("BepInEx/plugins/Author-Mod/Mod.dll.old")
1442 .exists()
1443 );
1444 assert!(!crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1445
1446 enable_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap();
1447
1448 assert!(
1449 profile_dir
1450 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1451 .exists()
1452 );
1453 assert!(crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1454 }
1455
1456 #[test]
1457 fn disable_leaves_files_alone_when_the_install_record_cannot_be_read() {
1458 let eco = Ecosystem::bundled();
1459
1460 let dir = tempdir().unwrap();
1461 let base = dir.path();
1462
1463 let extracted = tempdir().unwrap();
1464 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1465
1466 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1467 install_extracted(
1468 base,
1469 &eco,
1470 "valheim",
1471 "Main",
1472 "Author-Mod",
1473 "Author",
1474 &version("Author-Mod"),
1475 extracted.path(),
1476 None,
1477 1,
1478 )
1479 .unwrap();
1480
1481 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1482
1483 write_file(&state_file_path(&profile_dir, "Author-Mod"), b"not: [valid");
1484
1485 let error = disable_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap_err();
1486
1487 assert!(
1492 profile_dir
1493 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1494 .exists(),
1495 "the mod's files must be left as they were; got error: {error}"
1496 );
1497 assert!(
1498 !profile_dir
1499 .join("BepInEx/plugins/Author-Mod/Mod.dll.old")
1500 .exists()
1501 );
1502 assert!(crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1503 }
1504
1505 #[test]
1506 fn disable_mod_is_noop_for_modloader_pack() {
1507 let eco = Ecosystem::bundled();
1508
1509 let dir = tempdir().unwrap();
1510 let base = dir.path();
1511 let full_name = "denikson-BepInExPack_Valheim";
1512
1513 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1514
1515 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1516 let loader_file = profile_dir.join("winhttp.dll");
1517
1518 write_file(&loader_file, b"dll");
1519
1520 let mut mods = Vec::new();
1521
1522 modlist::upsert(
1523 &mut mods,
1524 mod_entry_from_version(full_name, "denikson", &version(full_name), 1),
1525 );
1526 modlist::write(&profile_dir, &mods).unwrap();
1527
1528 let tracker = crate::install::ModFileTracker {
1529 mod_name: full_name.to_string(),
1530 files: vec![("unused-cache-source".to_string(), "winhttp.dll".to_string())],
1531 };
1532
1533 write_state_file(&profile_dir, &tracker).unwrap();
1534
1535 disable_mod(base, &eco, "valheim", "Main", full_name).unwrap();
1536
1537 assert!(loader_file.exists());
1538 assert!(crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1539 }
1540
1541 #[test]
1542 fn install_mod_fails_on_unresolved_dependency() {
1543 let eco = Ecosystem::bundled();
1544
1545 let package = crate::models::Package {
1546 name: Some("Mod".to_string()),
1547 full_name: Some("Author-Mod".to_string()),
1548 owner: Some("Author".to_string()),
1549 package_url: Some("https://example.com/Author-Mod".to_string()),
1550 date_created: OffsetDateTime::UNIX_EPOCH,
1551 date_updated: OffsetDateTime::UNIX_EPOCH,
1552 uuid4: Some("u".to_string()),
1553 rating_score: Some(0),
1554 is_pinned: Some(false),
1555 is_deprecated: Some(false),
1556 has_nsfw_content: Some(false),
1557 categories: vec![],
1558 versions: vec![Version {
1559 dependencies: vec!["Missing-Dep-1.0.0".to_string()],
1560 ..version("Author-Mod")
1561 }],
1562 };
1563
1564 let index: PackageIndex = vec![package].into();
1565
1566 let dir = tempdir().unwrap();
1567 let base = dir.path();
1568 let cache_dir = tempdir().unwrap();
1569
1570 let client = ThunderstoreClient::builder()
1571 .package_index_url("http://localhost/pkg/")
1572 .cache_dir(cache_dir.path())
1573 .build()
1574 .unwrap();
1575
1576 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1577
1578 let rt = tokio::runtime::Runtime::new().unwrap();
1579 let result = rt.block_on(install_mod(
1580 base,
1581 &eco,
1582 &index,
1583 &client,
1584 "valheim",
1585 "Main",
1586 "Author-Mod",
1587 1,
1588 ));
1589
1590 let err = result.unwrap_err().to_string();
1591
1592 assert!(err.contains("Missing-Dep"), "unexpected error: {err}");
1593 }
1594
1595 #[test]
1596 fn server_mode_lifecycle_against_bare_target_dir() {
1597 let eco = Ecosystem::bundled();
1598
1599 let dir = tempdir().unwrap();
1600 let target_dir = dir.path();
1601
1602 let extracted = tempdir().unwrap();
1603 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1604
1605 install_extracted_in(
1606 target_dir,
1607 &eco,
1608 "valheim",
1609 "Author-Mod",
1610 "Author",
1611 &version("Author-Mod"),
1612 extracted.path(),
1613 None,
1614 123,
1615 )
1616 .unwrap();
1617
1618 assert!(
1619 target_dir
1620 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1621 .exists()
1622 );
1623 assert!(target_dir.join("mods.yml").exists());
1624
1625 let mods = modlist::read(target_dir).unwrap();
1626
1627 assert_eq!(mods.len(), 1);
1628 assert_eq!(mods[0].name, "Author-Mod");
1629 assert!(mods[0].enabled);
1630
1631 let state_source = extracted.path().join("state-source.txt");
1638
1639 write_file(&state_source, b"state-data");
1640 write_file(&target_dir.join("state-dest.txt"), b"state-data");
1641
1642 let tracker = crate::install::ModFileTracker {
1643 mod_name: "Author-Mod".to_string(),
1644 files: vec![(
1645 state_source.to_string_lossy().to_string(),
1646 "state-dest.txt".to_string(),
1647 )],
1648 };
1649
1650 write_state_file(target_dir, &tracker).unwrap();
1651
1652 assert!(state_file_path(target_dir, "Author-Mod").exists());
1653
1654 disable_mod_in(target_dir, &eco, "valheim", "Author-Mod").unwrap();
1655
1656 assert!(
1657 target_dir
1658 .join("BepInEx/plugins/Author-Mod/Mod.dll.old")
1659 .exists()
1660 );
1661 assert!(!target_dir.join("state-dest.txt").exists());
1662 assert!(!modlist::read(target_dir).unwrap()[0].enabled);
1663
1664 enable_mod_in(target_dir, &eco, "valheim", "Author-Mod").unwrap();
1665
1666 assert!(
1667 target_dir
1668 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1669 .exists()
1670 );
1671 assert!(target_dir.join("state-dest.txt").exists());
1672 assert!(modlist::read(target_dir).unwrap()[0].enabled);
1673
1674 uninstall_mod_in(target_dir, &eco, "valheim", "Author-Mod").unwrap();
1675
1676 assert!(!target_dir.join("BepInEx/plugins/Author-Mod").exists());
1677 assert!(!target_dir.join("state-dest.txt").exists());
1681 assert!(!state_file_path(target_dir, "Author-Mod").exists());
1682 assert_eq!(modlist::read(target_dir).unwrap().len(), 0);
1683 }
1684
1685 fn build_mod_zip() -> Vec<u8> {
1686 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1687 let opts: FileOptions<'_, ()> =
1688 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
1689
1690 for (name, body) in [
1691 ("manifest.json", &b"{\"version_number\":\"1.0.0\"}"[..]),
1692 ("plugins/CoolMod.dll", &b"dll-bytes"[..]),
1693 ] {
1694 zip.start_file(name, opts).unwrap();
1695 zip.write_all(body).unwrap();
1696 }
1697
1698 zip.finish().unwrap().into_inner()
1699 }
1700
1701 fn index_json(server_url: &str) -> String {
1702 format!(
1703 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}}]}}]"#
1704 )
1705 }
1706
1707 #[test]
1708 fn install_mod_in_extracts_into_cache_base_not_target_dir() {
1709 let mut server = mockito::Server::new();
1710 let zip_bytes = build_mod_zip();
1711
1712 let dl = server
1713 .mock("GET", "/dl/mod.zip")
1714 .with_status(200)
1715 .with_header("Content-Type", "application/zip")
1716 .with_body(zip_bytes)
1717 .create();
1718 let idx = server
1719 .mock("GET", "/pkg/")
1720 .with_status(200)
1721 .with_header("Content-Type", "application/json")
1722 .with_header("Last-Modified", "Wed, 21 Feb 2024 15:30:45 GMT")
1723 .with_body(index_json(&server.url()))
1724 .create();
1725
1726 let http_cache = tempdir().unwrap();
1727 let cache_base = tempdir().unwrap();
1728 let target = tempdir().unwrap();
1729
1730 let client = ThunderstoreClient::builder()
1731 .package_index_url(format!("{}/pkg/", server.url()))
1732 .cache_dir(http_cache.path())
1733 .build()
1734 .unwrap();
1735
1736 let rt = tokio::runtime::Runtime::new().unwrap();
1737 let eco = Ecosystem::bundled();
1738 let index = rt.block_on(client.get_manifest()).unwrap();
1739
1740 let installed = rt
1741 .block_on(install_mod_in(
1742 target.path(),
1743 cache_base.path(),
1744 &eco,
1745 &index,
1746 &client,
1747 "valheim",
1748 "Author-CoolMod",
1749 123,
1750 ))
1751 .unwrap();
1752
1753 assert_eq!(installed, vec!["Author-CoolMod".to_string()]);
1754
1755 assert!(
1756 target
1757 .path()
1758 .join("BepInEx/plugins/Author-CoolMod/CoolMod.dll")
1759 .exists()
1760 );
1761
1762 let cached = cache::package_cache_dir(cache_base.path(), "valheim", "Author-CoolMod", "1.0.0");
1763
1764 assert!(cached.join("plugins/CoolMod.dll").exists());
1765 assert!(!target.path().join("cache").exists());
1766
1767 dl.assert();
1768 idx.assert();
1769 }
1770
1771 fn profile_mod(name: &str) -> ProfileMod {
1775 ProfileMod {
1776 manifest_version: 1,
1777 name: name.to_string(),
1778 author_name: String::new(),
1779 website_url: String::new(),
1780 display_name: name.to_string(),
1781 description: String::new(),
1782 game_version: "0".to_string(),
1783 network_mode: "both".to_string(),
1784 package_type: "other".to_string(),
1785 install_mode: "managed".to_string(),
1786 installed_at_time: 1,
1787 loaders: Vec::new(),
1788 dependencies: Vec::new(),
1789 incompatibilities: Vec::new(),
1790 optional_dependencies: Vec::new(),
1791 version_number: SemVer::parse("1.0.0"),
1792 enabled: true,
1793 icon: String::new(),
1794 online_source: false,
1795 trusted_package: false,
1796 }
1797 }
1798
1799 #[test]
1800 fn uninstalling_a_loader_pack_without_a_state_file_is_refused() {
1801 let dir = tempdir().unwrap();
1802 let target_dir = dir.path();
1803 let eco = crate::ecosystem::Ecosystem::bundled();
1804
1805 modlist::write(target_dir, &[profile_mod("denikson-BepInExPack_Valheim")]).unwrap();
1809
1810 let result = uninstall_mod_in(target_dir, &eco, "valheim", "denikson-BepInExPack_Valheim");
1811
1812 assert!(result.is_err());
1813
1814 let message = result.unwrap_err().to_string();
1815
1816 assert!(
1817 message.contains("denikson-BepInExPack_Valheim"),
1818 "the error must name the loader; got: {message}"
1819 );
1820 assert_eq!(modlist::read(target_dir).unwrap().len(), 1);
1823 }
1824
1825 #[test]
1826 fn uninstalling_a_regular_mod_without_a_state_file_still_works() {
1827 let dir = tempdir().unwrap();
1828 let target_dir = dir.path();
1829 let eco = crate::ecosystem::Ecosystem::bundled();
1830
1831 modlist::write(target_dir, &[profile_mod("Owner-Regular")]).unwrap();
1834
1835 uninstall_mod_in(target_dir, &eco, "valheim", "Owner-Regular").unwrap();
1836
1837 assert!(modlist::read(target_dir).unwrap().is_empty());
1838 }
1839
1840 #[test]
1841 fn check_removable_batch_reports_every_unremovable_name_with_its_reason() {
1842 let dir = tempdir().unwrap();
1843 let target_dir = dir.path();
1844 let eco = crate::ecosystem::Ecosystem::bundled();
1845
1846 modlist::write(
1847 target_dir,
1848 &[
1849 profile_mod("denikson-BepInExPack_Valheim"),
1850 profile_mod("Owner-Regular"),
1851 ],
1852 )
1853 .unwrap();
1854
1855 let state_path = crate::install::state_file_path(target_dir, "Owner-Regular");
1862
1863 std::fs::create_dir_all(state_path.parent().unwrap()).unwrap();
1864 std::fs::write(&state_path, "not: [valid").unwrap();
1865
1866 let names = vec![
1867 "denikson-BepInExPack_Valheim".to_string(),
1868 "Owner-Regular".to_string(),
1869 ];
1870
1871 let blocked = check_removable_batch(target_dir, &eco, &names);
1872
1873 assert_eq!(
1874 blocked.len(),
1875 2,
1876 "both un-removable names must be reported, not just the first; got {blocked:?}"
1877 );
1878
1879 let loader_refusal = blocked
1880 .iter()
1881 .find(|(name, _)| name == "denikson-BepInExPack_Valheim")
1882 .map(|(_, refusal)| refusal)
1883 .unwrap();
1884 let loader_path = state_file_path(target_dir, "denikson-BepInExPack_Valheim");
1885
1886 assert!(
1889 matches!(loader_refusal, NotRemovable::RecordAbsent { path, .. } if path == &loader_path),
1890 "expected an absent record carrying its expected path; got: {loader_refusal:?}"
1891 );
1892
1893 let regular_refusal = blocked
1894 .iter()
1895 .find(|(name, _)| name == "Owner-Regular")
1896 .map(|(_, refusal)| refusal)
1897 .unwrap();
1898
1899 assert!(
1900 matches!(regular_refusal, NotRemovable::RecordUnreadable { path, .. } if path == &state_path),
1901 "expected an unreadable record carrying the path it could not read; got: {regular_refusal:?}"
1902 );
1903 }
1904
1905 #[test]
1906 fn a_removability_refusal_keeps_todays_wording_when_it_becomes_an_error() {
1907 let dir = tempdir().unwrap();
1908 let target_dir = dir.path();
1909 let eco = crate::ecosystem::Ecosystem::bundled();
1910
1911 modlist::write(target_dir, &[profile_mod("denikson-BepInExPack_Valheim")]).unwrap();
1912
1913 let refusal = ensure_removable(target_dir, &eco, "denikson-BepInExPack_Valheim").unwrap_err();
1914 let path = state_file_path(target_dir, "denikson-BepInExPack_Valheim");
1915
1916 assert_eq!(
1919 Error::from(refusal).to_string(),
1920 format!(
1921 "Profile error: denikson-BepInExPack_Valheim is a mod loader with no \
1922 install record at {}, so its files cannot be removed exactly. Reinstall \
1923 it, then uninstall it again, or remove its files by hand.",
1924 path.display()
1925 )
1926 );
1927 }
1928
1929 #[test]
1930 fn a_reinstall_the_caller_did_not_name_keeps_its_disabled_state() {
1931 let eco = Ecosystem::bundled();
1932 let dir = tempdir().unwrap();
1933 let base = dir.path();
1934
1935 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1936
1937 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1938 let extracted = tempdir().unwrap();
1939
1940 write_file(&extracted.path().join("plugins/Base.dll"), b"dll");
1941
1942 install_extracted(
1943 base,
1944 &eco,
1945 "valheim",
1946 "Main",
1947 "Author-Base",
1948 "Author",
1949 &version("Author-Base"),
1950 extracted.path(),
1951 None,
1952 1,
1953 )
1954 .unwrap();
1955
1956 disable_mod(base, &eco, "valheim", "Main", "Author-Base").unwrap();
1957
1958 assert!(!modlist::read(&profile_dir).unwrap()[0].enabled);
1959 assert!(
1960 profile_dir
1961 .join("BepInEx/plugins/Author-Base/Base.dll.old")
1962 .exists(),
1963 "the disable must have suffixed the file, or this test proves nothing"
1964 );
1965
1966 let batch = plan_install_batch(&profile_dir, &["Author-Base".to_string()], &[]).unwrap();
1969
1970 assert_eq!(batch.protect_disabled, vec!["Author-Base".to_string()]);
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 2,
1983 )
1984 .unwrap();
1985
1986 reapply_batch_disabled(&profile_dir, &eco, "valheim", &batch).unwrap();
1987
1988 let mods = modlist::read(&profile_dir).unwrap();
1989
1990 assert!(
1991 !mods[0].enabled,
1992 "a disabled dependency must not be re-enabled by an incidental reinstall"
1993 );
1994 assert!(
1995 profile_dir
1996 .join("BepInEx/plugins/Author-Base/Base.dll.old")
1997 .exists(),
1998 "the file must be left suffixed, not rewritten un-suffixed and orphaned"
1999 );
2000 }
2001
2002 #[test]
2003 fn planning_an_uninstall_batch_partitions_loaders_and_lists_untracked() {
2004 let eco = Ecosystem::bundled();
2005 let dir = tempdir().unwrap();
2006 let base = dir.path();
2007
2008 crate::profile::layout::create(base, "valheim", "Main").unwrap();
2009
2010 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2011 let extracted = tempdir().unwrap();
2012
2013 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2014 install_extracted(
2015 base,
2016 &eco,
2017 "valheim",
2018 "Main",
2019 "Author-Mod",
2020 "Author",
2021 &version("Author-Mod"),
2022 extracted.path(),
2023 None,
2024 1,
2025 )
2026 .unwrap();
2027
2028 write_file(
2030 &profile_dir.join("BepInEx/plugins/Other-ByHand/x.dll"),
2031 b"dll",
2032 );
2033 write_file(&profile_dir.join("BepInEx/plugins/MMHOOK/h.dll"), b"dll");
2035
2036 let batch = plan_uninstall_batch(&profile_dir, &eco, "valheim", None).unwrap();
2037
2038 assert_eq!(batch.mods, vec!["Author-Mod".to_string()]);
2039 assert!(batch.loaders.is_empty());
2040 assert_eq!(
2041 batch.untracked,
2042 vec![std::path::PathBuf::from("BepInEx/plugins/Other-ByHand")]
2043 );
2044
2045 let outcome = uninstall_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2046
2047 assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2048 assert_eq!(outcome.succeeded, vec!["Author-Mod".to_string()]);
2049 assert!(modlist::read(&profile_dir).unwrap().is_empty());
2050 assert!(profile_dir.join("BepInEx/plugins/MMHOOK/h.dll").exists());
2051 }
2052
2053 #[test]
2054 fn uninstalling_a_batch_removes_the_loader_after_ordinary_mods_and_its_own_files() {
2055 let eco = Ecosystem::bundled();
2056 let dir = tempdir().unwrap();
2057 let base = dir.path();
2058 let loader = "denikson-BepInExPack_Valheim";
2059
2060 crate::profile::layout::create(base, "valheim", "Main").unwrap();
2061
2062 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2063 let extracted = tempdir().unwrap();
2064
2065 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2066 install_extracted(
2067 base,
2068 &eco,
2069 "valheim",
2070 "Main",
2071 "Author-Mod",
2072 "Author",
2073 &version("Author-Mod"),
2074 extracted.path(),
2075 None,
2076 1,
2077 )
2078 .unwrap();
2079
2080 let winhttp = profile_dir.join("winhttp.dll");
2086 let core_dll = profile_dir.join("BepInEx/core/BepInEx.dll");
2087
2088 write_file(&winhttp, b"proxy");
2089 write_file(&core_dll, b"core");
2090
2091 let mut mods = modlist::read(&profile_dir).unwrap();
2092
2093 modlist::upsert(
2094 &mut mods,
2095 mod_entry_from_version(loader, "denikson", &version(loader), 1),
2096 );
2097 modlist::write(&profile_dir, &mods).unwrap();
2098
2099 let tracker = crate::install::ModFileTracker {
2100 mod_name: loader.to_string(),
2101 files: vec![
2102 (
2103 "unused-cache-source-1".to_string(),
2104 "winhttp.dll".to_string(),
2105 ),
2106 (
2107 "unused-cache-source-2".to_string(),
2108 "BepInEx/core/BepInEx.dll".to_string(),
2109 ),
2110 ],
2111 };
2112
2113 write_state_file(&profile_dir, &tracker).unwrap();
2114
2115 let batch = plan_uninstall_batch(&profile_dir, &eco, "valheim", None).unwrap();
2116
2117 assert_eq!(batch.mods, vec!["Author-Mod".to_string()]);
2118 assert_eq!(batch.loaders, vec![loader.to_string()]);
2119
2120 let outcome = uninstall_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2121
2122 assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2123 assert_eq!(
2131 outcome.succeeded,
2132 vec!["Author-Mod".to_string(), loader.to_string()]
2133 );
2134 assert!(modlist::read(&profile_dir).unwrap().is_empty());
2135 assert!(!winhttp.exists());
2138 assert!(!core_dll.exists());
2139 }
2140
2141 #[test]
2142 fn planning_an_uninstall_batch_sorts_and_removes_two_recorded_loaders() {
2143 let eco = Ecosystem::bundled();
2144 let dir = tempdir().unwrap();
2145 let base = dir.path();
2146 let first_loader = "denikson-BepInExPack_Valheim";
2147 let second_loader = "1F31A-BepInEx_Valheim_Full";
2153
2154 crate::profile::layout::create(base, "valheim", "Main").unwrap();
2155
2156 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2157
2158 let mut mods = Vec::new();
2159
2160 for loader in [first_loader, second_loader] {
2161 modlist::upsert(
2162 &mut mods,
2163 mod_entry_from_version(loader, "owner", &version(loader), 1),
2164 );
2165
2166 write_state_file(
2170 &profile_dir,
2171 &crate::install::ModFileTracker {
2172 mod_name: loader.to_string(),
2173 files: Vec::new(),
2174 },
2175 )
2176 .unwrap();
2177 }
2178
2179 modlist::write(&profile_dir, &mods).unwrap();
2180
2181 let batch = plan_uninstall_batch(&profile_dir, &eco, "valheim", None).unwrap();
2182
2183 assert!(batch.mods.is_empty());
2184 assert_eq!(
2186 batch.loaders,
2187 vec![second_loader.to_string(), first_loader.to_string()]
2188 );
2189
2190 let outcome = uninstall_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2191
2192 assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2193 assert!(modlist::read(&profile_dir).unwrap().is_empty());
2194 }
2195
2196 #[test]
2197 fn planning_a_toggle_batch_splits_changing_from_already_and_excludes_loaders() {
2198 let eco = Ecosystem::bundled();
2199 let dir = tempdir().unwrap();
2200 let base = dir.path();
2201 let loader = "denikson-BepInExPack_Valheim";
2202
2203 crate::profile::layout::create(base, "valheim", "Main").unwrap();
2204
2205 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2206 let extracted = tempdir().unwrap();
2207
2208 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2209
2210 for name in ["Author-One", "Author-Two"] {
2211 install_extracted(
2212 base,
2213 &eco,
2214 "valheim",
2215 "Main",
2216 name,
2217 "Author",
2218 &version(name),
2219 extracted.path(),
2220 None,
2221 1,
2222 )
2223 .unwrap();
2224 }
2225
2226 let mut mods = modlist::read(&profile_dir).unwrap();
2228
2229 mods.push(mod_entry_from_version(
2230 loader,
2231 "denikson",
2232 &version(loader),
2233 1,
2234 ));
2235 modlist::write(&profile_dir, &mods).unwrap();
2236
2237 disable_mod(base, &eco, "valheim", "Main", "Author-One").unwrap();
2238
2239 let batch = plan_set_enabled_batch(&profile_dir, &eco, None, false).unwrap();
2240
2241 assert_eq!(batch.changing, vec!["Author-Two".to_string()]);
2242 assert_eq!(batch.already, vec!["Author-One".to_string()]);
2243 assert_eq!(batch.loaders, vec![loader.to_string()]);
2244
2245 let outcome = set_enabled_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2246
2247 assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2248 assert_eq!(outcome.succeeded, vec!["Author-Two".to_string()]);
2249
2250 let after = modlist::read(&profile_dir).unwrap();
2251
2252 assert!(
2253 !after
2254 .iter()
2255 .find(|m| m.name == "Author-Two")
2256 .unwrap()
2257 .enabled
2258 );
2259 assert!(after.iter().find(|m| m.name == loader).unwrap().enabled);
2261 }
2262
2263 #[test]
2264 fn plan_set_enabled_batch_refuses_an_unrecorded_name() {
2265 let eco = Ecosystem::bundled();
2266 let dir = tempdir().unwrap();
2267 let base = dir.path();
2268
2269 crate::profile::layout::create(base, "valheim", "Main").unwrap();
2270
2271 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2272 let extracted = tempdir().unwrap();
2273
2274 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2275
2276 install_extracted(
2277 base,
2278 &eco,
2279 "valheim",
2280 "Main",
2281 "Author-One",
2282 "Author",
2283 &version("Author-One"),
2284 extracted.path(),
2285 None,
2286 1,
2287 )
2288 .unwrap();
2289
2290 let names = vec!["Author-Gone".to_string()];
2291 let error = plan_set_enabled_batch(&profile_dir, &eco, Some(&names), false).unwrap_err();
2292
2293 assert!(error.to_string().contains("Author-Gone"));
2294 assert!(error.to_string().contains("is not installed"));
2295 }
2296
2297 mod reinstall {
2301 use super::*;
2302 use crate::models::Package;
2303
2304 const MOD: &str = "Author-Mod";
2305 const DEP: &str = "Author-Dep";
2306 const LOADER: &str = "denikson-BepInExPack_Valheim";
2307
2308 struct Pkg<'a> {
2310 full_name: &'a str,
2311 version: &'a str,
2312 deps: &'a [&'a str],
2313 files: &'a [(&'a str, &'a [u8])],
2314 }
2315
2316 struct Repo {
2317 server: mockito::ServerGuard,
2318 mocks: Vec<mockito::Mock>,
2319 client: ThunderstoreClient,
2320 _http_cache: tempfile::TempDir,
2321 cache_base: tempfile::TempDir,
2322 target: tempfile::TempDir,
2323 eco: Ecosystem,
2324 rt: tokio::runtime::Runtime,
2325 clock: u64,
2326 }
2327
2328 impl Repo {
2329 fn new() -> Self {
2330 let server = mockito::Server::new();
2331 let http_cache = tempdir().unwrap();
2332 let client = ThunderstoreClient::builder()
2333 .package_index_url(format!("{}/pkg/", server.url()))
2334 .cache_dir(http_cache.path())
2335 .build()
2336 .unwrap();
2337
2338 Self {
2339 server,
2340 mocks: Vec::new(),
2341 client,
2342 _http_cache: http_cache,
2343 cache_base: tempdir().unwrap(),
2344 target: tempdir().unwrap(),
2345 eco: Ecosystem::bundled(),
2346 rt: tokio::runtime::Runtime::new().unwrap(),
2347 clock: 0,
2348 }
2349 }
2350
2351 fn publish(&mut self, pkgs: &[Pkg<'_>]) -> PackageIndex {
2354 let mut packages = Vec::new();
2355
2356 for pkg in pkgs {
2357 let path = format!("/dl/{}-{}.zip", pkg.full_name, pkg.version);
2358 let mock = self
2359 .server
2360 .mock("GET", path.as_str())
2361 .with_status(200)
2362 .with_header("Content-Type", "application/zip")
2363 .with_body(zip_of(pkg.files))
2364 .create();
2365
2366 self.mocks.push(mock);
2367
2368 let (owner, name) = pkg.full_name.split_once('-').unwrap();
2369 let json = serde_json::json!({
2370 "name": name, "full_name": pkg.full_name, "owner": owner,
2371 "package_url": "https://example.com", "date_created": "2024-01-01T12:00:00Z",
2372 "date_updated": "2024-01-02T12:00:00Z", "uuid4": "pkg", "rating_score": 1,
2373 "is_pinned": false, "is_deprecated": false, "has_nsfw_content": false,
2374 "categories": [],
2375 "versions": [{
2376 "name": name, "full_name": pkg.full_name, "description": "d", "icon": "i",
2377 "version_number": pkg.version, "dependencies": pkg.deps,
2378 "download_url": format!("{}{path}", self.server.url()), "downloads": 1,
2379 "date_created": "2024-01-01T12:00:00Z", "website_url": "", "is_active": true,
2380 "uuid4": "ver", "file_size": 1
2381 }]
2382 });
2383
2384 packages.push(serde_json::from_value::<Package>(json).unwrap());
2385 }
2386
2387 PackageIndex::from(packages)
2388 }
2389
2390 fn install(
2392 &mut self,
2393 index: &PackageIndex,
2394 desired: &[&str],
2395 explicit: &[&str],
2396 force: bool,
2397 ) -> BatchOutcome {
2398 let desired: Vec<String> = desired.iter().map(|s| s.to_string()).collect();
2399 let explicit: Vec<String> = explicit.iter().map(|s| s.to_string()).collect();
2400 let mut batch = plan_install_batch(self.target.path(), &desired, &explicit).unwrap();
2401
2402 batch.force = force;
2403 self.clock += 1;
2404
2405 let outcome = self
2406 .rt
2407 .block_on(install_batch(
2408 self.target.path(),
2409 self.cache_base.path(),
2410 &self.eco,
2411 index,
2412 &self.client,
2413 "valheim",
2414 &batch,
2415 self.clock,
2416 ))
2417 .unwrap();
2418
2419 assert!(outcome.is_ok(), "batch failed: {:?}", outcome.failed);
2420
2421 outcome
2422 }
2423
2424 fn path(&self, rel: &str) -> PathBuf {
2425 self.target.path().join(rel)
2426 }
2427
2428 fn entry(&self, name: &str) -> ProfileMod {
2429 modlist::read(self.target.path())
2430 .unwrap()
2431 .into_iter()
2432 .find(|m| m.name == name)
2433 .unwrap()
2434 }
2435 }
2436
2437 fn zip_of(files: &[(&str, &[u8])]) -> Vec<u8> {
2438 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
2439 let opts: FileOptions<'_, ()> =
2440 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
2441
2442 for (name, body) in files {
2443 zip.start_file(*name, opts).unwrap();
2444 zip.write_all(body).unwrap();
2445 }
2446
2447 zip.finish().unwrap().into_inner()
2448 }
2449
2450 fn names(list: &[&str]) -> Vec<String> {
2451 list.iter().map(|s| s.to_string()).collect()
2452 }
2453
2454 #[test]
2455 fn an_install_at_the_recorded_version_is_skipped() {
2456 let mut repo = Repo::new();
2457 let index = repo.publish(&[Pkg {
2458 full_name: MOD,
2459 version: "1.0.0",
2460 deps: &[],
2461 files: &[("plugins/Mod.dll", b"v1")],
2462 }]);
2463
2464 repo.install(&index, &[MOD], &[MOD], false);
2465
2466 let user_file = repo.path("BepInEx/plugins/Author-Mod/user.txt");
2467
2468 write_file(&user_file, b"mine");
2469
2470 let installed_at = repo.entry(MOD).installed_at_time;
2471 let outcome = repo.install(&index, &[MOD], &[MOD], false);
2472
2473 assert!(outcome.succeeded.is_empty());
2474 assert_eq!(outcome.unchanged, names(&[MOD]));
2475 assert!(user_file.exists(), "a skipped install must not touch files");
2476 assert_eq!(repo.entry(MOD).installed_at_time, installed_at);
2477 }
2478
2479 #[test]
2480 fn a_version_change_removes_files_only_the_old_version_had() {
2481 let mut repo = Repo::new();
2482 let v1 = repo.publish(&[Pkg {
2483 full_name: MOD,
2484 version: "1.0.0",
2485 deps: &[],
2486 files: &[
2487 ("plugins/Old.dll", b"old"),
2488 ("plugins/german.json", b"flat"),
2489 ],
2490 }]);
2491
2492 repo.install(&v1, &[MOD], &[MOD], false);
2493
2494 assert!(repo.path("BepInEx/plugins/Author-Mod/german.json").exists());
2495
2496 let v2 = repo.publish(&[Pkg {
2497 full_name: MOD,
2498 version: "2.0.0",
2499 deps: &[],
2500 files: &[
2501 ("plugins/Mod.dll", b"new"),
2502 ("plugins/Translations/German/german.json", b"nested"),
2503 ],
2504 }]);
2505 let outcome = repo.install(&v2, &[MOD], &[MOD], false);
2506
2507 assert_eq!(outcome.succeeded, names(&[MOD]));
2508 assert!(!repo.path("BepInEx/plugins/Author-Mod/Old.dll").exists());
2509 assert!(!repo.path("BepInEx/plugins/Author-Mod/german.json").exists());
2510 assert!(
2511 repo
2512 .path("BepInEx/plugins/Author-Mod/Translations/German/german.json")
2513 .exists()
2514 );
2515 assert_eq!(repo.entry(MOD).version_number.to_string(), "2.0.0");
2516 }
2517
2518 #[test]
2519 fn force_reinstalls_the_recorded_version_from_a_clean_folder() {
2520 let mut repo = Repo::new();
2521 let index = repo.publish(&[Pkg {
2522 full_name: MOD,
2523 version: "1.0.0",
2524 deps: &[],
2525 files: &[("plugins/Translations/German/german.json", b"nested")],
2526 }]);
2527
2528 repo.install(&index, &[MOD], &[MOD], false);
2529
2530 let stale = repo.path("BepInEx/plugins/Author-Mod/german.json");
2532
2533 write_file(&stale, b"flat");
2534
2535 let outcome = repo.install(&index, &[MOD], &[MOD], true);
2536
2537 assert_eq!(outcome.succeeded, names(&[MOD]));
2538 assert!(outcome.unchanged.is_empty());
2539 assert!(!stale.exists());
2540 assert!(
2541 repo
2542 .path("BepInEx/plugins/Author-Mod/Translations/German/german.json")
2543 .exists()
2544 );
2545 }
2546
2547 #[test]
2548 fn force_reinstalls_only_the_named_mod_not_its_dependencies() {
2549 let mut repo = Repo::new();
2550 let index = repo.publish(&[
2551 Pkg {
2552 full_name: MOD,
2553 version: "1.0.0",
2554 deps: &["Author-Dep-1.0.0"],
2555 files: &[("plugins/Mod.dll", b"mod")],
2556 },
2557 Pkg {
2558 full_name: DEP,
2559 version: "1.0.0",
2560 deps: &[],
2561 files: &[("plugins/Dep.dll", b"dep")],
2562 },
2563 ]);
2564
2565 repo.install(&index, &[MOD], &[MOD], false);
2566
2567 let dep_marker = repo.path("BepInEx/plugins/Author-Dep/user.txt");
2568
2569 write_file(&dep_marker, b"mine");
2570
2571 let outcome = repo.install(&index, &[MOD], &[MOD], true);
2572
2573 assert_eq!(outcome.succeeded, names(&[MOD]));
2574 assert_eq!(outcome.unchanged, names(&[DEP]));
2575 assert!(
2576 dep_marker.exists(),
2577 "the dependency must not be reinstalled"
2578 );
2579 }
2580
2581 #[test]
2582 fn a_disabled_mod_stays_disabled_through_force_and_an_update() {
2583 let mut repo = Repo::new();
2584 let v1 = repo.publish(&[Pkg {
2585 full_name: MOD,
2586 version: "1.0.0",
2587 deps: &[],
2588 files: &[("plugins/Mod.dll", b"v1"), ("plugins/Old.dll", b"v1")],
2589 }]);
2590
2591 repo.install(&v1, &[MOD], &[MOD], false);
2592
2593 set_enabled_in(repo.target.path(), &repo.eco, "valheim", MOD, false).unwrap();
2594
2595 repo.install(&v1, &[MOD], &[], true);
2597
2598 assert!(!repo.entry(MOD).enabled);
2599 assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll.old").exists());
2600 assert!(!repo.path("BepInEx/plugins/Author-Mod/Mod.dll").exists());
2601
2602 let v2 = repo.publish(&[Pkg {
2603 full_name: MOD,
2604 version: "2.0.0",
2605 deps: &[],
2606 files: &[("plugins/Mod.dll", b"v2")],
2607 }]);
2608
2609 repo.install(&v2, &[MOD], &[], false);
2610
2611 assert!(!repo.entry(MOD).enabled);
2612 assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll.old").exists());
2613 assert!(!repo.path("BepInEx/plugins/Author-Mod/Mod.dll").exists());
2614 assert!(!repo.path("BepInEx/plugins/Author-Mod/Old.dll.old").exists());
2615 }
2616
2617 #[test]
2618 fn naming_a_disabled_mod_at_its_recorded_version_enables_it() {
2619 let mut repo = Repo::new();
2620 let index = repo.publish(&[Pkg {
2621 full_name: MOD,
2622 version: "1.0.0",
2623 deps: &[],
2624 files: &[("plugins/Mod.dll", b"v1")],
2625 }]);
2626
2627 repo.install(&index, &[MOD], &[MOD], false);
2628
2629 set_enabled_in(repo.target.path(), &repo.eco, "valheim", MOD, false).unwrap();
2630
2631 let outcome = repo.install(&index, &[MOD], &[MOD], false);
2632
2633 assert_eq!(outcome.unchanged, names(&[MOD]));
2634 assert!(repo.entry(MOD).enabled);
2635 assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll").exists());
2636 assert!(!repo.path("BepInEx/plugins/Author-Mod/Mod.dll.old").exists());
2637 }
2638
2639 #[test]
2640 fn shared_config_survives_force_and_an_update() {
2641 let mut repo = Repo::new();
2642 let v1 = repo.publish(&[Pkg {
2643 full_name: MOD,
2644 version: "1.0.0",
2645 deps: &[],
2646 files: &[("plugins/Mod.dll", b"v1"), ("config/Mod.cfg", b"default")],
2647 }]);
2648
2649 repo.install(&v1, &[MOD], &[MOD], false);
2650
2651 let cfg = repo.path("BepInEx/config/Mod.cfg");
2652
2653 write_file(&cfg, b"edited");
2654 repo.install(&v1, &[MOD], &[MOD], true);
2655
2656 assert_eq!(std::fs::read(&cfg).unwrap(), b"edited");
2657
2658 let v2 = repo.publish(&[Pkg {
2659 full_name: MOD,
2660 version: "2.0.0",
2661 deps: &[],
2662 files: &[("plugins/Mod.dll", b"v2"), ("config/Mod.cfg", b"default")],
2663 }]);
2664
2665 repo.install(&v2, &[MOD], &[MOD], false);
2666
2667 assert_eq!(std::fs::read(&cfg).unwrap(), b"edited");
2668 }
2669
2670 #[test]
2671 fn force_reinstalls_a_loader_pack_and_keeps_its_config() {
2672 let mut repo = Repo::new();
2673 let index = repo.publish(&[Pkg {
2674 full_name: LOADER,
2675 version: "5.4.2333",
2676 deps: &[],
2677 files: &[
2678 ("BepInExPack_Valheim/winhttp.dll", b"proxy"),
2679 ("BepInExPack_Valheim/BepInEx/core/BepInEx.dll", b"core"),
2680 ("BepInExPack_Valheim/BepInEx/config/BepInEx.cfg", b"default"),
2681 ],
2682 }]);
2683
2684 repo.install(&index, &[LOADER], &[LOADER], false);
2685
2686 let cfg = repo.path("BepInEx/config/BepInEx.cfg");
2687 let core = repo.path("BepInEx/core/BepInEx.dll");
2688
2689 write_file(&cfg, b"edited");
2690 write_file(&core, b"tampered");
2691
2692 let outcome = repo.install(&index, &[LOADER], &[LOADER], true);
2693
2694 assert_eq!(outcome.succeeded, names(&[LOADER]));
2695 assert_eq!(std::fs::read(&core).unwrap(), b"core");
2696 assert!(repo.path("winhttp.dll").exists());
2697 assert_eq!(std::fs::read(&cfg).unwrap(), b"edited");
2698 assert!(state_file_path(repo.target.path(), LOADER).exists());
2699 }
2700
2701 #[test]
2702 fn a_recorded_mod_whose_folder_is_gone_is_reinstalled() {
2703 let mut repo = Repo::new();
2704 let index = repo.publish(&[Pkg {
2705 full_name: MOD,
2706 version: "1.0.0",
2707 deps: &[],
2708 files: &[("plugins/Mod.dll", b"v1")],
2709 }]);
2710
2711 repo.install(&index, &[MOD], &[MOD], false);
2712
2713 std::fs::remove_dir_all(repo.path("BepInEx/plugins/Author-Mod")).unwrap();
2714
2715 let outcome = repo.install(&index, &[MOD], &[MOD], false);
2716
2717 assert_eq!(outcome.succeeded, names(&[MOD]));
2718 assert!(outcome.unchanged.is_empty());
2719 assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll").exists());
2720 }
2721
2722 #[test]
2723 fn a_loader_pack_missing_a_tracked_file_is_reinstalled() {
2724 let mut repo = Repo::new();
2725 let index = repo.publish(&[Pkg {
2726 full_name: LOADER,
2727 version: "5.4.2333",
2728 deps: &[],
2729 files: &[
2730 ("BepInExPack_Valheim/winhttp.dll", b"proxy"),
2731 ("BepInExPack_Valheim/BepInEx/core/BepInEx.dll", b"core"),
2732 ],
2733 }]);
2734
2735 repo.install(&index, &[LOADER], &[LOADER], false);
2736
2737 std::fs::remove_file(repo.path("winhttp.dll")).unwrap();
2738
2739 let outcome = repo.install(&index, &[LOADER], &[LOADER], false);
2740
2741 assert_eq!(outcome.succeeded, names(&[LOADER]));
2742 assert!(repo.path("winhttp.dll").exists());
2743 }
2744
2745 #[test]
2746 fn a_recorded_mod_with_no_files_to_place_is_skipped() {
2747 let mut repo = Repo::new();
2748 let index = repo.publish(&[Pkg {
2749 full_name: MOD,
2750 version: "1.0.0",
2751 deps: &[],
2752 files: &[("manifest.json", b"{}"), ("README.md", b"modpack")],
2753 }]);
2754
2755 repo.install(&index, &[MOD], &[MOD], false);
2756
2757 let outcome = repo.install(&index, &[MOD], &[MOD], false);
2758
2759 assert!(outcome.succeeded.is_empty());
2760 assert_eq!(outcome.unchanged, names(&[MOD]));
2761 }
2762
2763 #[cfg(unix)]
2764 #[test]
2765 fn a_failed_enable_is_reported_per_item_without_losing_the_batch() {
2766 use std::os::unix::fs::PermissionsExt;
2767
2768 let mut repo = Repo::new();
2769 let index = repo.publish(&[
2770 Pkg {
2771 full_name: MOD,
2772 version: "1.0.0",
2773 deps: &[],
2774 files: &[("plugins/Mod.dll", b"v1")],
2775 },
2776 Pkg {
2777 full_name: DEP,
2778 version: "1.0.0",
2779 deps: &[],
2780 files: &[("plugins/Dep.dll", b"dep")],
2781 },
2782 ]);
2783
2784 repo.install(&index, &[MOD], &[MOD], false);
2785
2786 set_enabled_in(repo.target.path(), &repo.eco, "valheim", MOD, false).unwrap();
2787
2788 let folder = repo.path("BepInEx/plugins/Author-Mod");
2790
2791 std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o555)).unwrap();
2792
2793 if std::fs::write(folder.join("probe"), b"").is_ok() {
2794 return;
2796 }
2797
2798 let desired = names(&[MOD, DEP]);
2799 let batch = plan_install_batch(repo.target.path(), &desired, &desired).unwrap();
2800 let result = repo.rt.block_on(install_batch(
2801 repo.target.path(),
2802 repo.cache_base.path(),
2803 &repo.eco,
2804 &index,
2805 &repo.client,
2806 "valheim",
2807 &batch,
2808 1,
2809 ));
2810
2811 std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o755)).unwrap();
2812
2813 let outcome = result.expect("a failed enable must not discard the batch");
2814 let failed: Vec<&str> = outcome.failed.iter().map(|(n, _)| n.as_str()).collect();
2815
2816 assert_eq!(failed, vec![MOD]);
2817 assert_eq!(outcome.succeeded, names(&[DEP]));
2818 assert!(outcome.unchanged.is_empty());
2819 }
2820
2821 #[test]
2822 fn a_disabled_mod_not_named_is_still_skipped() {
2823 let mut repo = Repo::new();
2824 let index = repo.publish(&[Pkg {
2825 full_name: MOD,
2826 version: "1.0.0",
2827 deps: &[],
2828 files: &[("plugins/Mod.dll", b"v1")],
2829 }]);
2830
2831 repo.install(&index, &[MOD], &[MOD], false);
2832
2833 set_enabled_in(repo.target.path(), &repo.eco, "valheim", MOD, false).unwrap();
2834
2835 let outcome = repo.install(&index, &[MOD], &[], false);
2836
2837 assert_eq!(outcome.unchanged, names(&[MOD]));
2838 assert!(!repo.entry(MOD).enabled);
2839 assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll.old").exists());
2840 }
2841
2842 #[test]
2843 fn a_mod_left_half_disabled_is_reinstalled() {
2844 let mut repo = Repo::new();
2845 let index = repo.publish(&[Pkg {
2846 full_name: MOD,
2847 version: "1.0.0",
2848 deps: &[],
2849 files: &[("plugins/Mod.dll", b"v1"), ("plugins/Extra.dll", b"v1")],
2850 }]);
2851
2852 repo.install(&index, &[MOD], &[MOD], false);
2853
2854 let extra = repo.path("BepInEx/plugins/Author-Mod/Extra.dll");
2857
2858 std::fs::rename(
2859 &extra,
2860 repo.path("BepInEx/plugins/Author-Mod/Extra.dll.old"),
2861 )
2862 .unwrap();
2863
2864 let outcome = repo.install(&index, &[MOD], &[MOD], false);
2865
2866 assert_eq!(outcome.succeeded, names(&[MOD]));
2867 assert!(outcome.unchanged.is_empty());
2868 assert!(extra.exists());
2869 assert!(
2870 !repo
2871 .path("BepInEx/plugins/Author-Mod/Extra.dll.old")
2872 .exists()
2873 );
2874 assert!(repo.path("BepInEx/plugins/Author-Mod/Mod.dll").exists());
2875 }
2876
2877 #[cfg(unix)]
2878 #[test]
2879 fn a_failed_loader_reinstall_keeps_it_uninstallable() {
2880 let mut repo = Repo::new();
2881 let v1 = repo.publish(&[Pkg {
2882 full_name: LOADER,
2883 version: "5.4.2333",
2884 deps: &[],
2885 files: &[
2886 ("BepInExPack_Valheim/winhttp.dll", b"proxy"),
2887 ("BepInExPack_Valheim/BepInEx/core/BepInEx.dll", b"core"),
2888 ],
2889 }]);
2890
2891 repo.install(&v1, &[LOADER], &[LOADER], false);
2892
2893 let v2 = repo.publish(&[Pkg {
2894 full_name: LOADER,
2895 version: "5.4.2334",
2896 deps: &[],
2897 files: &[
2898 ("BepInExPack_Valheim/winhttp.dll", b"proxy"),
2899 ("BepInExPack_Valheim/BepInEx/core/BepInEx.dll", b"core"),
2900 ("BepInExPack_Valheim/BepInEx/core/Extra.dll", b"extra"),
2901 ],
2902 }]);
2903
2904 std::os::unix::fs::symlink(
2907 repo.path("elsewhere.dll"),
2908 repo.path("BepInEx/core/Extra.dll"),
2909 )
2910 .unwrap();
2911
2912 let desired = names(&[LOADER]);
2913 let batch = plan_install_batch(repo.target.path(), &desired, &desired).unwrap();
2914 let outcome = repo
2915 .rt
2916 .block_on(install_batch(
2917 repo.target.path(),
2918 repo.cache_base.path(),
2919 &repo.eco,
2920 &v2,
2921 &repo.client,
2922 "valheim",
2923 &batch,
2924 2,
2925 ))
2926 .unwrap();
2927 let failed: Vec<&str> = outcome.failed.iter().map(|(n, _)| n.as_str()).collect();
2928
2929 assert_eq!(failed, vec![LOADER]);
2930 assert!(state_file_path(repo.target.path(), LOADER).exists());
2931
2932 uninstall_mod_in(repo.target.path(), &repo.eco, "valheim", LOADER).unwrap();
2933
2934 assert!(modlist::read(repo.target.path()).unwrap().is_empty());
2935 assert!(!state_file_path(repo.target.path(), LOADER).exists());
2936 }
2937 }
2938}