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, plan_install, remove_delisted, remove_state_payload, state_tracker_from_plan,
67 write_state_file,
68};
69use crate::models::{DependencyGraph, PackageIndex, Version};
70use modlist::{ProfileMod, SemVer};
71use std::collections::BTreeSet;
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 failed: Vec<(String, Error)>,
143}
144
145impl BatchOutcome {
146 pub fn is_ok(&self) -> bool {
148 self.failed.is_empty()
149 }
150}
151
152#[derive(Debug, Clone, Default)]
155#[non_exhaustive]
156pub struct InstallBatch {
157 pub desired: Vec<String>,
160 pub protect_disabled: Vec<String>,
167}
168
169impl InstallBatch {
170 pub fn new(desired: Vec<String>, protect_disabled: Vec<String>) -> Self {
174 Self {
175 desired,
176 protect_disabled,
177 }
178 }
179}
180
181pub fn plan_install_batch(
189 target_dir: &Path,
190 desired: &[String],
191 explicit: &[String],
192) -> Result<InstallBatch> {
193 let recorded = modlist::read(target_dir)?;
194
195 let protect_disabled = recorded
196 .iter()
197 .filter(|entry| !entry.enabled)
198 .filter(|entry| !explicit.iter().any(|name| name == &entry.name))
199 .map(|entry| entry.name.clone())
200 .collect();
201
202 Ok(InstallBatch {
203 desired: desired.to_vec(),
204 protect_disabled,
205 })
206}
207
208pub fn reapply_batch_disabled(
215 target_dir: &Path,
216 eco: &Ecosystem,
217 game: &str,
218 batch: &InstallBatch,
219) -> Result<()> {
220 for full_name in &batch.protect_disabled {
221 set_enabled_in(target_dir, eco, game, full_name, false)?;
222 }
223
224 Ok(())
225}
226
227#[allow(clippy::too_many_arguments)]
234pub fn install_extracted_in(
235 target_dir: &Path,
236 eco: &Ecosystem,
237 game: &str,
238 full_name: &str,
239 owner: &str,
240 version: &Version,
241 extracted_dir: &Path,
242 archive: Option<&Path>,
243 installed_at_time: u64,
244) -> Result<()> {
245 let game_profile = eco
246 .game(game)
247 .and_then(|g| g.profile())
248 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
249
250 let plan = plan_install(eco, game_profile, full_name, extracted_dir, archive)?;
251
252 apply_install(&plan, target_dir)?;
253
254 if let Some(tracker) = state_tracker_from_plan(&plan, full_name) {
255 write_state_file(target_dir, &tracker)?;
256 }
257
258 let mut mods = modlist::read(target_dir)?;
259
260 modlist::upsert(
261 &mut mods,
262 mod_entry_from_version(full_name, owner, version, installed_at_time),
263 );
264
265 modlist::write(target_dir, &mods)
266}
267
268#[allow(clippy::too_many_arguments)]
271pub fn install_extracted(
272 base: &Path,
273 eco: &Ecosystem,
274 game: &str,
275 profile_name: &str,
276 full_name: &str,
277 owner: &str,
278 version: &Version,
279 extracted_dir: &Path,
280 archive: Option<&Path>,
281 installed_at_time: u64,
282) -> Result<()> {
283 install_extracted_in(
284 &layout::profile_dir(base, game, profile_name),
285 eco,
286 game,
287 full_name,
288 owner,
289 version,
290 extracted_dir,
291 archive,
292 installed_at_time,
293 )
294}
295
296#[derive(Debug)]
311pub enum NotRemovable {
312 RecordAbsent {
315 name: String,
317 path: PathBuf,
319 },
320 RecordUnreadable {
323 name: String,
325 path: PathBuf,
327 source: Error,
329 },
330}
331
332impl std::fmt::Display for NotRemovable {
333 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334 match self {
335 Self::RecordAbsent { name, path } => write!(
336 f,
337 "{name} is a mod loader with no install record at {}, so its files \
338 cannot be removed exactly. Reinstall it, then uninstall it again, or \
339 remove its files by hand.",
340 path.display()
341 ),
342 Self::RecordUnreadable { name, path, source } => write!(
343 f,
344 "{name} has an install record at {} that cannot be read, so its files \
345 cannot be removed exactly: {source}",
346 path.display()
347 ),
348 }
349 }
350}
351
352impl From<NotRemovable> for Error {
353 fn from(value: NotRemovable) -> Self {
354 Error::Profile(value.to_string())
355 }
356}
357
358pub fn ensure_removable(
391 target_dir: &Path,
392 eco: &Ecosystem,
393 full_name: &str,
394) -> std::result::Result<(), NotRemovable> {
395 match crate::install::read_state_file(target_dir, full_name) {
396 Ok(Some(_)) => return Ok(()),
397 Ok(None) => {}
398 Err(source) => {
399 return Err(NotRemovable::RecordUnreadable {
400 name: full_name.to_string(),
401 path: crate::install::state_file_path(target_dir, full_name),
402 source,
403 });
404 }
405 }
406
407 if eco.modloader_package(full_name).is_none() {
408 return Ok(());
409 }
410
411 Err(NotRemovable::RecordAbsent {
412 name: full_name.to_string(),
413 path: crate::install::state_file_path(target_dir, full_name),
414 })
415}
416
417pub fn uninstall_mod_in(
436 target_dir: &Path,
437 eco: &Ecosystem,
438 game: &str,
439 full_name: &str,
440) -> Result<()> {
441 let game_profile = eco
442 .game(game)
443 .and_then(|g| g.profile())
444 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
445
446 ensure_removable(target_dir, eco, full_name)?;
451
452 let mut mods = modlist::read(target_dir)?;
453
454 mods.retain(|m| m.name != full_name);
455
456 let keep: BTreeSet<String> = mods.iter().map(|m| m.name.clone()).collect();
457
458 remove_delisted(game_profile, target_dir, &keep)?;
459 remove_state_payload(target_dir, full_name)?;
460
461 modlist::write(target_dir, &mods)
462}
463
464pub fn uninstall_mod(
466 base: &Path,
467 eco: &Ecosystem,
468 game: &str,
469 profile_name: &str,
470 full_name: &str,
471) -> Result<()> {
472 uninstall_mod_in(
473 &layout::profile_dir(base, game, profile_name),
474 eco,
475 game,
476 full_name,
477 )
478}
479
480pub fn set_enabled_in(
488 target_dir: &Path,
489 eco: &Ecosystem,
490 game: &str,
491 full_name: &str,
492 enabled: bool,
493) -> Result<()> {
494 let game_profile = eco
495 .game(game)
496 .and_then(|g| g.profile())
497 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
498
499 let mut mods = modlist::read(target_dir)?;
500
501 let entry = mods
502 .iter_mut()
503 .find(|m| m.name == full_name)
504 .ok_or_else(|| Error::Profile(format!("{full_name} is not installed")))?;
505
506 if eco.modloader_package(full_name).is_some() {
507 return Ok(());
508 }
509
510 if enabled {
511 crate::install::enable_mod(game_profile, target_dir, full_name)?;
512 } else {
513 crate::install::disable_mod(game_profile, target_dir, full_name)?;
514 }
515
516 entry.enabled = enabled;
517
518 modlist::write(target_dir, &mods)
519}
520
521pub fn set_enabled(
524 base: &Path,
525 eco: &Ecosystem,
526 game: &str,
527 profile_name: &str,
528 full_name: &str,
529 enabled: bool,
530) -> Result<()> {
531 set_enabled_in(
532 &layout::profile_dir(base, game, profile_name),
533 eco,
534 game,
535 full_name,
536 enabled,
537 )
538}
539
540pub fn enable_mod_in(
542 target_dir: &Path,
543 eco: &Ecosystem,
544 game: &str,
545 full_name: &str,
546) -> Result<()> {
547 set_enabled_in(target_dir, eco, game, full_name, true)
548}
549
550pub fn disable_mod_in(
552 target_dir: &Path,
553 eco: &Ecosystem,
554 game: &str,
555 full_name: &str,
556) -> Result<()> {
557 set_enabled_in(target_dir, eco, game, full_name, false)
558}
559
560pub fn enable_mod(
562 base: &Path,
563 eco: &Ecosystem,
564 game: &str,
565 profile_name: &str,
566 full_name: &str,
567) -> Result<()> {
568 set_enabled(base, eco, game, profile_name, full_name, true)
569}
570
571pub fn disable_mod(
573 base: &Path,
574 eco: &Ecosystem,
575 game: &str,
576 profile_name: &str,
577 full_name: &str,
578) -> Result<()> {
579 set_enabled(base, eco, game, profile_name, full_name, false)
580}
581
582#[allow(clippy::too_many_arguments)]
593async fn install_one(
594 target_dir: &Path,
595 cache_base: &Path,
596 eco: &Ecosystem,
597 index: &PackageIndex,
598 client: &ThunderstoreClient,
599 game: &str,
600 full_name: &str,
601 installed_at_time: u64,
602) -> Result<Vec<String>> {
603 let resolution = DependencyGraph::new(vec![full_name.to_string()]).resolve_report(index);
604
605 if !resolution.unresolved.is_empty() {
606 return Err(Error::Profile(format!(
607 "cannot install {full_name}: unresolved dependencies: {}",
608 resolution.unresolved.join(", ")
609 )));
610 }
611
612 let urls = resolution.urls;
613
614 client.download_files(urls.clone()).await?;
615
616 let downloads = client.cache_dir().join("downloads");
617 let mut installed = Vec::new();
618
619 for filename in urls.keys() {
620 let (pkg_full_name, version_str) = parse_package_filename(filename)
621 .ok_or_else(|| Error::Profile(format!("unparseable package filename {filename:?}")))?;
622
623 let archive = downloads.join(filename);
624
625 let extracted =
626 cache::ensure_extracted(cache_base, game, &pkg_full_name, &version_str, &archive)?;
627
628 let package = index
629 .get_package_by_full_name(&pkg_full_name)
630 .ok_or_else(|| Error::Profile(format!("{pkg_full_name} missing from index")))?;
631
632 let owner = package.owner.clone().unwrap_or_default();
633 let version = package
634 .versions
635 .iter()
636 .find(|v| v.version_number.as_deref() == Some(version_str.as_str()))
637 .or_else(|| package.latest_version())
638 .ok_or_else(|| Error::Profile(format!("{pkg_full_name} has no usable version")))?;
639
640 install_extracted_in(
641 target_dir,
642 eco,
643 game,
644 &pkg_full_name,
645 &owner,
646 version,
647 &extracted,
648 Some(&archive),
649 installed_at_time,
650 )?;
651
652 installed.push(pkg_full_name);
653 }
654
655 installed.sort();
656
657 Ok(installed)
658}
659
660#[allow(clippy::too_many_arguments)]
668pub async fn install_batch(
669 target_dir: &Path,
670 cache_base: &Path,
671 eco: &Ecosystem,
672 index: &PackageIndex,
673 client: &ThunderstoreClient,
674 game: &str,
675 batch: &InstallBatch,
676 installed_at_time: u64,
677) -> Result<BatchOutcome> {
678 let mut outcome = BatchOutcome::default();
679
680 for full_name in &batch.desired {
681 match install_one(
682 target_dir,
683 cache_base,
684 eco,
685 index,
686 client,
687 game,
688 full_name,
689 installed_at_time,
690 )
691 .await
692 {
693 Ok(installed) => outcome.succeeded.extend(installed),
694 Err(error) => outcome.failed.push((full_name.clone(), error)),
695 }
696 }
697
698 reapply_batch_disabled(target_dir, eco, game, batch)?;
699
700 outcome.succeeded.sort();
701 outcome.succeeded.dedup();
702
703 Ok(outcome)
704}
705
706#[allow(clippy::too_many_arguments)]
763pub async fn install_mod_in(
764 target_dir: &Path,
765 cache_base: &Path,
766 eco: &Ecosystem,
767 index: &PackageIndex,
768 client: &ThunderstoreClient,
769 game: &str,
770 full_name: &str,
771 installed_at_time: u64,
772) -> Result<Vec<String>> {
773 let named = [full_name.to_string()];
774 let batch = plan_install_batch(target_dir, &named, &named)?;
775
776 let outcome = install_batch(
777 target_dir,
778 cache_base,
779 eco,
780 index,
781 client,
782 game,
783 &batch,
784 installed_at_time,
785 )
786 .await?;
787
788 match outcome.failed.into_iter().next() {
789 Some((_, error)) => Err(error),
790 None => Ok(outcome.succeeded),
791 }
792}
793
794#[allow(clippy::too_many_arguments)]
799pub async fn install_mod(
800 base: &Path,
801 eco: &Ecosystem,
802 index: &PackageIndex,
803 client: &ThunderstoreClient,
804 game: &str,
805 profile_name: &str,
806 full_name: &str,
807 installed_at_time: u64,
808) -> Result<Vec<String>> {
809 install_mod_in(
810 &layout::profile_dir(base, game, profile_name),
811 base,
812 eco,
813 index,
814 client,
815 game,
816 full_name,
817 installed_at_time,
818 )
819 .await
820}
821
822#[derive(Debug, Clone, Default)]
830#[non_exhaustive]
831pub struct UninstallBatch {
832 pub mods: Vec<String>,
834 pub loaders: Vec<String>,
836 pub untracked: Vec<PathBuf>,
839}
840
841pub fn check_removable_batch(
848 target_dir: &Path,
849 eco: &Ecosystem,
850 names: &[String],
851) -> Vec<(String, NotRemovable)> {
852 names
853 .iter()
854 .filter_map(|name| match ensure_removable(target_dir, eco, name) {
855 Ok(()) => None,
856 Err(refusal) => Some((name.clone(), refusal)),
857 })
858 .collect()
859}
860
861pub fn plan_uninstall_batch(
868 target_dir: &Path,
869 eco: &Ecosystem,
870 game: &str,
871 names: Option<&[String]>,
872) -> Result<UninstallBatch> {
873 let game_profile = eco
874 .game(game)
875 .and_then(|g| g.profile())
876 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
877
878 let recorded = modlist::read(target_dir)?;
879
880 let subjects: Vec<String> = match names {
881 Some(names) => {
882 for name in names {
883 if modlist::find(&recorded, name).is_none() {
884 return Err(Error::Profile(format!("{name} is not installed")));
885 }
886 }
887
888 names.to_vec()
889 }
890 None => recorded.iter().map(|entry| entry.name.clone()).collect(),
891 };
892
893 if let Some((name, refusal)) = check_removable_batch(target_dir, eco, &subjects)
894 .into_iter()
895 .next()
896 {
897 return Err(Error::Profile(format!(
898 "{name} cannot be removed: {refusal}"
899 )));
900 }
901
902 let mut mods: Vec<String> = subjects
903 .iter()
904 .filter(|name| eco.modloader_package(name).is_none())
905 .cloned()
906 .collect();
907 let mut loaders: Vec<String> = subjects
908 .iter()
909 .filter(|name| eco.modloader_package(name).is_some())
910 .cloned()
911 .collect();
912
913 mods.sort();
914 loaders.sort();
915
916 let keep: BTreeSet<String> = recorded.iter().map(|entry| entry.name.clone()).collect();
917 let untracked = crate::install::plan_delisted(game_profile, target_dir, &keep)?;
918
919 Ok(UninstallBatch {
920 mods,
921 loaders,
922 untracked,
923 })
924}
925
926pub fn uninstall_batch(
932 target_dir: &Path,
933 eco: &Ecosystem,
934 game: &str,
935 batch: &UninstallBatch,
936) -> Result<BatchOutcome> {
937 let mut outcome = BatchOutcome::default();
938
939 for full_name in batch.mods.iter().chain(batch.loaders.iter()) {
940 match uninstall_mod_in(target_dir, eco, game, full_name) {
941 Ok(()) => outcome.succeeded.push(full_name.clone()),
942 Err(error) => outcome.failed.push((full_name.clone(), error)),
943 }
944 }
945
946 Ok(outcome)
947}
948
949#[derive(Debug, Clone, Default)]
956#[non_exhaustive]
957pub struct ToggleBatch {
958 pub changing: Vec<String>,
960 pub already: Vec<String>,
962 pub loaders: Vec<String>,
964 pub enabled: bool,
969}
970
971pub fn plan_set_enabled_batch(
979 target_dir: &Path,
980 eco: &Ecosystem,
981 names: Option<&[String]>,
982 enabled: bool,
983) -> Result<ToggleBatch> {
984 let recorded = modlist::read(target_dir)?;
985
986 if let Some(names) = names {
987 for name in names {
988 if modlist::find(&recorded, name).is_none() {
989 return Err(Error::Profile(format!("{name} is not installed")));
990 }
991 }
992 }
993
994 let mut batch = ToggleBatch {
995 enabled,
996 ..ToggleBatch::default()
997 };
998
999 for entry in &recorded {
1000 if let Some(names) = names
1001 && !names.iter().any(|name| name == &entry.name)
1002 {
1003 continue;
1004 }
1005
1006 if eco.modloader_package(&entry.name).is_some() {
1007 batch.loaders.push(entry.name.clone());
1008 } else if entry.enabled == enabled {
1009 batch.already.push(entry.name.clone());
1010 } else {
1011 batch.changing.push(entry.name.clone());
1012 }
1013 }
1014
1015 batch.changing.sort();
1016 batch.already.sort();
1017 batch.loaders.sort();
1018
1019 Ok(batch)
1020}
1021
1022pub fn set_enabled_batch(
1029 target_dir: &Path,
1030 eco: &Ecosystem,
1031 game: &str,
1032 batch: &ToggleBatch,
1033) -> Result<BatchOutcome> {
1034 let mut outcome = BatchOutcome::default();
1035
1036 for full_name in &batch.changing {
1037 match set_enabled_in(target_dir, eco, game, full_name, batch.enabled) {
1038 Ok(()) => outcome.succeeded.push(full_name.clone()),
1039 Err(error) => outcome.failed.push((full_name.clone(), error)),
1040 }
1041 }
1042
1043 Ok(outcome)
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048 use super::*;
1049 use crate::ecosystem::Ecosystem;
1050 use crate::install::state_file_path;
1051 use crate::models::Version;
1052 use std::io::{Cursor, Write};
1053 use tempfile::tempdir;
1054 use time::OffsetDateTime;
1055 use zip::write::{FileOptions, ZipWriter};
1056
1057 fn write_file(path: &std::path::Path, bytes: &[u8]) {
1058 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1059 std::fs::File::create(path)
1060 .unwrap()
1061 .write_all(bytes)
1062 .unwrap();
1063 }
1064
1065 fn version(full_name: &str) -> Version {
1066 Version {
1067 name: Some("Mod".to_string()),
1068 full_name: Some(full_name.to_string()),
1069 description: Some("desc".to_string()),
1070 icon: Some("icon.png".to_string()),
1071 version_number: Some("1.2.3".to_string()),
1072 dependencies: vec!["Other-Dep-1.0.0".to_string()],
1073 download_url: Some("https://example.com/dl".to_string()),
1074 downloads: Some(1),
1075 date_created: OffsetDateTime::UNIX_EPOCH,
1076 website_url: Some("https://example.com".to_string()),
1077 is_active: Some(true),
1078 uuid4: Some("u".to_string()),
1079 file_size: Some(1),
1080 }
1081 }
1082
1083 #[test]
1084 fn mod_entry_maps_version_with_r2modman_defaults() {
1085 let entry = mod_entry_from_version("Author-Mod", "Author", &version("Author-Mod"), 123);
1086
1087 assert_eq!(entry.name, "Author-Mod");
1088 assert_eq!(entry.author_name, "Author");
1089 assert_eq!(entry.network_mode, "both");
1090 assert_eq!(entry.package_type, "other");
1091 assert_eq!(entry.install_mode, "managed");
1092 assert_eq!(entry.game_version, "0");
1093 assert_eq!(entry.manifest_version, 1);
1094 assert_eq!(
1095 entry.version_number,
1096 crate::profile::modlist::SemVer {
1097 major: 1,
1098 minor: 2,
1099 patch: 3
1100 }
1101 );
1102 assert_eq!(entry.dependencies, vec!["Other-Dep-1.0.0".to_string()]);
1103 assert_eq!(entry.installed_at_time, 123);
1104 assert!(entry.enabled);
1105 }
1106
1107 #[test]
1108 fn install_extracted_places_files_and_records_mods_yml() {
1109 let eco = Ecosystem::bundled();
1110
1111 let dir = tempdir().unwrap();
1112 let base = dir.path();
1113
1114 let extracted = tempdir().unwrap();
1115 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1116 write_file(&extracted.path().join("manifest.json"), b"{}");
1117
1118 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1119
1120 install_extracted(
1121 base,
1122 &eco,
1123 "valheim",
1124 "Main",
1125 "Author-Mod",
1126 "Author",
1127 &version("Author-Mod"),
1128 extracted.path(),
1129 None,
1130 123,
1131 )
1132 .unwrap();
1133
1134 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1135
1136 assert!(
1137 profile_dir
1138 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1139 .exists()
1140 );
1141
1142 let mods = crate::profile::modlist::read(&profile_dir).unwrap();
1143
1144 assert_eq!(mods.len(), 1);
1145 assert_eq!(mods[0].name, "Author-Mod");
1146 assert!(mods[0].enabled);
1147 }
1148
1149 #[test]
1150 fn uninstall_removes_files_and_mods_yml_entry() {
1151 let eco = Ecosystem::bundled();
1152
1153 let dir = tempdir().unwrap();
1154 let base = dir.path();
1155
1156 let extracted = tempdir().unwrap();
1157 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1158
1159 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1160
1161 install_extracted(
1162 base,
1163 &eco,
1164 "valheim",
1165 "Main",
1166 "Author-Mod",
1167 "Author",
1168 &version("Author-Mod"),
1169 extracted.path(),
1170 None,
1171 1,
1172 )
1173 .unwrap();
1174
1175 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1176
1177 assert!(
1178 profile_dir
1179 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1180 .exists()
1181 );
1182
1183 uninstall_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap();
1184
1185 assert!(!profile_dir.join("BepInEx/plugins/Author-Mod").exists());
1186 assert_eq!(
1187 crate::profile::modlist::read(&profile_dir).unwrap().len(),
1188 0
1189 );
1190 }
1191
1192 #[test]
1193 fn uninstall_refuses_a_mod_whose_install_record_cannot_be_read() {
1194 let eco = Ecosystem::bundled();
1195
1196 let dir = tempdir().unwrap();
1197 let base = dir.path();
1198
1199 let extracted = tempdir().unwrap();
1200 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1201
1202 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1203 install_extracted(
1204 base,
1205 &eco,
1206 "valheim",
1207 "Main",
1208 "Author-Mod",
1209 "Author",
1210 &version("Author-Mod"),
1211 extracted.path(),
1212 None,
1213 1,
1214 )
1215 .unwrap();
1216
1217 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1218
1219 write_file(&state_file_path(&profile_dir, "Author-Mod"), b"not: [valid");
1223
1224 let error = uninstall_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap_err();
1225
1226 assert!(
1231 profile_dir
1232 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1233 .exists(),
1234 "the mod's files must survive a refusal; got error: {error}"
1235 );
1236 assert_eq!(
1237 crate::profile::modlist::read(&profile_dir).unwrap().len(),
1238 1
1239 );
1240 }
1241
1242 #[test]
1243 fn disable_then_enable_updates_disk_and_mods_yml() {
1244 let eco = Ecosystem::bundled();
1245
1246 let dir = tempdir().unwrap();
1247 let base = dir.path();
1248
1249 let extracted = tempdir().unwrap();
1250 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1251
1252 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1253 install_extracted(
1254 base,
1255 &eco,
1256 "valheim",
1257 "Main",
1258 "Author-Mod",
1259 "Author",
1260 &version("Author-Mod"),
1261 extracted.path(),
1262 None,
1263 1,
1264 )
1265 .unwrap();
1266
1267 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1268
1269 disable_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap();
1270
1271 assert!(
1272 profile_dir
1273 .join("BepInEx/plugins/Author-Mod/Mod.dll.old")
1274 .exists()
1275 );
1276 assert!(!crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1277
1278 enable_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap();
1279
1280 assert!(
1281 profile_dir
1282 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1283 .exists()
1284 );
1285 assert!(crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1286 }
1287
1288 #[test]
1289 fn disable_leaves_files_alone_when_the_install_record_cannot_be_read() {
1290 let eco = Ecosystem::bundled();
1291
1292 let dir = tempdir().unwrap();
1293 let base = dir.path();
1294
1295 let extracted = tempdir().unwrap();
1296 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1297
1298 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1299 install_extracted(
1300 base,
1301 &eco,
1302 "valheim",
1303 "Main",
1304 "Author-Mod",
1305 "Author",
1306 &version("Author-Mod"),
1307 extracted.path(),
1308 None,
1309 1,
1310 )
1311 .unwrap();
1312
1313 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1314
1315 write_file(&state_file_path(&profile_dir, "Author-Mod"), b"not: [valid");
1316
1317 let error = disable_mod(base, &eco, "valheim", "Main", "Author-Mod").unwrap_err();
1318
1319 assert!(
1324 profile_dir
1325 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1326 .exists(),
1327 "the mod's files must be left as they were; got error: {error}"
1328 );
1329 assert!(
1330 !profile_dir
1331 .join("BepInEx/plugins/Author-Mod/Mod.dll.old")
1332 .exists()
1333 );
1334 assert!(crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1335 }
1336
1337 #[test]
1338 fn disable_mod_is_noop_for_modloader_pack() {
1339 let eco = Ecosystem::bundled();
1340
1341 let dir = tempdir().unwrap();
1342 let base = dir.path();
1343 let full_name = "denikson-BepInExPack_Valheim";
1344
1345 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1346
1347 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1348 let loader_file = profile_dir.join("winhttp.dll");
1349
1350 write_file(&loader_file, b"dll");
1351
1352 let mut mods = Vec::new();
1353
1354 modlist::upsert(
1355 &mut mods,
1356 mod_entry_from_version(full_name, "denikson", &version(full_name), 1),
1357 );
1358 modlist::write(&profile_dir, &mods).unwrap();
1359
1360 let tracker = crate::install::ModFileTracker {
1361 mod_name: full_name.to_string(),
1362 files: vec![("unused-cache-source".to_string(), "winhttp.dll".to_string())],
1363 };
1364
1365 write_state_file(&profile_dir, &tracker).unwrap();
1366
1367 disable_mod(base, &eco, "valheim", "Main", full_name).unwrap();
1368
1369 assert!(loader_file.exists());
1370 assert!(crate::profile::modlist::read(&profile_dir).unwrap()[0].enabled);
1371 }
1372
1373 #[test]
1374 fn install_mod_fails_on_unresolved_dependency() {
1375 let eco = Ecosystem::bundled();
1376
1377 let package = crate::models::Package {
1378 name: Some("Mod".to_string()),
1379 full_name: Some("Author-Mod".to_string()),
1380 owner: Some("Author".to_string()),
1381 package_url: Some("https://example.com/Author-Mod".to_string()),
1382 date_created: OffsetDateTime::UNIX_EPOCH,
1383 date_updated: OffsetDateTime::UNIX_EPOCH,
1384 uuid4: Some("u".to_string()),
1385 rating_score: Some(0),
1386 is_pinned: Some(false),
1387 is_deprecated: Some(false),
1388 has_nsfw_content: Some(false),
1389 categories: vec![],
1390 versions: vec![Version {
1391 dependencies: vec!["Missing-Dep-1.0.0".to_string()],
1392 ..version("Author-Mod")
1393 }],
1394 };
1395
1396 let index: PackageIndex = vec![package].into();
1397
1398 let dir = tempdir().unwrap();
1399 let base = dir.path();
1400 let cache_dir = tempdir().unwrap();
1401
1402 let client = ThunderstoreClient::builder()
1403 .package_index_url("http://localhost/pkg/")
1404 .cache_dir(cache_dir.path())
1405 .build()
1406 .unwrap();
1407
1408 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1409
1410 let rt = tokio::runtime::Runtime::new().unwrap();
1411 let result = rt.block_on(install_mod(
1412 base,
1413 &eco,
1414 &index,
1415 &client,
1416 "valheim",
1417 "Main",
1418 "Author-Mod",
1419 1,
1420 ));
1421
1422 let err = result.unwrap_err().to_string();
1423
1424 assert!(err.contains("Missing-Dep"), "unexpected error: {err}");
1425 }
1426
1427 #[test]
1428 fn server_mode_lifecycle_against_bare_target_dir() {
1429 let eco = Ecosystem::bundled();
1430
1431 let dir = tempdir().unwrap();
1432 let target_dir = dir.path();
1433
1434 let extracted = tempdir().unwrap();
1435 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1436
1437 install_extracted_in(
1438 target_dir,
1439 &eco,
1440 "valheim",
1441 "Author-Mod",
1442 "Author",
1443 &version("Author-Mod"),
1444 extracted.path(),
1445 None,
1446 123,
1447 )
1448 .unwrap();
1449
1450 assert!(
1451 target_dir
1452 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1453 .exists()
1454 );
1455 assert!(target_dir.join("mods.yml").exists());
1456
1457 let mods = modlist::read(target_dir).unwrap();
1458
1459 assert_eq!(mods.len(), 1);
1460 assert_eq!(mods[0].name, "Author-Mod");
1461 assert!(mods[0].enabled);
1462
1463 let state_source = extracted.path().join("state-source.txt");
1470
1471 write_file(&state_source, b"state-data");
1472 write_file(&target_dir.join("state-dest.txt"), b"state-data");
1473
1474 let tracker = crate::install::ModFileTracker {
1475 mod_name: "Author-Mod".to_string(),
1476 files: vec![(
1477 state_source.to_string_lossy().to_string(),
1478 "state-dest.txt".to_string(),
1479 )],
1480 };
1481
1482 write_state_file(target_dir, &tracker).unwrap();
1483
1484 assert!(state_file_path(target_dir, "Author-Mod").exists());
1485
1486 disable_mod_in(target_dir, &eco, "valheim", "Author-Mod").unwrap();
1487
1488 assert!(
1489 target_dir
1490 .join("BepInEx/plugins/Author-Mod/Mod.dll.old")
1491 .exists()
1492 );
1493 assert!(!target_dir.join("state-dest.txt").exists());
1494 assert!(!modlist::read(target_dir).unwrap()[0].enabled);
1495
1496 enable_mod_in(target_dir, &eco, "valheim", "Author-Mod").unwrap();
1497
1498 assert!(
1499 target_dir
1500 .join("BepInEx/plugins/Author-Mod/Mod.dll")
1501 .exists()
1502 );
1503 assert!(target_dir.join("state-dest.txt").exists());
1504 assert!(modlist::read(target_dir).unwrap()[0].enabled);
1505
1506 uninstall_mod_in(target_dir, &eco, "valheim", "Author-Mod").unwrap();
1507
1508 assert!(!target_dir.join("BepInEx/plugins/Author-Mod").exists());
1509 assert!(!target_dir.join("state-dest.txt").exists());
1513 assert!(!state_file_path(target_dir, "Author-Mod").exists());
1514 assert_eq!(modlist::read(target_dir).unwrap().len(), 0);
1515 }
1516
1517 fn build_mod_zip() -> Vec<u8> {
1518 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1519 let opts: FileOptions<'_, ()> =
1520 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
1521
1522 for (name, body) in [
1523 ("manifest.json", &b"{\"version_number\":\"1.0.0\"}"[..]),
1524 ("plugins/CoolMod.dll", &b"dll-bytes"[..]),
1525 ] {
1526 zip.start_file(name, opts).unwrap();
1527 zip.write_all(body).unwrap();
1528 }
1529
1530 zip.finish().unwrap().into_inner()
1531 }
1532
1533 fn index_json(server_url: &str) -> String {
1534 format!(
1535 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}}]}}]"#
1536 )
1537 }
1538
1539 #[test]
1540 fn install_mod_in_extracts_into_cache_base_not_target_dir() {
1541 let mut server = mockito::Server::new();
1542 let zip_bytes = build_mod_zip();
1543
1544 let dl = server
1545 .mock("GET", "/dl/mod.zip")
1546 .with_status(200)
1547 .with_header("Content-Type", "application/zip")
1548 .with_body(zip_bytes)
1549 .create();
1550 let idx = server
1551 .mock("GET", "/pkg/")
1552 .with_status(200)
1553 .with_header("Content-Type", "application/json")
1554 .with_header("Last-Modified", "Wed, 21 Feb 2024 15:30:45 GMT")
1555 .with_body(index_json(&server.url()))
1556 .create();
1557
1558 let http_cache = tempdir().unwrap();
1559 let cache_base = tempdir().unwrap();
1560 let target = tempdir().unwrap();
1561
1562 let client = ThunderstoreClient::builder()
1563 .package_index_url(format!("{}/pkg/", server.url()))
1564 .cache_dir(http_cache.path())
1565 .build()
1566 .unwrap();
1567
1568 let rt = tokio::runtime::Runtime::new().unwrap();
1569 let eco = Ecosystem::bundled();
1570 let index = rt.block_on(client.get_manifest()).unwrap();
1571
1572 let installed = rt
1573 .block_on(install_mod_in(
1574 target.path(),
1575 cache_base.path(),
1576 &eco,
1577 &index,
1578 &client,
1579 "valheim",
1580 "Author-CoolMod",
1581 123,
1582 ))
1583 .unwrap();
1584
1585 assert_eq!(installed, vec!["Author-CoolMod".to_string()]);
1586
1587 assert!(
1588 target
1589 .path()
1590 .join("BepInEx/plugins/Author-CoolMod/CoolMod.dll")
1591 .exists()
1592 );
1593
1594 let cached = cache::package_cache_dir(cache_base.path(), "valheim", "Author-CoolMod", "1.0.0");
1595
1596 assert!(cached.join("plugins/CoolMod.dll").exists());
1597 assert!(!target.path().join("cache").exists());
1598
1599 dl.assert();
1600 idx.assert();
1601 }
1602
1603 fn profile_mod(name: &str) -> ProfileMod {
1607 ProfileMod {
1608 manifest_version: 1,
1609 name: name.to_string(),
1610 author_name: String::new(),
1611 website_url: String::new(),
1612 display_name: name.to_string(),
1613 description: String::new(),
1614 game_version: "0".to_string(),
1615 network_mode: "both".to_string(),
1616 package_type: "other".to_string(),
1617 install_mode: "managed".to_string(),
1618 installed_at_time: 1,
1619 loaders: Vec::new(),
1620 dependencies: Vec::new(),
1621 incompatibilities: Vec::new(),
1622 optional_dependencies: Vec::new(),
1623 version_number: SemVer::parse("1.0.0"),
1624 enabled: true,
1625 icon: String::new(),
1626 online_source: false,
1627 trusted_package: false,
1628 }
1629 }
1630
1631 #[test]
1632 fn uninstalling_a_loader_pack_without_a_state_file_is_refused() {
1633 let dir = tempdir().unwrap();
1634 let target_dir = dir.path();
1635 let eco = crate::ecosystem::Ecosystem::bundled();
1636
1637 modlist::write(target_dir, &[profile_mod("denikson-BepInExPack_Valheim")]).unwrap();
1641
1642 let result = uninstall_mod_in(target_dir, &eco, "valheim", "denikson-BepInExPack_Valheim");
1643
1644 assert!(result.is_err());
1645
1646 let message = result.unwrap_err().to_string();
1647
1648 assert!(
1649 message.contains("denikson-BepInExPack_Valheim"),
1650 "the error must name the loader; got: {message}"
1651 );
1652 assert_eq!(modlist::read(target_dir).unwrap().len(), 1);
1655 }
1656
1657 #[test]
1658 fn uninstalling_a_regular_mod_without_a_state_file_still_works() {
1659 let dir = tempdir().unwrap();
1660 let target_dir = dir.path();
1661 let eco = crate::ecosystem::Ecosystem::bundled();
1662
1663 modlist::write(target_dir, &[profile_mod("Owner-Regular")]).unwrap();
1666
1667 uninstall_mod_in(target_dir, &eco, "valheim", "Owner-Regular").unwrap();
1668
1669 assert!(modlist::read(target_dir).unwrap().is_empty());
1670 }
1671
1672 #[test]
1673 fn check_removable_batch_reports_every_unremovable_name_with_its_reason() {
1674 let dir = tempdir().unwrap();
1675 let target_dir = dir.path();
1676 let eco = crate::ecosystem::Ecosystem::bundled();
1677
1678 modlist::write(
1679 target_dir,
1680 &[
1681 profile_mod("denikson-BepInExPack_Valheim"),
1682 profile_mod("Owner-Regular"),
1683 ],
1684 )
1685 .unwrap();
1686
1687 let state_path = crate::install::state_file_path(target_dir, "Owner-Regular");
1694
1695 std::fs::create_dir_all(state_path.parent().unwrap()).unwrap();
1696 std::fs::write(&state_path, "not: [valid").unwrap();
1697
1698 let names = vec![
1699 "denikson-BepInExPack_Valheim".to_string(),
1700 "Owner-Regular".to_string(),
1701 ];
1702
1703 let blocked = check_removable_batch(target_dir, &eco, &names);
1704
1705 assert_eq!(
1706 blocked.len(),
1707 2,
1708 "both un-removable names must be reported, not just the first; got {blocked:?}"
1709 );
1710
1711 let loader_refusal = blocked
1712 .iter()
1713 .find(|(name, _)| name == "denikson-BepInExPack_Valheim")
1714 .map(|(_, refusal)| refusal)
1715 .unwrap();
1716 let loader_path = state_file_path(target_dir, "denikson-BepInExPack_Valheim");
1717
1718 assert!(
1721 matches!(loader_refusal, NotRemovable::RecordAbsent { path, .. } if path == &loader_path),
1722 "expected an absent record carrying its expected path; got: {loader_refusal:?}"
1723 );
1724
1725 let regular_refusal = blocked
1726 .iter()
1727 .find(|(name, _)| name == "Owner-Regular")
1728 .map(|(_, refusal)| refusal)
1729 .unwrap();
1730
1731 assert!(
1732 matches!(regular_refusal, NotRemovable::RecordUnreadable { path, .. } if path == &state_path),
1733 "expected an unreadable record carrying the path it could not read; got: {regular_refusal:?}"
1734 );
1735 }
1736
1737 #[test]
1738 fn a_removability_refusal_keeps_todays_wording_when_it_becomes_an_error() {
1739 let dir = tempdir().unwrap();
1740 let target_dir = dir.path();
1741 let eco = crate::ecosystem::Ecosystem::bundled();
1742
1743 modlist::write(target_dir, &[profile_mod("denikson-BepInExPack_Valheim")]).unwrap();
1744
1745 let refusal = ensure_removable(target_dir, &eco, "denikson-BepInExPack_Valheim").unwrap_err();
1746 let path = state_file_path(target_dir, "denikson-BepInExPack_Valheim");
1747
1748 assert_eq!(
1751 Error::from(refusal).to_string(),
1752 format!(
1753 "Profile error: denikson-BepInExPack_Valheim is a mod loader with no \
1754 install record at {}, so its files cannot be removed exactly. Reinstall \
1755 it, then uninstall it again, or remove its files by hand.",
1756 path.display()
1757 )
1758 );
1759 }
1760
1761 #[test]
1762 fn a_reinstall_the_caller_did_not_name_keeps_its_disabled_state() {
1763 let eco = Ecosystem::bundled();
1764 let dir = tempdir().unwrap();
1765 let base = dir.path();
1766
1767 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1768
1769 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1770 let extracted = tempdir().unwrap();
1771
1772 write_file(&extracted.path().join("plugins/Base.dll"), b"dll");
1773
1774 install_extracted(
1775 base,
1776 &eco,
1777 "valheim",
1778 "Main",
1779 "Author-Base",
1780 "Author",
1781 &version("Author-Base"),
1782 extracted.path(),
1783 None,
1784 1,
1785 )
1786 .unwrap();
1787
1788 disable_mod(base, &eco, "valheim", "Main", "Author-Base").unwrap();
1789
1790 assert!(!modlist::read(&profile_dir).unwrap()[0].enabled);
1791 assert!(
1792 profile_dir
1793 .join("BepInEx/plugins/Author-Base/Base.dll.old")
1794 .exists(),
1795 "the disable must have suffixed the file, or this test proves nothing"
1796 );
1797
1798 let batch = plan_install_batch(&profile_dir, &["Author-Base".to_string()], &[]).unwrap();
1801
1802 assert_eq!(batch.protect_disabled, vec!["Author-Base".to_string()]);
1803
1804 install_extracted(
1805 base,
1806 &eco,
1807 "valheim",
1808 "Main",
1809 "Author-Base",
1810 "Author",
1811 &version("Author-Base"),
1812 extracted.path(),
1813 None,
1814 2,
1815 )
1816 .unwrap();
1817
1818 reapply_batch_disabled(&profile_dir, &eco, "valheim", &batch).unwrap();
1819
1820 let mods = modlist::read(&profile_dir).unwrap();
1821
1822 assert!(
1823 !mods[0].enabled,
1824 "a disabled dependency must not be re-enabled by an incidental reinstall"
1825 );
1826 assert!(
1827 profile_dir
1828 .join("BepInEx/plugins/Author-Base/Base.dll.old")
1829 .exists(),
1830 "the file must be left suffixed, not rewritten un-suffixed and orphaned"
1831 );
1832 }
1833
1834 #[test]
1835 fn planning_an_uninstall_batch_partitions_loaders_and_lists_untracked() {
1836 let eco = Ecosystem::bundled();
1837 let dir = tempdir().unwrap();
1838 let base = dir.path();
1839
1840 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1841
1842 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1843 let extracted = tempdir().unwrap();
1844
1845 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1846 install_extracted(
1847 base,
1848 &eco,
1849 "valheim",
1850 "Main",
1851 "Author-Mod",
1852 "Author",
1853 &version("Author-Mod"),
1854 extracted.path(),
1855 None,
1856 1,
1857 )
1858 .unwrap();
1859
1860 write_file(
1862 &profile_dir.join("BepInEx/plugins/Other-ByHand/x.dll"),
1863 b"dll",
1864 );
1865 write_file(&profile_dir.join("BepInEx/plugins/MMHOOK/h.dll"), b"dll");
1867
1868 let batch = plan_uninstall_batch(&profile_dir, &eco, "valheim", None).unwrap();
1869
1870 assert_eq!(batch.mods, vec!["Author-Mod".to_string()]);
1871 assert!(batch.loaders.is_empty());
1872 assert_eq!(
1873 batch.untracked,
1874 vec![std::path::PathBuf::from("BepInEx/plugins/Other-ByHand")]
1875 );
1876
1877 let outcome = uninstall_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
1878
1879 assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
1880 assert_eq!(outcome.succeeded, vec!["Author-Mod".to_string()]);
1881 assert!(modlist::read(&profile_dir).unwrap().is_empty());
1882 assert!(profile_dir.join("BepInEx/plugins/MMHOOK/h.dll").exists());
1883 }
1884
1885 #[test]
1886 fn uninstalling_a_batch_removes_the_loader_after_ordinary_mods_and_its_own_files() {
1887 let eco = Ecosystem::bundled();
1888 let dir = tempdir().unwrap();
1889 let base = dir.path();
1890 let loader = "denikson-BepInExPack_Valheim";
1891
1892 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1893
1894 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1895 let extracted = tempdir().unwrap();
1896
1897 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
1898 install_extracted(
1899 base,
1900 &eco,
1901 "valheim",
1902 "Main",
1903 "Author-Mod",
1904 "Author",
1905 &version("Author-Mod"),
1906 extracted.path(),
1907 None,
1908 1,
1909 )
1910 .unwrap();
1911
1912 let winhttp = profile_dir.join("winhttp.dll");
1918 let core_dll = profile_dir.join("BepInEx/core/BepInEx.dll");
1919
1920 write_file(&winhttp, b"proxy");
1921 write_file(&core_dll, b"core");
1922
1923 let mut mods = modlist::read(&profile_dir).unwrap();
1924
1925 modlist::upsert(
1926 &mut mods,
1927 mod_entry_from_version(loader, "denikson", &version(loader), 1),
1928 );
1929 modlist::write(&profile_dir, &mods).unwrap();
1930
1931 let tracker = crate::install::ModFileTracker {
1932 mod_name: loader.to_string(),
1933 files: vec![
1934 (
1935 "unused-cache-source-1".to_string(),
1936 "winhttp.dll".to_string(),
1937 ),
1938 (
1939 "unused-cache-source-2".to_string(),
1940 "BepInEx/core/BepInEx.dll".to_string(),
1941 ),
1942 ],
1943 };
1944
1945 write_state_file(&profile_dir, &tracker).unwrap();
1946
1947 let batch = plan_uninstall_batch(&profile_dir, &eco, "valheim", None).unwrap();
1948
1949 assert_eq!(batch.mods, vec!["Author-Mod".to_string()]);
1950 assert_eq!(batch.loaders, vec![loader.to_string()]);
1951
1952 let outcome = uninstall_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
1953
1954 assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
1955 assert_eq!(
1963 outcome.succeeded,
1964 vec!["Author-Mod".to_string(), loader.to_string()]
1965 );
1966 assert!(modlist::read(&profile_dir).unwrap().is_empty());
1967 assert!(!winhttp.exists());
1970 assert!(!core_dll.exists());
1971 }
1972
1973 #[test]
1974 fn planning_an_uninstall_batch_sorts_and_removes_two_recorded_loaders() {
1975 let eco = Ecosystem::bundled();
1976 let dir = tempdir().unwrap();
1977 let base = dir.path();
1978 let first_loader = "denikson-BepInExPack_Valheim";
1979 let second_loader = "1F31A-BepInEx_Valheim_Full";
1985
1986 crate::profile::layout::create(base, "valheim", "Main").unwrap();
1987
1988 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
1989
1990 let mut mods = Vec::new();
1991
1992 for loader in [first_loader, second_loader] {
1993 modlist::upsert(
1994 &mut mods,
1995 mod_entry_from_version(loader, "owner", &version(loader), 1),
1996 );
1997
1998 write_state_file(
2002 &profile_dir,
2003 &crate::install::ModFileTracker {
2004 mod_name: loader.to_string(),
2005 files: Vec::new(),
2006 },
2007 )
2008 .unwrap();
2009 }
2010
2011 modlist::write(&profile_dir, &mods).unwrap();
2012
2013 let batch = plan_uninstall_batch(&profile_dir, &eco, "valheim", None).unwrap();
2014
2015 assert!(batch.mods.is_empty());
2016 assert_eq!(
2018 batch.loaders,
2019 vec![second_loader.to_string(), first_loader.to_string()]
2020 );
2021
2022 let outcome = uninstall_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2023
2024 assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2025 assert!(modlist::read(&profile_dir).unwrap().is_empty());
2026 }
2027
2028 #[test]
2029 fn planning_a_toggle_batch_splits_changing_from_already_and_excludes_loaders() {
2030 let eco = Ecosystem::bundled();
2031 let dir = tempdir().unwrap();
2032 let base = dir.path();
2033 let loader = "denikson-BepInExPack_Valheim";
2034
2035 crate::profile::layout::create(base, "valheim", "Main").unwrap();
2036
2037 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2038 let extracted = tempdir().unwrap();
2039
2040 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2041
2042 for name in ["Author-One", "Author-Two"] {
2043 install_extracted(
2044 base,
2045 &eco,
2046 "valheim",
2047 "Main",
2048 name,
2049 "Author",
2050 &version(name),
2051 extracted.path(),
2052 None,
2053 1,
2054 )
2055 .unwrap();
2056 }
2057
2058 let mut mods = modlist::read(&profile_dir).unwrap();
2060
2061 mods.push(mod_entry_from_version(
2062 loader,
2063 "denikson",
2064 &version(loader),
2065 1,
2066 ));
2067 modlist::write(&profile_dir, &mods).unwrap();
2068
2069 disable_mod(base, &eco, "valheim", "Main", "Author-One").unwrap();
2070
2071 let batch = plan_set_enabled_batch(&profile_dir, &eco, None, false).unwrap();
2072
2073 assert_eq!(batch.changing, vec!["Author-Two".to_string()]);
2074 assert_eq!(batch.already, vec!["Author-One".to_string()]);
2075 assert_eq!(batch.loaders, vec![loader.to_string()]);
2076
2077 let outcome = set_enabled_batch(&profile_dir, &eco, "valheim", &batch).unwrap();
2078
2079 assert!(outcome.is_ok(), "failed: {:?}", outcome.failed);
2080 assert_eq!(outcome.succeeded, vec!["Author-Two".to_string()]);
2081
2082 let after = modlist::read(&profile_dir).unwrap();
2083
2084 assert!(
2085 !after
2086 .iter()
2087 .find(|m| m.name == "Author-Two")
2088 .unwrap()
2089 .enabled
2090 );
2091 assert!(after.iter().find(|m| m.name == loader).unwrap().enabled);
2093 }
2094
2095 #[test]
2096 fn plan_set_enabled_batch_refuses_an_unrecorded_name() {
2097 let eco = Ecosystem::bundled();
2098 let dir = tempdir().unwrap();
2099 let base = dir.path();
2100
2101 crate::profile::layout::create(base, "valheim", "Main").unwrap();
2102
2103 let profile_dir = crate::profile::layout::profile_dir(base, "valheim", "Main");
2104 let extracted = tempdir().unwrap();
2105
2106 write_file(&extracted.path().join("plugins/Mod.dll"), b"dll");
2107
2108 install_extracted(
2109 base,
2110 &eco,
2111 "valheim",
2112 "Main",
2113 "Author-One",
2114 "Author",
2115 &version("Author-One"),
2116 extracted.path(),
2117 None,
2118 1,
2119 )
2120 .unwrap();
2121
2122 let names = vec!["Author-Gone".to_string()];
2123 let error = plan_set_enabled_batch(&profile_dir, &eco, Some(&names), false).unwrap_err();
2124
2125 assert!(error.to_string().contains("Author-Gone"));
2126 assert!(error.to_string().contains("is not installed"));
2127 }
2128}