1use crate::hook_config::{MATCH_OPTIONS, matched_suffix};
22use crate::patch::{MemoryPatch, apply_all};
23use crate::{
24 DEFAULT_MODS_DIR_NAME, HookConfig, HookConfigError, RedirectionMode, RedirectionRule,
25 ResolvedAction, RuleTarget,
26};
27use glob::Pattern;
28use log::{debug, error, info, warn};
29use std::collections::HashSet;
30use std::path::{Path, PathBuf};
31
32const MAX_REDIRECT_CHAIN_DEPTH: usize = 8;
44
45#[derive(Debug)]
49pub struct HookResolver {
50 config: HookConfig,
51 game_dir: PathBuf,
52 temp_dir: PathBuf,
53}
54
55struct ChainOutcome {
57 terminal_path: Option<PathBuf>,
62 patches: Vec<MemoryPatch>,
66}
67
68impl HookResolver {
69 #[must_use]
74 pub fn new(config: HookConfig, game_dir: PathBuf, temp_dir: PathBuf) -> Self {
75 Self {
76 config,
77 game_dir,
78 temp_dir,
79 }
80 }
81
82 #[must_use]
97 pub fn load(
98 mods_dir: &Path,
99 game_dir: PathBuf,
100 temp_dir: PathBuf,
101 ) -> (Self, Vec<(String, HookConfigError)>) {
102 info!("Loading mods from: {}", mods_dir.display());
103
104 let (mut config, skipped) = HookConfig::load_from_mods_dir(mods_dir).unwrap_or_else(|e| {
105 debug!(
106 "Could not load {} ({e}); proceeding with no mods enabled",
107 mods_dir.display()
108 );
109 (HookConfig::default(), Vec::new())
110 });
111
112 for rule in &mut config.rules {
113 match &mut rule.target {
114 Some(RuleTarget::Path(target)) if target.is_relative() => {
115 *target = game_dir.join(&*target);
116 }
117 Some(RuleTarget::Managed { local, .. }) if local.is_relative() => {
118 *local = game_dir.join(&*local);
119 }
120 _ => {}
121 }
122 }
123
124 debug!("Loaded config: {config:#?}");
125
126 (Self::new(config, game_dir, temp_dir), skipped)
127 }
128
129 #[must_use]
133 pub fn load_default(
134 game_dir: PathBuf,
135 temp_dir: PathBuf,
136 ) -> (Self, Vec<(String, HookConfigError)>) {
137 let mods_dir = game_dir.join(DEFAULT_MODS_DIR_NAME);
138 Self::load(&mods_dir, game_dir, temp_dir)
139 }
140
141 #[must_use]
143 pub fn config(&self) -> &HookConfig {
144 &self.config
145 }
146
147 #[must_use]
150 pub fn game_dir(&self) -> &Path {
151 &self.game_dir
152 }
153
154 fn original_path(&self, rel_key: &str) -> PathBuf {
155 self.game_dir
156 .join(rel_key.replace('/', std::path::MAIN_SEPARATOR_STR))
157 }
158
159 fn structured_temp_path(&self, rel_key: &str) -> PathBuf {
167 self.temp_dir
168 .join(rel_key.replace('/', std::path::MAIN_SEPARATOR_STR))
169 }
170
171 fn next_matching_rule(
177 &self,
178 rel_key: &str,
179 start_index: usize,
180 ) -> Option<(usize, &RedirectionRule)> {
181 let normalized = rel_key.replace('\\', "/");
182 self.config
183 .rules
184 .get(start_index..)?
185 .iter()
186 .enumerate()
187 .find_map(|(i, rule)| {
188 Pattern::new(&rule.glob_pattern)
189 .is_ok_and(|pattern| pattern.matches_with(&normalized, MATCH_OPTIONS))
190 .then_some((start_index + i, rule))
191 })
192 }
193
194 fn rel_key_of(&self, path: &Path) -> Option<String> {
211 let game_dir_lower = self
212 .game_dir
213 .to_string_lossy()
214 .replace('\\', "/")
215 .trim_end_matches('/')
216 .to_lowercase();
217 let path_lower = path.to_string_lossy().replace('\\', "/").to_lowercase();
218
219 let rest = path_lower.strip_prefix(&game_dir_lower)?;
220 let rest = rest.strip_prefix('/').unwrap_or(rest);
221 (!rest.is_empty()).then(|| rest.to_string())
222 }
223
224 fn resolve_chain(&self, rel_key: &str) -> ChainOutcome {
271 let mut current = rel_key.to_string();
272 let mut visited: HashSet<String> = HashSet::from([current.clone()]);
273 let mut patches: Vec<MemoryPatch> = Vec::new();
274 let mut redirect_hops = 0usize;
275
276 loop {
277 let mut resume_index = 0;
278 loop {
279 let Some((idx, rule)) = self.next_matching_rule(¤t, resume_index) else {
280 return ChainOutcome {
282 terminal_path: Some(self.original_path(¤t)),
283 patches,
284 };
285 };
286
287 let structured_target = compute_target_path(rule, ¤t);
288 let is_redirect = structured_target.as_ref().is_some_and(|p| p.exists());
289
290 if is_redirect {
291 patches.extend(rule.patches.iter().cloned());
292 let target = structured_target.expect("is_redirect implies Some");
293
294 redirect_hops += 1;
295 if redirect_hops > MAX_REDIRECT_CHAIN_DEPTH {
296 warn!(
297 "Redirect chain resolving {rel_key} exceeded depth cap of \
298 {MAX_REDIRECT_CHAIN_DEPTH}, stopping at {}",
299 target.display()
300 );
301 return ChainOutcome {
302 terminal_path: Some(target),
303 patches,
304 };
305 }
306
307 match self.rel_key_of(&target) {
308 Some(next) if visited.insert(next.clone()) => {
309 current = next;
310 break; }
312 Some(_) => {
313 warn!(
314 "Redirect cycle detected resolving {rel_key}, stopping at {}",
315 target.display()
316 );
317 return ChainOutcome {
318 terminal_path: Some(target),
319 patches,
320 };
321 }
322 None => {
323 return ChainOutcome {
327 terminal_path: Some(target),
328 patches,
329 };
330 }
331 }
332 } else if rule.mode == RedirectionMode::HardError {
333 return ChainOutcome {
334 terminal_path: None,
335 patches: Vec::new(),
336 };
337 } else {
338 patches.extend(rule.patches.iter().cloned());
339 resume_index = idx + 1;
340 }
342 }
343 }
344 }
345
346 fn resolve_chain_for_target(
375 &self,
376 rel_key: &str,
377 ) -> Option<(&RedirectionRule, Option<PathBuf>)> {
378 let mut current = rel_key.to_string();
379 let mut visited: HashSet<String> = HashSet::from([current.clone()]);
380 let mut redirect_hops = 0usize;
381 let mut last_rule: Option<&RedirectionRule> = None;
382 let mut last_target: Option<PathBuf> = None;
393
394 loop {
395 let mut resume_index = 0;
396 loop {
397 let Some((idx, rule)) = self.next_matching_rule(¤t, resume_index) else {
398 return last_rule.map(|r| (r, last_target));
404 };
405 last_rule = Some(rule);
406
407 let Some(target) = compute_target_path(rule, ¤t) else {
408 resume_index = idx + 1;
410 continue;
411 };
412
413 redirect_hops += 1;
414 last_target = Some(target.clone());
415 if redirect_hops > MAX_REDIRECT_CHAIN_DEPTH {
416 warn!(
417 "Write/delete redirect chain resolving {rel_key} exceeded depth \
418 cap of {MAX_REDIRECT_CHAIN_DEPTH}, stopping at {}",
419 target.display()
420 );
421 return Some((rule, Some(target)));
422 }
423
424 match self.rel_key_of(&target) {
425 Some(next) if visited.insert(next.clone()) => {
426 current = next;
427 break; }
429 Some(_) => {
430 warn!(
431 "Write/delete redirect cycle resolving {rel_key}, stopping at {}",
432 target.display()
433 );
434 return Some((rule, Some(target)));
435 }
436 None => return Some((rule, Some(target))),
437 }
438 }
439 }
440 }
441
442 #[must_use]
462 pub fn resolve_path(&self, rel_key: &str) -> ResolvedAction {
463 let chain = self.resolve_chain(rel_key);
464
465 let Some(base_path) = chain.terminal_path else {
466 debug!("RESOLVE {rel_key} => HardErrorMissingFile (blocked)");
467 return ResolvedAction::HardErrorMissingFile;
468 };
469
470 let patches = chain.patches.as_slice();
471 let is_original = base_path == self.original_path(rel_key);
472
473 let no_patch_action = || {
476 if is_original {
477 ResolvedAction::PassThrough
478 } else {
479 ResolvedAction::Redirect {
480 replacement_path: base_path.clone(),
481 }
482 }
483 };
484
485 let final_action = if patches.is_empty() {
486 no_patch_action()
487 } else {
488 let temp_path = self.structured_temp_path(rel_key);
489
490 let has_script_patch = patches
496 .iter()
497 .any(|p| matches!(p, MemoryPatch::Script { .. }));
498
499 let needs_restage = if has_script_patch {
500 true
501 } else if temp_path.exists() {
502 match (std::fs::metadata(&base_path), std::fs::metadata(&temp_path)) {
503 (Ok(bm), Ok(tm)) => match (bm.modified(), tm.modified()) {
504 (Ok(bt), Ok(tt)) => bt > tt,
505 _ => true,
506 },
507 _ => true,
508 }
509 } else {
510 true
511 };
512
513 if needs_restage {
514 match (|| -> std::io::Result<()> {
515 if let Some(parent) = temp_path.parent() {
516 std::fs::create_dir_all(parent)?;
517 }
518 let data = std::fs::read(&base_path)?;
519
520 let patched = match apply_all(patches, &data, rel_key) {
522 Ok(p) => p,
523 Err(e) => {
524 error!(
525 "Patch application failed for {}: {}",
526 base_path.display(),
527 e
528 );
529 return Err(std::io::Error::other(e.to_string()));
530 }
531 };
532
533 std::fs::write(&temp_path, &patched)?;
534 Ok(())
535 })() {
536 Ok(()) => ResolvedAction::Redirect {
537 replacement_path: temp_path,
538 },
539 Err(e) => {
540 error!("IO error staging {}: {e}", base_path.display());
541 no_patch_action()
542 }
543 }
544 } else {
545 ResolvedAction::Redirect {
546 replacement_path: temp_path,
547 }
548 }
549 };
550
551 debug!("RESOLVE {rel_key} => {final_action:?}");
552 final_action
553 }
554
555 #[must_use]
574 pub fn should_hide_original_entry(&self, rel_key: &str) -> bool {
575 let chain = self.resolve_chain(rel_key);
576 match chain.terminal_path {
577 None => true, Some(base_path) => {
579 base_path != self.original_path(rel_key) || !chain.patches.is_empty()
580 }
581 }
582 }
583
584 #[must_use]
591 pub fn resolve_write_target(&self, rel_key: &str) -> Option<PathBuf> {
592 let (_rule, target_path) = self.resolve_chain_for_target(rel_key)?;
593 let target_path = target_path?;
594
595 let normalized_key = rel_key.to_lowercase();
596 if self.rel_key_of(&target_path).as_deref() == Some(normalized_key.as_str()) {
597 error!("Write target for {rel_key} resolves back to the original file");
598 return None;
599 }
600
601 if let Some(parent) = target_path.parent()
602 && let Err(e) = std::fs::create_dir_all(parent)
603 {
604 error!(
605 "Failed to create target directory {}: {e}",
606 parent.display()
607 );
608 return None;
609 }
610
611 Some(target_path)
612 }
613
614 #[must_use]
618 pub fn has_write_redirect(&self, rel_key: &str) -> bool {
619 self.resolve_chain_for_target(rel_key)
620 .is_some_and(|(_, target)| target.is_some())
621 }
622
623 #[must_use]
630 pub fn resolve_path_for_write(&self, rel_key: &str) -> ResolvedAction {
631 let Some((_rule, Some(target_path))) = self.resolve_chain_for_target(rel_key) else {
632 return ResolvedAction::PassThrough;
633 };
634
635 let normalized_key = rel_key.to_lowercase();
639 if self.rel_key_of(&target_path).as_deref() == Some(normalized_key.as_str()) {
640 error!("Write target for {rel_key} resolves back to the original file");
641 return ResolvedAction::HardErrorMissingFile;
642 }
643
644 if let Some(parent) = target_path.parent()
645 && let Err(e) = std::fs::create_dir_all(parent)
646 {
647 error!("Cannot prepare write target {}: {e}", target_path.display());
648 return ResolvedAction::HardErrorMissingFile;
649 }
650
651 let original_path = self.original_path(rel_key);
652 if original_path.exists() {
653 match seed_target_file(&original_path, &target_path) {
654 Ok(true) => debug!(
655 "WRITE SEED: copied original {} -> {}",
656 original_path.display(),
657 target_path.display()
658 ),
659 Ok(false) => debug!(
660 "WRITE SEED: {} already present, skipping",
661 target_path.display()
662 ),
663 Err(e) => {
664 error!("Failed to seed target file {}: {e}", target_path.display());
665 return ResolvedAction::HardErrorMissingFile;
666 }
667 }
668 } else if !target_path.exists() {
669 debug!(
670 "WRITE CREATE: original missing, caller will create new file at {}",
671 target_path.display()
672 );
673 }
674
675 debug!("WRITE RESOLVE {rel_key} -> {}", target_path.display());
676
677 ResolvedAction::Redirect {
678 replacement_path: target_path,
679 }
680 }
681
682 #[must_use]
708 pub fn resolve_path_for_delete(&self, rel_key: &str) -> ResolvedAction {
709 let Some((rule, target_path)) = self.resolve_chain_for_target(rel_key) else {
710 return ResolvedAction::PassThrough;
711 };
712
713 let final_action = match target_path {
714 Some(path) if path.exists() => ResolvedAction::Redirect {
715 replacement_path: path,
716 },
717 _ => match rule.mode {
718 RedirectionMode::HardError => ResolvedAction::HardErrorMissingFile,
719 RedirectionMode::GracefulFallback => ResolvedAction::PassThrough,
720 },
721 };
722
723 debug!("DELETE RESOLVE {rel_key} => {final_action:?}");
724 final_action
725 }
726
727 #[must_use]
733 pub fn collect_mod_files_in_dir(&self, rel_dir: &str) -> Vec<String> {
734 let mut files: Vec<String> = Vec::new();
735 let rel_dir_lower = rel_dir.to_lowercase();
736
737 for rule in &self.config.rules {
738 let Some(target) = rule.target.as_ref() else {
739 continue;
740 };
741 let glob_lower = rule.glob_pattern.to_lowercase();
742
743 if rule.is_literal_glob() {
744 let rule_dir = Path::new(&glob_lower)
745 .parent()
746 .map(|p| p.to_string_lossy().replace('\\', "/"))
747 .unwrap_or_default();
748 let listed = target.path().exists() || target.managed().is_some();
758 if rule_dir == rel_dir_lower
759 && listed
760 && let Some(name) = target.path().file_name().and_then(|n| n.to_str())
761 {
762 files.push(name.to_string());
763 }
764 continue;
765 }
766
767 let prefix_matches = if let Some(star_pos) = glob_lower.find("**") {
768 let prefix = &glob_lower[..star_pos];
769 rel_dir_lower.starts_with(prefix.trim_end_matches('/').trim_end_matches('\\'))
770 } else {
771 let rule_dir = Path::new(&glob_lower)
772 .parent()
773 .map(|p| p.to_string_lossy().replace('\\', "/"))
774 .unwrap_or_default();
775 rule_dir == rel_dir_lower
776 };
777
778 if !prefix_matches {
779 continue;
780 }
781
782 let scan_dir = if let Some(star_pos) = glob_lower.find("**") {
783 let prefix = &glob_lower[..star_pos];
784 let suffix = rel_dir_lower
785 .strip_prefix(prefix.trim_end_matches('/').trim_end_matches('\\'))
786 .unwrap_or("")
787 .trim_start_matches('/')
788 .trim_start_matches('\\');
789 if suffix.is_empty() {
790 target.path().to_path_buf()
791 } else {
792 target.path().join(suffix)
793 }
794 } else {
795 target.path().to_path_buf()
796 };
797
798 if let Ok(entries) = std::fs::read_dir(&scan_dir) {
799 for entry in entries.flatten() {
800 if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false)
801 && let Some(name) = entry.file_name().to_str()
802 && !name.starts_with('.')
806 {
807 files.push(name.to_string());
808 }
809 }
810 }
811 }
812
813 files.sort_unstable();
814 files.dedup();
815 files
816 }
817}
818
819#[must_use]
833pub fn is_write_operation(desired_access: u32, create_disposition: u32) -> bool {
834 const WRITE_ACCESS_MASK: u32 = 0x4000_0000 | 0x0000_0002 | 0x0000_0004;
835 const DESTRUCTIVE_DISPOSITIONS: [u32; 4] = [0, 2, 4, 5];
836
837 (desired_access & WRITE_ACCESS_MASK) != 0
838 || DESTRUCTIVE_DISPOSITIONS.contains(&create_disposition)
839}
840
841#[must_use]
856pub fn compute_target_path(rule: &RedirectionRule, rel_key: &str) -> Option<PathBuf> {
857 let target = rule.target.as_ref()?;
858 let path = target.path();
859
860 if rule.is_literal_glob() {
861 return Some(path.to_path_buf());
862 }
863
864 Some(path.join(matched_suffix(&rule.glob_pattern, rel_key)))
865}
866
867#[derive(Debug, Clone, PartialEq)]
874pub struct ManagedFileSpec {
875 pub rel_key: String,
878 pub local: PathBuf,
881 pub remote: String,
883 pub poll_interval_secs: Option<u64>,
886 pub push_url: Option<String>,
889}
890
891#[must_use]
900pub fn managed_file_specs(game_dir: &Path, config: &HookConfig) -> Vec<ManagedFileSpec> {
901 let mut out = Vec::new();
902 for rule in &config.rules {
903 let Some(target) = &rule.target else {
904 continue;
905 };
906 let Some((remote, poll_interval_secs, push_url)) = target.managed() else {
907 continue;
908 };
909 if !rule.is_literal_glob() {
910 continue;
911 }
912 let local = target.path();
913 let local_abs = if local.is_absolute() {
914 local.to_path_buf()
915 } else {
916 game_dir.join(local)
917 };
918 out.push(ManagedFileSpec {
919 rel_key: rule.glob_pattern.replace('\\', "/"),
920 local: local_abs,
921 remote: remote.to_string(),
922 poll_interval_secs,
923 push_url: push_url.map(str::to_string),
924 });
925 }
926 out
927}
928
929pub fn seed_target_file(original_path: &Path, target_path: &Path) -> std::io::Result<bool> {
946 if target_path.exists() {
947 return Ok(false);
948 }
949 let parent = target_path.parent().ok_or_else(|| {
950 std::io::Error::new(
951 std::io::ErrorKind::InvalidInput,
952 "write target has no parent",
953 )
954 })?;
955 let mut staged = tempfile::NamedTempFile::new_in(parent)?;
956 std::io::copy(
957 &mut std::fs::File::open(original_path)?,
958 staged.as_file_mut(),
959 )?;
960 staged.as_file_mut().sync_all()?;
961 match std::fs::hard_link(staged.path(), target_path) {
962 Ok(()) => Ok(true),
963 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
964 Err(e) => Err(e),
965 }
966}
967
968#[cfg(test)]
969mod chain_resolution_tests {
970 use super::HookResolver;
981 use crate::{
982 HookConfig, MemoryPatch, RedirectionMode, RedirectionRule, RuleTarget, StringReplacement,
983 };
984 use std::fs;
985 use std::path::PathBuf;
986
987 struct ScratchDir(PathBuf);
988
989 impl ScratchDir {
990 fn new(name: &str) -> Self {
991 let dir = std::env::temp_dir().join(format!(
992 "hook_resolver_chain_test_{name}_{}",
993 std::process::id()
994 ));
995 let _ = fs::remove_dir_all(&dir);
996 fs::create_dir_all(dir.join("game")).unwrap();
997 Self(dir)
998 }
999
1000 fn path(&self, name: &str) -> PathBuf {
1001 self.0.join(name)
1002 }
1003
1004 fn write_file(&self, name: &str, contents: &[u8]) {
1005 let path = self.path(name);
1006 fs::create_dir_all(path.parent().unwrap()).unwrap();
1007 fs::write(path, contents).unwrap();
1008 }
1009 }
1010
1011 impl Drop for ScratchDir {
1012 fn drop(&mut self) {
1013 let _ = fs::remove_dir_all(&self.0);
1014 }
1015 }
1016
1017 fn resolver_for(dir: &ScratchDir, rules: Vec<RedirectionRule>) -> HookResolver {
1018 HookResolver::new(HookConfig { rules }, dir.path("game"), dir.path("temp"))
1019 }
1020
1021 fn replace_patch(find: &str, replace: &str) -> MemoryPatch {
1022 MemoryPatch::ReplaceString {
1023 replacements: vec![StringReplacement {
1024 find: find.to_string(),
1025 replace: replace.to_string(),
1026 enable_if: None,
1027 }],
1028 }
1029 }
1030
1031 fn identity_rule(
1032 glob_pattern: &str,
1033 mode: RedirectionMode,
1034 patches: Vec<MemoryPatch>,
1035 ) -> RedirectionRule {
1036 RedirectionRule {
1037 glob_pattern: glob_pattern.into(),
1038 target: None,
1039 mode,
1040 patches,
1041 enable_if: None,
1042 }
1043 }
1044
1045 fn redirect_rule(
1046 glob_pattern: &str,
1047 target: PathBuf,
1048 mode: RedirectionMode,
1049 patches: Vec<MemoryPatch>,
1050 ) -> RedirectionRule {
1051 RedirectionRule {
1052 glob_pattern: glob_pattern.into(),
1053 target: Some(RuleTarget::Path(target)),
1054 mode,
1055 patches,
1056 enable_if: None,
1057 }
1058 }
1059
1060 #[test]
1061 fn identity_hop_with_patches_and_no_target_still_patches_original() {
1062 let dir = ScratchDir::new("regression_detail_config");
1065 dir.write_file("game/Data/Detail_Config.txt", b"amount_of_snowflakes\t= 1;");
1066
1067 let resolver = resolver_for(
1068 &dir,
1069 vec![identity_rule(
1070 "Data/Detail_Config.txt",
1071 RedirectionMode::GracefulFallback,
1072 vec![replace_patch(
1073 "amount_of_snowflakes\t= 1;",
1074 "amount_of_snowflakes = 10.0;",
1075 )],
1076 )],
1077 );
1078
1079 let action = resolver.resolve_path("Data/Detail_Config.txt");
1080 let crate::ResolvedAction::Redirect { replacement_path } = action else {
1081 panic!("expected a staged patch redirect, got {action:?}");
1082 };
1083 assert_eq!(
1084 fs::read(&replacement_path).unwrap(),
1085 b"amount_of_snowflakes = 10.0;"
1086 );
1087 }
1088
1089 #[test]
1090 fn two_identity_hops_stack_patches_in_rule_order() {
1091 let dir = ScratchDir::new("two_identity_hops");
1092 dir.write_file("game/data/levels/Object_Data.txt", b"base");
1093
1094 let resolver = resolver_for(
1095 &dir,
1096 vec![
1097 identity_rule(
1098 "**/Object_Data.txt",
1099 RedirectionMode::GracefulFallback,
1100 vec![replace_patch("base", "base+ring")],
1101 ),
1102 identity_rule(
1103 "**/Object_Data.txt",
1104 RedirectionMode::GracefulFallback,
1105 vec![replace_patch("ring", "ring+lighting")],
1106 ),
1107 ],
1108 );
1109
1110 let action = resolver.resolve_path("data/levels/Object_Data.txt");
1111 let crate::ResolvedAction::Redirect { replacement_path } = action else {
1112 panic!("expected a staged patch redirect, got {action:?}");
1113 };
1114 assert_eq!(fs::read(&replacement_path).unwrap(), b"base+ring+lighting");
1115 }
1116
1117 #[test]
1118 fn redirect_hop_then_identity_hops_stack_on_top() {
1119 let dir = ScratchDir::new("redirect_then_identity");
1123 dir.write_file("game/campaign/level1.dat", b"unused");
1124 dir.write_file("game/campaign/training.dat", b"training level");
1125 dir.write_file("game/campaign/Object_Data.txt", b"base");
1126
1127 let resolver = resolver_for(
1128 &dir,
1129 vec![
1130 redirect_rule(
1131 "campaign/level1.dat",
1132 dir.path("game/campaign/training.dat"),
1133 RedirectionMode::GracefulFallback,
1134 vec![],
1135 ),
1136 identity_rule(
1137 "**/Object_Data.txt",
1138 RedirectionMode::GracefulFallback,
1139 vec![replace_patch("base", "base+ring")],
1140 ),
1141 identity_rule(
1142 "**/Object_Data.txt",
1143 RedirectionMode::GracefulFallback,
1144 vec![replace_patch("ring", "ring+lighting")],
1145 ),
1146 ],
1147 );
1148
1149 let action = resolver.resolve_path("campaign/Object_Data.txt");
1155 let crate::ResolvedAction::Redirect { replacement_path } = action else {
1156 panic!("expected a staged patch redirect, got {action:?}");
1157 };
1158 assert_eq!(fs::read(&replacement_path).unwrap(), b"base+ring+lighting");
1159
1160 assert_eq!(
1162 resolver.resolve_path("campaign/level1.dat"),
1163 crate::ResolvedAction::Redirect {
1164 replacement_path: dir.path("game/campaign/training.dat")
1165 }
1166 );
1167 }
1168
1169 #[test]
1170 fn redirecting_rules_own_patches_apply_at_the_end_not_immediately() {
1171 let dir = ScratchDir::new("deferred_patch");
1175 fs::create_dir_all(dir.path("game/mid")).unwrap();
1176 fs::create_dir_all(dir.path("game/final")).unwrap();
1177 fs::write(dir.path("game/mid/file.txt"), b"unused").unwrap();
1178 fs::write(dir.path("game/final/file.txt"), b"final content").unwrap();
1179
1180 let resolver = resolver_for(
1181 &dir,
1182 vec![
1183 redirect_rule(
1184 "start/file.txt",
1185 dir.path("game/mid/file.txt"),
1186 RedirectionMode::GracefulFallback,
1187 vec![replace_patch("final", "FINAL")],
1188 ),
1189 redirect_rule(
1190 "mid/file.txt",
1191 dir.path("game/final/file.txt"),
1192 RedirectionMode::GracefulFallback,
1193 vec![],
1194 ),
1195 ],
1196 );
1197
1198 let action = resolver.resolve_path("start/file.txt");
1199 let crate::ResolvedAction::Redirect { replacement_path } = action else {
1200 panic!("expected a staged patch redirect, got {action:?}");
1201 };
1202 assert_eq!(fs::read(&replacement_path).unwrap(), b"FINAL content");
1203 }
1204
1205 #[test]
1206 fn hard_error_identity_hop_blocks_regardless_of_prior_patches() {
1207 let dir = ScratchDir::new("hard_error_blocks");
1208 dir.write_file("game/data/thing.txt", b"base");
1209
1210 let resolver = resolver_for(
1211 &dir,
1212 vec![
1213 identity_rule(
1214 "data/thing.txt",
1215 RedirectionMode::GracefulFallback,
1216 vec![replace_patch("base", "patched")],
1217 ),
1218 identity_rule("data/thing.txt", RedirectionMode::HardError, vec![]),
1219 ],
1220 );
1221
1222 assert_eq!(
1223 resolver.resolve_path("data/thing.txt"),
1224 crate::ResolvedAction::HardErrorMissingFile
1225 );
1226 }
1227
1228 #[test]
1229 fn chain_cycle_is_broken() {
1230 let dir = ScratchDir::new("cycle");
1231 fs::write(dir.path("game/a.txt"), b"a").unwrap();
1232 fs::write(dir.path("game/b.txt"), b"b").unwrap();
1233
1234 let resolver = resolver_for(
1235 &dir,
1236 vec![
1237 redirect_rule(
1238 "a.txt",
1239 dir.path("game/b.txt"),
1240 RedirectionMode::GracefulFallback,
1241 vec![],
1242 ),
1243 redirect_rule(
1244 "b.txt",
1245 dir.path("game/a.txt"),
1246 RedirectionMode::GracefulFallback,
1247 vec![],
1248 ),
1249 ],
1250 );
1251
1252 let action = resolver.resolve_path("a.txt");
1255 assert_eq!(action, crate::ResolvedAction::PassThrough);
1256 }
1257
1258 #[test]
1259 fn chain_depth_cap_is_enforced() {
1260 let dir = ScratchDir::new("depth_cap");
1264 let chain_len = 20;
1265 let mut rules = Vec::new();
1266 for i in 0..chain_len {
1267 let from = format!("f{i}.txt");
1268 let to_path = dir.path(&format!("game/f{}.txt", i + 1));
1269 fs::write(&to_path, format!("content {}", i + 1)).unwrap();
1270 rules.push(redirect_rule(
1271 &from,
1272 to_path,
1273 RedirectionMode::GracefulFallback,
1274 vec![],
1275 ));
1276 }
1277 fs::write(dir.path("game/f0.txt"), b"content 0").unwrap();
1278
1279 let resolver = resolver_for(&dir, rules);
1280
1281 let action = resolver.resolve_path("f0.txt");
1283 let crate::ResolvedAction::Redirect { replacement_path } = action else {
1284 panic!("expected a redirect, got {action:?}");
1285 };
1286 assert_ne!(
1287 replacement_path,
1288 dir.path(&format!("game/f{chain_len}.txt")),
1289 "depth cap should have stopped the chain before reaching the end"
1290 );
1291 }
1292
1293 #[test]
1294 fn chain_does_not_special_case_mod_space_targets() {
1295 let dir = ScratchDir::new("mod_space");
1302 fs::create_dir_all(dir.path("mods/some_mod")).unwrap();
1303 fs::write(dir.path("mods/some_mod/file.txt"), b"mod content").unwrap();
1304
1305 let resolver = resolver_for(
1306 &dir,
1307 vec![redirect_rule(
1308 "data/file.txt",
1309 dir.path("mods/some_mod/file.txt"),
1310 RedirectionMode::GracefulFallback,
1311 vec![],
1312 )],
1313 );
1314
1315 assert_eq!(
1316 resolver.resolve_path("data/file.txt"),
1317 crate::ResolvedAction::Redirect {
1318 replacement_path: dir.path("mods/some_mod/file.txt")
1319 }
1320 );
1321 }
1322
1323 #[test]
1324 fn chain_does_chain_when_mod_space_target_lands_back_under_game_dir() {
1325 let dir = ScratchDir::new("mod_space_chains_too");
1330 fs::create_dir_all(dir.path("game/.supreme/mods/some_mod")).unwrap();
1331 fs::write(
1332 dir.path("game/.supreme/mods/some_mod/file.txt"),
1333 b"mod content",
1334 )
1335 .unwrap();
1336 fs::write(dir.path("game/final.txt"), b"final content").unwrap();
1337
1338 let resolver = resolver_for(
1339 &dir,
1340 vec![
1341 redirect_rule(
1342 "data/file.txt",
1343 dir.path("game/.supreme/mods/some_mod/file.txt"),
1344 RedirectionMode::GracefulFallback,
1345 vec![],
1346 ),
1347 redirect_rule(
1348 ".supreme/mods/some_mod/file.txt",
1349 dir.path("game/final.txt"),
1350 RedirectionMode::GracefulFallback,
1351 vec![],
1352 ),
1353 ],
1354 );
1355
1356 assert_eq!(
1357 resolver.resolve_path("data/file.txt"),
1358 crate::ResolvedAction::Redirect {
1359 replacement_path: dir.path("game/final.txt")
1360 }
1361 );
1362 }
1363
1364 #[test]
1365 fn non_script_patches_reuse_the_cached_stage_when_base_is_unchanged() {
1366 let dir = ScratchDir::new("non_script_cache_reuse");
1371 dir.write_file("game/data/levels/Object_Data.txt", b"base");
1372
1373 let resolver = resolver_for(
1374 &dir,
1375 vec![identity_rule(
1376 "**/Object_Data.txt",
1377 RedirectionMode::GracefulFallback,
1378 vec![replace_patch("base", "patched")],
1379 )],
1380 );
1381
1382 let action = resolver.resolve_path("data/levels/Object_Data.txt");
1383 let crate::ResolvedAction::Redirect { replacement_path } = action else {
1384 panic!("expected a staged patch redirect, got {action:?}");
1385 };
1386 assert_eq!(fs::read(&replacement_path).unwrap(), b"patched");
1387 let mtime_after_first_call = fs::metadata(&replacement_path).unwrap().modified().unwrap();
1388
1389 std::thread::sleep(std::time::Duration::from_millis(5));
1390 let action2 = resolver.resolve_path("data/levels/Object_Data.txt");
1391 assert_eq!(
1392 action2,
1393 crate::ResolvedAction::Redirect {
1394 replacement_path: replacement_path.clone()
1395 }
1396 );
1397 let mtime_after_second_call = fs::metadata(&replacement_path).unwrap().modified().unwrap();
1398 assert_eq!(
1399 mtime_after_first_call, mtime_after_second_call,
1400 "a non-script patch's stage should not be rewritten when the base file hasn't changed"
1401 );
1402 }
1403
1404 #[test]
1405 fn script_patches_are_rerun_on_every_read_even_when_the_base_file_is_unchanged() {
1406 let dir = ScratchDir::new("script_rerun_every_read");
1415 dir.write_file("game/data/levels/Object_Data.txt", b"base");
1416
1417 let script_patch = MemoryPatch::Script {
1418 source: crate::ScriptSource::Inline {
1419 inline: "`${input}${timestamp}`".to_string(),
1420 },
1421 parameters: vec![],
1422 configuration: std::collections::HashMap::new(),
1423 };
1424
1425 let resolver = resolver_for(
1426 &dir,
1427 vec![identity_rule(
1428 "**/Object_Data.txt",
1429 RedirectionMode::GracefulFallback,
1430 vec![script_patch],
1431 )],
1432 );
1433
1434 let action1 = resolver.resolve_path("data/levels/Object_Data.txt");
1435 let crate::ResolvedAction::Redirect {
1436 replacement_path: path1,
1437 } = action1
1438 else {
1439 panic!("expected a staged patch redirect, got {action1:?}");
1440 };
1441 let first = fs::read_to_string(&path1).unwrap();
1442 assert!(first.starts_with("base"));
1443
1444 std::thread::sleep(std::time::Duration::from_millis(5));
1445
1446 let action2 = resolver.resolve_path("data/levels/Object_Data.txt");
1447 let crate::ResolvedAction::Redirect {
1448 replacement_path: path2,
1449 } = action2
1450 else {
1451 panic!("expected a staged patch redirect, got {action2:?}");
1452 };
1453 let second = fs::read_to_string(&path2).unwrap();
1454
1455 assert_eq!(
1456 path1, path2,
1457 "the same rel_key should always stage to the same temp path"
1458 );
1459 assert_ne!(
1460 first, second,
1461 "a script patch must re-run (fresh timestamp) on every read, not reuse a cached stage"
1462 );
1463 }
1464
1465 #[test]
1466 fn script_patches_receive_the_rel_key_as_file_path() {
1467 let dir = ScratchDir::new("script_file_path_var");
1468 dir.write_file("game/data/levels/Object_Data.txt", b"ignored");
1469
1470 let script_patch = MemoryPatch::Script {
1471 source: crate::ScriptSource::Inline {
1472 inline: "file_path".to_string(),
1473 },
1474 parameters: vec![],
1475 configuration: std::collections::HashMap::new(),
1476 };
1477
1478 let resolver = resolver_for(
1479 &dir,
1480 vec![identity_rule(
1481 "**/Object_Data.txt",
1482 RedirectionMode::GracefulFallback,
1483 vec![script_patch],
1484 )],
1485 );
1486
1487 let action = resolver.resolve_path("data/levels/Object_Data.txt");
1488 let crate::ResolvedAction::Redirect { replacement_path } = action else {
1489 panic!("expected a staged patch redirect, got {action:?}");
1490 };
1491 assert_eq!(
1492 fs::read_to_string(&replacement_path).unwrap(),
1493 "data/levels/Object_Data.txt"
1494 );
1495 }
1496}
1497
1498#[cfg(test)]
1499mod is_write_operation_tests {
1500 use super::is_write_operation;
1501
1502 const GENERIC_READ: u32 = 0x8000_0000;
1503 const GENERIC_WRITE: u32 = 0x4000_0000;
1504 const FILE_WRITE_DATA: u32 = 0x0000_0002;
1505 const FILE_APPEND_DATA: u32 = 0x0000_0004;
1506 const FILE_READ_DATA: u32 = 0x0000_0001;
1507
1508 const SUPERSEDE: u32 = 0;
1509 const OPEN: u32 = 1;
1510 const CREATE: u32 = 2;
1511 const OPEN_IF: u32 = 3;
1512 const OVERWRITE: u32 = 4;
1513 const OVERWRITE_IF: u32 = 5;
1514
1515 #[test]
1516 fn destructive_dispositions_are_always_writes_even_with_read_only_access() {
1517 for disposition in [SUPERSEDE, CREATE, OVERWRITE, OVERWRITE_IF] {
1518 assert!(
1519 is_write_operation(GENERIC_READ, disposition),
1520 "disposition {disposition} should be a write regardless of access mask"
1521 );
1522 }
1523 }
1524
1525 #[test]
1526 fn open_with_read_only_access_is_not_a_write() {
1527 assert!(!is_write_operation(GENERIC_READ, OPEN));
1528 assert!(!is_write_operation(FILE_READ_DATA, OPEN));
1529 }
1530
1531 #[test]
1532 fn open_if_with_read_only_access_is_not_a_write() {
1533 assert!(!is_write_operation(GENERIC_READ, OPEN_IF));
1534 }
1535
1536 #[test]
1537 fn open_with_generic_write_access_is_a_write() {
1538 assert!(is_write_operation(GENERIC_WRITE, OPEN));
1539 }
1540
1541 #[test]
1542 fn open_with_file_write_data_access_is_a_write() {
1543 assert!(is_write_operation(FILE_WRITE_DATA, OPEN));
1544 }
1545
1546 #[test]
1547 fn open_if_with_file_append_data_access_is_a_write() {
1548 assert!(is_write_operation(FILE_APPEND_DATA, OPEN_IF));
1549 }
1550
1551 #[test]
1552 fn open_with_combined_read_and_write_access_is_a_write() {
1553 assert!(is_write_operation(GENERIC_READ | GENERIC_WRITE, OPEN));
1554 }
1555
1556 #[test]
1557 fn no_access_bits_and_non_destructive_disposition_is_not_a_write() {
1558 assert!(!is_write_operation(0, OPEN));
1559 }
1560}
1561
1562#[cfg(test)]
1563mod seed_target_file_tests {
1564 use super::seed_target_file;
1565 use std::fs;
1566
1567 struct ScratchDir(std::path::PathBuf);
1568
1569 impl ScratchDir {
1570 fn new(name: &str) -> Self {
1571 let dir = std::env::temp_dir()
1572 .join(format!("hook_resolver_test_{name}_{}", std::process::id()));
1573 let _ = fs::remove_dir_all(&dir);
1574 fs::create_dir_all(&dir).unwrap();
1575 Self(dir)
1576 }
1577
1578 fn path(&self, name: &str) -> std::path::PathBuf {
1579 self.0.join(name)
1580 }
1581 }
1582
1583 impl Drop for ScratchDir {
1584 fn drop(&mut self) {
1585 let _ = fs::remove_dir_all(&self.0);
1586 }
1587 }
1588
1589 #[test]
1590 fn seeds_target_with_original_contents_when_target_is_absent() {
1591 let dir = ScratchDir::new("seeds_when_absent");
1592 let original = dir.path("original.dat");
1593 let target = dir.path("target.dat");
1594 fs::write(&original, b"original bytes").unwrap();
1595
1596 let seeded = seed_target_file(&original, &target).unwrap();
1597
1598 assert!(seeded, "first caller should perform the seed");
1599 assert_eq!(fs::read(&target).unwrap(), b"original bytes");
1600 }
1601
1602 #[test]
1603 fn does_not_touch_target_when_it_already_exists() {
1604 let dir = ScratchDir::new("skips_when_present");
1605 let original = dir.path("original.dat");
1606 let target = dir.path("target.dat");
1607 fs::write(&original, b"original bytes").unwrap();
1608 fs::write(&target, b"already-written save data").unwrap();
1609
1610 let seeded = seed_target_file(&original, &target).unwrap();
1611
1612 assert!(!seeded);
1613 assert_eq!(fs::read(&target).unwrap(), b"already-written save data");
1614 }
1615
1616 #[test]
1617 fn second_call_after_a_successful_seed_is_a_no_op() {
1618 let dir = ScratchDir::new("second_call_is_noop");
1619 let original = dir.path("original.dat");
1620 let target = dir.path("target.dat");
1621 fs::write(&original, b"original bytes").unwrap();
1622
1623 assert!(seed_target_file(&original, &target).unwrap());
1624 fs::write(&target, b"game already wrote new save data here").unwrap();
1625
1626 let seeded_again = seed_target_file(&original, &target).unwrap();
1627
1628 assert!(!seeded_again);
1629 assert_eq!(
1630 fs::read(&target).unwrap(),
1631 b"game already wrote new save data here"
1632 );
1633 }
1634
1635 #[test]
1636 fn propagates_an_error_when_the_original_cannot_be_read() {
1637 let dir = ScratchDir::new("missing_original_errors");
1638 let original = dir.path("does_not_exist.dat");
1639 let target = dir.path("target.dat");
1640
1641 let result = seed_target_file(&original, &target);
1642
1643 assert!(result.is_err());
1644 assert!(!target.exists());
1645 }
1646
1647 #[test]
1648 fn concurrent_seeds_publish_only_complete_original_bytes() {
1649 let dir = ScratchDir::new("concurrent_complete_seeds");
1650 let original = dir.path("original.dat");
1651 let target = dir.path("target.dat");
1652 let expected = vec![0x5au8; 2 * 1024 * 1024];
1653 fs::write(&original, &expected).unwrap();
1654 let barrier = std::sync::Arc::new(std::sync::Barrier::new(5));
1655 let results = std::thread::scope(|scope| {
1656 let threads: Vec<_> = (0..4)
1657 .map(|_| {
1658 let barrier = barrier.clone();
1659 let original = original.clone();
1660 let target = target.clone();
1661 scope.spawn(move || {
1662 barrier.wait();
1663 let seeded = seed_target_file(&original, &target).unwrap();
1664 assert_eq!(fs::metadata(&target).unwrap().len(), 2 * 1024 * 1024);
1665 seeded
1666 })
1667 })
1668 .collect();
1669 barrier.wait();
1670 threads
1671 .into_iter()
1672 .map(|thread| thread.join().unwrap())
1673 .collect::<Vec<_>>()
1674 });
1675 assert_eq!(results.into_iter().filter(|seeded| *seeded).count(), 1);
1676 assert_eq!(fs::read(target).unwrap(), expected);
1677 }
1678}
1679
1680#[cfg(test)]
1681mod write_seed_failure_tests {
1682 use super::*;
1683 use crate::{HookConfig, RedirectionMode, RedirectionRule, ResolvedAction};
1684
1685 #[test]
1686 fn partial_write_starts_with_original_and_leaves_original_untouched() {
1687 let dir = tempfile::tempdir().unwrap();
1688 let game = dir.path().join("game");
1689 std::fs::create_dir_all(game.join("data")).unwrap();
1690 std::fs::write(game.join("data/save.dat"), b"original bytes").unwrap();
1691 let target = game.join("mod/save.dat");
1692 let resolver = HookResolver::new(
1693 HookConfig {
1694 rules: vec![RedirectionRule {
1695 glob_pattern: "data/save.dat".into(),
1696 target: Some(RuleTarget::Path(target.clone())),
1697 mode: RedirectionMode::GracefulFallback,
1698 patches: vec![],
1699 enable_if: None,
1700 }],
1701 },
1702 game.clone(),
1703 dir.path().join("temp"),
1704 );
1705
1706 assert_eq!(
1707 resolver.resolve_path_for_write("data/save.dat"),
1708 ResolvedAction::Redirect {
1709 replacement_path: target.clone()
1710 }
1711 );
1712 let mut bytes = std::fs::read(&target).unwrap();
1713 bytes[0..7].copy_from_slice(b"updated");
1714 std::fs::write(&target, bytes).unwrap();
1715 assert_eq!(std::fs::read(&target).unwrap(), b"updatedl bytes");
1716 assert_eq!(
1717 std::fs::read(game.join("data/save.dat")).unwrap(),
1718 b"original bytes"
1719 );
1720 }
1721
1722 #[test]
1723 fn target_preparation_failure_blocks_write_even_in_graceful_mode() {
1724 let dir = tempfile::tempdir().unwrap();
1725 let game = dir.path().join("game");
1726 std::fs::create_dir_all(game.join("data")).unwrap();
1727 std::fs::write(game.join("data/save.dat"), b"original bytes").unwrap();
1728 let blocker = game.join("blocker");
1729 std::fs::write(&blocker, b"a file, not a directory").unwrap();
1730 let resolver = HookResolver::new(
1731 HookConfig {
1732 rules: vec![RedirectionRule {
1733 glob_pattern: "data/save.dat".into(),
1734 target: Some(RuleTarget::Path(blocker.join("save.dat"))),
1735 mode: RedirectionMode::GracefulFallback,
1736 patches: vec![],
1737 enable_if: None,
1738 }],
1739 },
1740 game.clone(),
1741 dir.path().join("temp"),
1742 );
1743 assert_eq!(
1744 resolver.resolve_path_for_write("data/save.dat"),
1745 ResolvedAction::HardErrorMissingFile
1746 );
1747 assert!(resolver.has_write_redirect("data/save.dat"));
1748 assert!(resolver.resolve_write_target("data/save.dat").is_none());
1749 assert_eq!(
1750 std::fs::read(game.join("data/save.dat")).unwrap(),
1751 b"original bytes"
1752 );
1753 }
1754
1755 #[test]
1756 fn unreadable_original_blocks_redirect_instead_of_publishing_partial_target() {
1757 let dir = tempfile::tempdir().unwrap();
1758 let game = dir.path().join("game");
1759 std::fs::create_dir_all(game.join("data/save.dat")).unwrap();
1760 let target = game.join("mod/save.dat");
1761 let resolver = HookResolver::new(
1762 HookConfig {
1763 rules: vec![RedirectionRule {
1764 glob_pattern: "data/save.dat".into(),
1765 target: Some(RuleTarget::Path(target.clone())),
1766 mode: RedirectionMode::GracefulFallback,
1767 patches: vec![],
1768 enable_if: None,
1769 }],
1770 },
1771 game,
1772 dir.path().join("temp"),
1773 );
1774 assert_eq!(
1775 resolver.resolve_path_for_write("data/save.dat"),
1776 ResolvedAction::HardErrorMissingFile
1777 );
1778 assert!(!target.exists());
1779 }
1780
1781 #[test]
1782 fn self_redirect_cannot_write_into_original_file() {
1783 let dir = tempfile::tempdir().unwrap();
1784 let game = dir.path().join("game");
1785 std::fs::create_dir_all(game.join("data")).unwrap();
1786 let original = game.join("data/save.dat");
1787 std::fs::write(&original, b"original bytes").unwrap();
1788 let resolver = HookResolver::new(
1789 HookConfig {
1790 rules: vec![RedirectionRule {
1791 glob_pattern: "data/save.dat".into(),
1792 target: Some(RuleTarget::Path(original.clone())),
1793 mode: RedirectionMode::GracefulFallback,
1794 patches: vec![],
1795 enable_if: None,
1796 }],
1797 },
1798 game,
1799 dir.path().join("temp"),
1800 );
1801 assert_eq!(
1802 resolver.resolve_path_for_write("data/save.dat"),
1803 ResolvedAction::HardErrorMissingFile
1804 );
1805 assert!(resolver.has_write_redirect("data/save.dat"));
1806 assert!(resolver.resolve_write_target("data/save.dat").is_none());
1807 assert_eq!(std::fs::read(original).unwrap(), b"original bytes");
1808 }
1809}
1810
1811#[cfg(test)]
1812mod compute_target_path_tests {
1813 use super::compute_target_path;
1814 use crate::{RedirectionMode, RedirectionRule, RuleTarget};
1815 use std::path::PathBuf;
1816
1817 fn rule(glob_pattern: &str, target: &str) -> RedirectionRule {
1818 RedirectionRule {
1819 glob_pattern: glob_pattern.into(),
1820 target: Some(RuleTarget::Path(PathBuf::from(target))),
1821 mode: RedirectionMode::GracefulFallback,
1822 patches: vec![],
1823 enable_if: None,
1824 }
1825 }
1826
1827 #[test]
1828 fn double_star_preserves_subdirectory_structure() {
1829 let rule = rule("data/levels/**", "mods/levels");
1830 assert_eq!(
1831 compute_target_path(&rule, "data/levels/alpine/tracks/easy/replays/r2.dat"),
1832 Some(PathBuf::from(
1833 "mods/levels/alpine/tracks/easy/replays/r2.dat"
1834 ))
1835 );
1836 }
1837
1838 #[test]
1845 fn single_star_mid_path_preserves_subdirectory_structure() {
1846 let rule = rule("data/levels/*/ghost/*.dat", "mods/ghosts");
1847 assert_eq!(
1848 compute_target_path(&rule, "data/levels/alpine/ghost/run_42.dat"),
1849 Some(PathBuf::from("mods/ghosts/alpine/ghost/run_42.dat"))
1850 );
1851 }
1852
1853 #[test]
1854 fn single_star_last_segment_reduces_to_basename() {
1855 let rule = rule("data/levels/*.dat", "mods/levels");
1859 assert_eq!(
1860 compute_target_path(&rule, "data/levels/level1.dat"),
1861 Some(PathBuf::from("mods/levels/level1.dat"))
1862 );
1863 }
1864
1865 #[test]
1866 fn literal_glob_uses_target_directly() {
1867 let rule = rule("Saved_Data/high_times.txt", "mods/high_times_custom.txt");
1868 assert_eq!(
1869 compute_target_path(&rule, "saved_data/high_times.txt"),
1870 Some(PathBuf::from("mods/high_times_custom.txt"))
1871 );
1872 }
1873}
1874
1875#[cfg(test)]
1876mod write_then_delete_tests {
1877 use super::HookResolver;
1887 use crate::{HookConfig, RedirectionMode, RedirectionRule, RuleTarget};
1888 use std::fs;
1889 use std::path::PathBuf;
1890
1891 struct ScratchDir(PathBuf);
1892
1893 impl ScratchDir {
1894 fn new(name: &str) -> Self {
1895 let dir = std::env::temp_dir().join(format!(
1896 "hook_resolver_write_delete_test_{name}_{}",
1897 std::process::id()
1898 ));
1899 let _ = fs::remove_dir_all(&dir);
1900 fs::create_dir_all(&dir).unwrap();
1901 Self(dir)
1902 }
1903
1904 fn path(&self, name: &str) -> PathBuf {
1905 self.0.join(name)
1906 }
1907 }
1908
1909 impl Drop for ScratchDir {
1910 fn drop(&mut self) {
1911 let _ = fs::remove_dir_all(&self.0);
1912 }
1913 }
1914
1915 fn resolver_for(dir: &ScratchDir, glob_pattern: &str, mode: RedirectionMode) -> HookResolver {
1916 let config = HookConfig {
1917 rules: vec![RedirectionRule {
1918 glob_pattern: glob_pattern.into(),
1919 target: Some(RuleTarget::Path(dir.path("mods_ghosts"))),
1920 mode,
1921 patches: vec![],
1922 enable_if: None,
1923 }],
1924 };
1925 HookResolver::new(config, dir.path("game"), dir.path("temp"))
1926 }
1927
1928 #[test]
1929 fn delete_finds_the_same_file_a_wildcard_write_created() {
1930 let dir = ScratchDir::new("delete_finds_write");
1931 let resolver = resolver_for(
1932 &dir,
1933 "data/levels/*/ghost/*.dat",
1934 RedirectionMode::GracefulFallback,
1935 );
1936 let rel_key = "data/levels/alpine/ghost/run_42.dat";
1937
1938 let write_action = resolver.resolve_path_for_write(rel_key);
1939 let crate::ResolvedAction::Redirect {
1940 replacement_path: written_path,
1941 } = write_action
1942 else {
1943 panic!("expected a write redirect, got {write_action:?}");
1944 };
1945 fs::write(&written_path, b"ghost data").unwrap();
1946
1947 let delete_action = resolver.resolve_path_for_delete(rel_key);
1948 assert_eq!(
1949 delete_action,
1950 crate::ResolvedAction::Redirect {
1951 replacement_path: written_path.clone()
1952 },
1953 "delete must resolve to the exact same physical file the write created"
1954 );
1955
1956 let mut all_files = Vec::new();
1958 for entry in walk(&dir.path("mods_ghosts")) {
1959 all_files.push(entry);
1960 }
1961 assert_eq!(all_files, vec![written_path]);
1962 }
1963
1964 #[test]
1965 fn read_also_finds_the_same_file_a_wildcard_write_created() {
1966 let dir = ScratchDir::new("read_finds_write");
1972 let resolver = resolver_for(
1973 &dir,
1974 "data/levels/*/ghost/*.dat",
1975 RedirectionMode::GracefulFallback,
1976 );
1977 let rel_key = "data/levels/alpine/ghost/run_42.dat";
1978
1979 let crate::ResolvedAction::Redirect {
1980 replacement_path: written_path,
1981 } = resolver.resolve_path_for_write(rel_key)
1982 else {
1983 panic!("expected a write redirect");
1984 };
1985 fs::write(&written_path, b"ghost data").unwrap();
1986
1987 assert_eq!(
1988 resolver.resolve_path(rel_key),
1989 crate::ResolvedAction::Redirect {
1990 replacement_path: written_path
1991 }
1992 );
1993 }
1994
1995 #[test]
1996 fn delete_hard_errors_when_nothing_was_ever_written() {
1997 let dir = ScratchDir::new("delete_hard_errors");
1998 let resolver = resolver_for(
1999 &dir,
2000 "data/levels/*/ghost/*.dat",
2001 RedirectionMode::HardError,
2002 );
2003
2004 assert_eq!(
2005 resolver.resolve_path_for_delete("data/levels/practice/ghost/never_written.dat"),
2006 crate::ResolvedAction::HardErrorMissingFile
2007 );
2008 }
2009
2010 #[test]
2011 fn delete_passes_through_when_nothing_was_ever_written_in_fallback_mode() {
2012 let dir = ScratchDir::new("delete_passes_through");
2013 let resolver = resolver_for(
2014 &dir,
2015 "data/levels/*/ghost/*.dat",
2016 RedirectionMode::GracefulFallback,
2017 );
2018
2019 assert_eq!(
2020 resolver.resolve_path_for_delete("data/levels/practice/ghost/never_written.dat"),
2021 crate::ResolvedAction::PassThrough
2022 );
2023 }
2024
2025 #[test]
2026 fn write_target_skips_targetless_rule_to_reach_a_real_one() {
2027 let dir = ScratchDir::new("write_skips_targetless");
2031 let config = HookConfig {
2032 rules: vec![
2033 RedirectionRule {
2034 glob_pattern: "saves/*.sav".into(),
2035 target: None,
2036 mode: RedirectionMode::GracefulFallback,
2037 patches: vec![],
2038 enable_if: None,
2039 },
2040 RedirectionRule {
2041 glob_pattern: "saves/*.sav".into(),
2042 target: Some(RuleTarget::Path(dir.path("mod_saves"))),
2043 mode: RedirectionMode::GracefulFallback,
2044 patches: vec![],
2045 enable_if: None,
2046 },
2047 ],
2048 };
2049 let resolver = HookResolver::new(config, dir.path("game"), dir.path("temp"));
2050
2051 let action = resolver.resolve_path_for_write("saves/slot1.sav");
2052 assert_eq!(
2053 action,
2054 crate::ResolvedAction::Redirect {
2055 replacement_path: dir.path("mod_saves/slot1.sav")
2056 }
2057 );
2058 }
2059
2060 #[test]
2061 fn write_target_follows_redirect_to_its_end() {
2062 let dir = ScratchDir::new("write_follows_chain");
2067 fs::create_dir_all(dir.path("game")).unwrap();
2068 let config = HookConfig {
2069 rules: vec![
2070 RedirectionRule {
2071 glob_pattern: "data/x.txt".into(),
2072 target: Some(RuleTarget::Path(dir.path("game/mid/x.txt"))),
2073 mode: RedirectionMode::GracefulFallback,
2074 patches: vec![],
2075 enable_if: None,
2076 },
2077 RedirectionRule {
2078 glob_pattern: "mid/x.txt".into(),
2079 target: Some(RuleTarget::Path(dir.path("mods/final_x.txt"))),
2080 mode: RedirectionMode::GracefulFallback,
2081 patches: vec![],
2082 enable_if: None,
2083 },
2084 ],
2085 };
2086 let resolver = HookResolver::new(config, dir.path("game"), dir.path("temp"));
2087
2088 let action = resolver.resolve_path_for_write("data/x.txt");
2089 assert_eq!(
2090 action,
2091 crate::ResolvedAction::Redirect {
2092 replacement_path: dir.path("mods/final_x.txt")
2093 }
2094 );
2095 }
2096
2097 #[test]
2098 fn delete_uses_last_reached_rule_mode_for_missing_file_fallback() {
2099 let dir = ScratchDir::new("delete_last_rule_mode");
2104 fs::create_dir_all(dir.path("game")).unwrap();
2105 let config = HookConfig {
2106 rules: vec![
2107 RedirectionRule {
2108 glob_pattern: "data/y.txt".into(),
2109 target: Some(RuleTarget::Path(dir.path("game/mid/y.txt"))),
2110 mode: RedirectionMode::GracefulFallback,
2111 patches: vec![],
2112 enable_if: None,
2113 },
2114 RedirectionRule {
2115 glob_pattern: "mid/y.txt".into(),
2116 target: None,
2117 mode: RedirectionMode::HardError,
2118 patches: vec![],
2119 enable_if: None,
2120 },
2121 ],
2122 };
2123 let resolver = HookResolver::new(config, dir.path("game"), dir.path("temp"));
2124
2125 assert_eq!(
2126 resolver.resolve_path_for_delete("data/y.txt"),
2127 crate::ResolvedAction::HardErrorMissingFile,
2128 "the last rule reached (hard_error) should govern, not the first (graceful_fallback)"
2129 );
2130 }
2131
2132 #[test]
2133 fn write_redirects_through_a_wildcard_rule_even_when_the_target_matches_nothing_further() {
2134 let dir = ScratchDir::new("write_wildcard_terminal_redirect");
2142 fs::create_dir_all(dir.path("game/data/levels/easy")).unwrap();
2143 fs::create_dir_all(dir.path("game/data/levels/practice_easy")).unwrap();
2144
2145 let config = HookConfig {
2146 rules: vec![RedirectionRule {
2147 glob_pattern: "data/levels/easy/**".into(),
2148 target: Some(RuleTarget::Path(
2149 dir.path("game/data/levels/practice_easy/"),
2150 )),
2151 mode: RedirectionMode::HardError,
2152 patches: vec![],
2153 enable_if: None,
2154 }],
2155 };
2156 let resolver = HookResolver::new(config, dir.path("game"), dir.path("temp"));
2157
2158 let action = resolver.resolve_path_for_write("data/levels/easy/replays/replay_0.dat");
2159 assert_eq!(
2160 action,
2161 crate::ResolvedAction::Redirect {
2162 replacement_path: dir.path("game/data/levels/practice_easy/replays/replay_0.dat")
2163 },
2164 "a write through a single-hop wildcard redirect must land in the \
2165 rule's target directory, not fall through to the original file"
2166 );
2167 }
2168
2169 #[test]
2170 fn delete_redirects_through_a_wildcard_rule_even_when_the_target_matches_nothing_further() {
2171 let dir = ScratchDir::new("delete_wildcard_terminal_redirect");
2177 fs::create_dir_all(dir.path("game/data/levels/easy")).unwrap();
2178 fs::create_dir_all(dir.path("game/data/levels/practice_easy/replays")).unwrap();
2179 fs::write(
2180 dir.path("game/data/levels/practice_easy/replays/replay_4.dat"),
2181 b"a replay",
2182 )
2183 .unwrap();
2184
2185 let config = HookConfig {
2186 rules: vec![RedirectionRule {
2187 glob_pattern: "data/levels/easy/**".into(),
2188 target: Some(RuleTarget::Path(
2189 dir.path("game/data/levels/practice_easy/"),
2190 )),
2191 mode: RedirectionMode::HardError,
2192 patches: vec![],
2193 enable_if: None,
2194 }],
2195 };
2196 let resolver = HookResolver::new(config, dir.path("game"), dir.path("temp"));
2197
2198 assert_eq!(
2199 resolver.resolve_path_for_delete("data/levels/easy/replays/replay_4.dat"),
2200 crate::ResolvedAction::Redirect {
2201 replacement_path: dir.path("game/data/levels/practice_easy/replays/replay_4.dat")
2202 }
2203 );
2204
2205 assert_eq!(
2210 resolver.resolve_path_for_delete("data/levels/easy/replays/replay_0.dat"),
2211 crate::ResolvedAction::HardErrorMissingFile
2212 );
2213 }
2214
2215 #[test]
2216 fn literal_redirect_under_game_dir_keeps_the_declared_target_casing() {
2217 let dir = ScratchDir::new("literal_target_case_preserved");
2227 fs::create_dir_all(dir.path("game/saved_data")).unwrap();
2228
2229 let config = HookConfig {
2230 rules: vec![RedirectionRule {
2231 glob_pattern: "Saved_Data/village_Tracks_medium_high_times.txt".into(),
2232 target: Some(RuleTarget::Path(dir.path(
2233 "game/.supreme/mods/m/High_Times/Village_Tracks_Medium_high_times.txt",
2234 ))),
2235 mode: RedirectionMode::GracefulFallback,
2236 patches: vec![],
2237 enable_if: None,
2238 }],
2239 };
2240 let resolver = HookResolver::new(config, dir.path("game"), dir.path("temp"));
2241
2242 let declared =
2243 dir.path("game/.supreme/mods/m/High_Times/Village_Tracks_Medium_high_times.txt");
2244 assert_eq!(
2245 resolver.resolve_path_for_write("saved_data/village_tracks_medium_high_times.txt"),
2246 crate::ResolvedAction::Redirect {
2247 replacement_path: declared.clone()
2248 },
2249 "the write target must carry the declared path byte-exactly, \
2250 not a lowercased re-expression of it"
2251 );
2252 std::fs::write(&declared, b"managed").unwrap();
2257 assert_eq!(
2258 resolver.resolve_path_for_delete("saved_data/village_tracks_medium_high_times.txt"),
2259 crate::ResolvedAction::Redirect {
2260 replacement_path: declared
2261 }
2262 );
2263 }
2264
2265 fn walk(dir: &std::path::Path) -> Vec<PathBuf> {
2266 let mut out = Vec::new();
2267 let Ok(entries) = fs::read_dir(dir) else {
2268 return out;
2269 };
2270 for entry in entries.flatten() {
2271 let path = entry.path();
2272 if path.is_dir() {
2273 out.extend(walk(&path));
2274 } else {
2275 out.push(path);
2276 }
2277 }
2278 out
2279 }
2280}
2281
2282#[cfg(test)]
2283mod listing_visibility_tests {
2284 use super::HookResolver;
2301 use crate::{HookConfig, RedirectionMode, RedirectionRule, RuleTarget};
2302 use std::fs;
2303 use std::path::PathBuf;
2304
2305 struct ScratchDir(PathBuf);
2306
2307 impl ScratchDir {
2308 fn new(name: &str) -> Self {
2309 let dir = std::env::temp_dir().join(format!(
2310 "hook_resolver_listing_visibility_test_{name}_{}",
2311 std::process::id()
2312 ));
2313 let _ = fs::remove_dir_all(&dir);
2314 fs::create_dir_all(dir.join("a")).unwrap();
2315 fs::create_dir_all(dir.join("b")).unwrap();
2316 Self(dir)
2317 }
2318
2319 fn path(&self, name: &str) -> PathBuf {
2320 self.0.join(name)
2321 }
2322 }
2323
2324 impl Drop for ScratchDir {
2325 fn drop(&mut self) {
2326 let _ = fs::remove_dir_all(&self.0);
2327 }
2328 }
2329
2330 fn resolver_for(dir: &ScratchDir, mode: RedirectionMode) -> HookResolver {
2331 let config = HookConfig {
2332 rules: vec![RedirectionRule {
2333 glob_pattern: "a/**".into(),
2334 target: Some(RuleTarget::Path(dir.path("b"))),
2335 mode,
2336 patches: vec![],
2337 enable_if: None,
2338 }],
2339 };
2340 HookResolver::new(config, dir.path("game"), dir.path("temp"))
2341 }
2342
2343 #[test]
2344 fn graceful_fallback_hides_only_the_entry_with_a_real_replacement() {
2345 let dir = ScratchDir::new("graceful");
2346 fs::write(dir.path("a/2.txt"), b"original 2").unwrap();
2347 fs::write(dir.path("a/3.txt"), b"original 3").unwrap();
2348 fs::write(dir.path("b/1.txt"), b"replacement 1").unwrap();
2349 let resolver = resolver_for(&dir, RedirectionMode::GracefulFallback);
2350
2351 assert!(
2352 resolver.should_hide_original_entry("a/1.txt"),
2353 "1.txt has a real replacement in b/, so it should be hidden \
2354 (the injected mod entry takes its place)"
2355 );
2356 assert!(
2357 !resolver.should_hide_original_entry("a/2.txt"),
2358 "2.txt has no replacement in b/ yet -- graceful_fallback must \
2359 keep showing the original"
2360 );
2361 assert!(
2362 !resolver.should_hide_original_entry("a/3.txt"),
2363 "same as 2.txt: no replacement yet, must stay visible"
2364 );
2365 }
2366
2367 #[test]
2374 fn hard_error_hides_every_matched_entry_even_without_a_replacement() {
2375 let dir = ScratchDir::new("hard_error");
2376 fs::write(dir.path("a/2.txt"), b"original 2").unwrap();
2377 fs::write(dir.path("a/3.txt"), b"original 3").unwrap();
2378 fs::write(dir.path("b/1.txt"), b"replacement 1").unwrap();
2379 let resolver = resolver_for(&dir, RedirectionMode::HardError);
2380
2381 assert!(
2382 resolver.should_hide_original_entry("a/1.txt"),
2383 "1.txt has a real replacement -- hidden either way"
2384 );
2385 assert!(
2386 resolver.should_hide_original_entry("a/2.txt"),
2387 "hard_error must hide 2.txt even though b/2.txt doesn't exist \
2388 yet -- the rule already claimed this rel_key"
2389 );
2390 assert!(
2391 resolver.should_hide_original_entry("a/3.txt"),
2392 "same as 2.txt"
2393 );
2394 }
2395
2396 #[test]
2397 fn unmatched_path_is_never_hidden() {
2398 let dir = ScratchDir::new("unmatched");
2399 let resolver = resolver_for(&dir, RedirectionMode::HardError);
2400
2401 assert!(
2402 !resolver.should_hide_original_entry("completely/unrelated/file.txt"),
2403 "no rule matches this rel_key at all, so there's nothing to hide"
2404 );
2405 }
2406
2407 #[test]
2408 fn agrees_with_resolve_path_across_every_case_in_this_scenario() {
2409 let dir = ScratchDir::new("agrees_with_resolve_path");
2415 fs::write(dir.path("a/2.txt"), b"original 2").unwrap();
2416 fs::write(dir.path("a/3.txt"), b"original 3").unwrap();
2417 fs::write(dir.path("b/1.txt"), b"replacement 1").unwrap();
2418
2419 for mode in [
2420 RedirectionMode::GracefulFallback,
2421 RedirectionMode::HardError,
2422 ] {
2423 let resolver = resolver_for(&dir, mode);
2424 for rel_key in ["a/1.txt", "a/2.txt", "a/3.txt", "unrelated.txt"] {
2425 let hidden = resolver.should_hide_original_entry(rel_key);
2426 let would_serve_original = matches!(
2427 resolver.resolve_path(rel_key),
2428 crate::ResolvedAction::FallbackToOriginal | crate::ResolvedAction::PassThrough
2429 );
2430 assert_eq!(
2431 hidden, !would_serve_original,
2432 "mode={mode:?} rel_key={rel_key}: listing visibility must be the \
2433 exact inverse of \"resolve_path would serve the original\""
2434 );
2435 }
2436 }
2437 }
2438
2439 #[test]
2440 fn agrees_with_resolve_path_for_a_chained_and_patched_scenario() {
2441 use crate::{MemoryPatch, StringReplacement};
2446
2447 let dir = ScratchDir::new("agrees_with_resolve_path_chained");
2448 fs::write(dir.path("a/redirected.txt"), b"unused").unwrap();
2449 fs::write(dir.path("b/redirected.txt"), b"base content").unwrap();
2450
2451 let config = HookConfig {
2452 rules: vec![
2453 RedirectionRule {
2454 glob_pattern: "a/redirected.txt".into(),
2455 target: Some(RuleTarget::Path(dir.path("b/redirected.txt"))),
2456 mode: RedirectionMode::GracefulFallback,
2457 patches: vec![],
2458 enable_if: None,
2459 },
2460 RedirectionRule {
2461 glob_pattern: "b/redirected.txt".into(),
2462 target: None,
2463 mode: RedirectionMode::GracefulFallback,
2464 patches: vec![MemoryPatch::ReplaceString {
2465 replacements: vec![StringReplacement {
2466 find: "base".into(),
2467 replace: "patched".into(),
2468 enable_if: None,
2469 }],
2470 }],
2471 enable_if: None,
2472 },
2473 ],
2474 };
2475 let resolver = HookResolver::new(config, dir.path("game_root_unused"), dir.path("temp"));
2476 let hidden = resolver.should_hide_original_entry("a/redirected.txt");
2484 let would_serve_original = matches!(
2485 resolver.resolve_path("a/redirected.txt"),
2486 crate::ResolvedAction::FallbackToOriginal | crate::ResolvedAction::PassThrough
2487 );
2488 assert_eq!(
2489 hidden, !would_serve_original,
2490 "a chained, patched rel_key must still agree between listing and read"
2491 );
2492 assert!(
2493 hidden,
2494 "this rel_key has a real, patched replacement -- must be hidden"
2495 );
2496 }
2497}
2498
2499#[cfg(test)]
2500mod managed_file_specs_tests {
2501 use super::*;
2506
2507 fn plain_rule(glob: &str, target: Option<&str>) -> RedirectionRule {
2508 RedirectionRule {
2509 glob_pattern: glob.to_owned(),
2510 target: target.map(|t| RuleTarget::Path(PathBuf::from(t))),
2511 mode: RedirectionMode::HardError,
2512 patches: vec![],
2513 enable_if: None,
2514 }
2515 }
2516
2517 fn managed_rule(glob: &str, local: &str, remote: &str, poll: Option<u64>) -> RedirectionRule {
2518 managed_rule_with_push(glob, local, remote, poll, None)
2519 }
2520
2521 fn managed_rule_with_push(
2522 glob: &str,
2523 local: &str,
2524 remote: &str,
2525 poll: Option<u64>,
2526 push: Option<&str>,
2527 ) -> RedirectionRule {
2528 RedirectionRule {
2529 glob_pattern: glob.to_owned(),
2530 target: Some(RuleTarget::Managed {
2531 local: PathBuf::from(local),
2532 remote: remote.to_owned(),
2533 poll_interval_secs: poll,
2534 push_url: push.map(str::to_owned),
2535 }),
2536 mode: RedirectionMode::GracefulFallback,
2537 patches: vec![],
2538 enable_if: None,
2539 }
2540 }
2541
2542 fn config(rules: Vec<RedirectionRule>) -> HookConfig {
2543 HookConfig { rules }
2544 }
2545
2546 const GAME: &str = "/games/supreme";
2547
2548 #[test]
2549 fn collects_every_managed_rule_in_config_order() {
2550 let cfg = config(vec![
2551 plain_rule(
2552 "Data/Levels/village/Tracks/medium/replays/Replay_*.dat",
2553 Some(".supreme/mods/online-tournament/replays"),
2554 ),
2555 managed_rule_with_push(
2556 "Saved_Data/village_Tracks_medium_high_times.txt",
2557 ".supreme/mods/online-tournament/high_times/v.txt",
2558 "http://host/ssbz/index.php?action=room_high_times_file",
2559 Some(10),
2560 Some("http://host/ssbz/index.php?action=room_high_times_file"),
2561 ),
2562 managed_rule("Other/file.txt", "absolute-not", "https://host2/file", None),
2563 ]);
2564 let specs = managed_file_specs(Path::new(GAME), &cfg);
2565 assert_eq!(specs.len(), 2, "plain targets are not managed files");
2566 assert_eq!(
2567 specs[0].rel_key,
2568 "Saved_Data/village_Tracks_medium_high_times.txt"
2569 );
2570 assert_eq!(
2571 specs[0].local,
2572 PathBuf::from("/games/supreme/.supreme/mods/online-tournament/high_times/v.txt"),
2573 "relative local resolved against game_dir"
2574 );
2575 assert_eq!(specs[0].poll_interval_secs, Some(10));
2576 assert_eq!(
2577 specs[0].push_url.as_deref(),
2578 Some("http://host/ssbz/index.php?action=room_high_times_file"),
2579 "declared push URL carried into the spec"
2580 );
2581 assert_eq!(specs[1].poll_interval_secs, None, "absent = no polling");
2582 assert_eq!(specs[1].push_url, None, "absent = fetch-only");
2583 }
2584
2585 #[test]
2586 fn wildcard_managed_rules_are_skipped() {
2587 let cfg = config(vec![managed_rule(
2589 "Data/Updates/**",
2590 ".supreme/mods/m/updates",
2591 "http://host/blob",
2592 Some(5),
2593 )]);
2594 assert!(managed_file_specs(Path::new(GAME), &cfg).is_empty());
2595 }
2596
2597 #[test]
2598 fn plain_targets_only_is_empty() {
2599 let cfg = config(vec![
2600 plain_rule("Saved_Data/Defaults.txt", None),
2601 plain_rule("a.txt", Some("b.txt")),
2602 ]);
2603 assert!(managed_file_specs(Path::new(GAME), &cfg).is_empty());
2604 }
2605
2606 #[test]
2607 fn round_trips_through_serde_both_forms() {
2608 let classic: RedirectionRule = serde_json::from_value(serde_json::json!({
2611 "glob_pattern": "a.txt",
2612 "target": "b.txt",
2613 "mode": "graceful_fallback"
2614 }))
2615 .expect("classic string target");
2616 assert_eq!(
2617 classic.target,
2618 Some(RuleTarget::Path(PathBuf::from("b.txt")))
2619 );
2620
2621 let managed: RedirectionRule = serde_json::from_value(serde_json::json!({
2622 "glob_pattern": "a.txt",
2623 "target": {
2624 "local": "b.txt",
2625 "remote": "http://host/f",
2626 "poll_interval_secs": 10
2627 },
2628 "mode": "graceful_fallback"
2629 }))
2630 .expect("managed struct target");
2631 match managed.target {
2632 Some(RuleTarget::Managed {
2633 local,
2634 remote,
2635 poll_interval_secs,
2636 push_url,
2637 }) => {
2638 assert_eq!(local, PathBuf::from("b.txt"));
2639 assert_eq!(remote, "http://host/f");
2640 assert_eq!(poll_interval_secs, Some(10));
2641 assert_eq!(push_url, None, "absent push_url deserializes as None");
2642 }
2643 other => panic!("expected managed target, got {other:?}"),
2644 }
2645
2646 let no_poll = RuleTarget::Managed {
2648 local: PathBuf::from("b.txt"),
2649 remote: "http://host/f".to_owned(),
2650 poll_interval_secs: None,
2651 push_url: None,
2652 };
2653 let v = serde_json::to_value(&no_poll).unwrap();
2654 assert!(v.get("poll_interval_secs").is_none());
2655 assert!(v.get("push_url").is_none());
2656
2657 let pushing: RedirectionRule = serde_json::from_value(serde_json::json!({
2659 "glob_pattern": "a.txt",
2660 "target": {
2661 "local": "b.txt",
2662 "remote": "http://host/f",
2663 "push_url": "http://host/push?f=${room}"
2664 },
2665 "mode": "graceful_fallback"
2666 }))
2667 .expect("managed struct target with push_url");
2668 match pushing.target {
2669 Some(RuleTarget::Managed {
2670 push_url: Some(push),
2671 poll_interval_secs,
2672 ..
2673 }) => {
2674 assert_eq!(push, "http://host/push?f=${room}");
2675 assert_eq!(poll_interval_secs, None);
2676 }
2677 other => panic!("expected managed target with push_url, got {other:?}"),
2678 }
2679 }
2680}