1pub mod dir_listing;
24mod error;
25pub mod hook_config;
26pub mod mod_config;
27pub mod mods_state;
28pub mod patch;
29pub mod resolver;
30pub mod script_engine;
31pub mod ui;
32pub mod validation;
33
34pub use dir_listing::{
35 EntryOffsets, FilterResult, filter_redirected_entries, wildcard_match, write_directory_entry,
36};
37pub use error::HookConfigError;
38pub use hook_config::{HookConfig, RedirectionMode, RedirectionRule, ResolvedAction, RuleTarget};
39pub use mod_config::{ModConfig, ModParameter, ParameterKind};
40pub use mods_state::{
41 DEFAULT_MODS_DIR_NAME, MOD_FILE_NAME, MODS_STATE_FILE_NAME, ModConfigurations, ModsPreset,
42 ModsState, load_mods_state, save_mods_state,
43};
44pub use patch::{BytesReplacement, MemoryPatch, ScriptSource, StringReplacement};
45pub use resolver::{
46 HookResolver, ManagedFileSpec, compute_target_path, is_write_operation, managed_file_specs,
47 seed_target_file,
48};
49pub use ui::{ModUi, UiButton, UiField, UiFieldKind, UiPopup, UiPopupKind};
50pub use validation::{PLAYER_NAME_TAG, ROOM_NAME_TAG, is_known_tag, validate_value};
51
52#[cfg(test)]
57use hook_config::MATCH_OPTIONS;
58
59#[cfg(test)]
60mod tests {
61 use std::{
62 collections::HashMap,
63 path::{Path, PathBuf},
64 };
65
66 use glob::Pattern;
67
68 use super::*;
69
70 fn sample_config() -> HookConfig {
71 HookConfig {
72 rules: vec![
73 RedirectionRule {
74 glob_pattern: "data/levels/*.dat".into(),
75 target: Some(RuleTarget::Path(PathBuf::from("mods/custom_level"))),
76 mode: RedirectionMode::GracefulFallback,
77 patches: vec![],
78 enable_if: None,
79 },
80 RedirectionRule {
81 glob_pattern: "data/strings/*.txt".into(),
82 target: Some(RuleTarget::Path(PathBuf::from("mods/localization"))),
83 mode: RedirectionMode::HardError,
84 patches: vec![MemoryPatch::ReplaceString {
85 replacements: vec![StringReplacement {
86 find: "PLACEHOLDER".into(),
87 replace: "Bonjour".into(),
88 enable_if: None,
89 }],
90 }],
91 enable_if: None,
92 },
93 ],
94 }
95 }
96
97 #[test]
98 fn no_rule_matches_passes_through() {
99 let config = sample_config();
100 assert_eq!(
101 config.resolve("data/audio/theme.ogg", |_| false),
102 ResolvedAction::PassThrough
103 );
104 }
105
106 #[test]
107 fn matched_rule_with_existing_replacement_redirects() {
108 let config = sample_config();
109 let action = config.resolve("data/levels/level1.dat", |p| {
110 p == Path::new("mods/custom_level/level1.dat")
111 });
112 assert_eq!(
113 action,
114 ResolvedAction::Redirect {
115 replacement_path: PathBuf::from("mods/custom_level/level1.dat")
116 }
117 );
118 }
119
120 #[test]
121 fn graceful_fallback_when_missing() {
122 let config = sample_config();
123 let action = config.resolve("data/levels/level1.dat", |_| false);
124 assert_eq!(action, ResolvedAction::FallbackToOriginal);
125 }
126
127 #[test]
128 fn hard_error_when_missing() {
129 let config = sample_config();
130 let action = config.resolve("data/strings/en.txt", |_| false);
131 assert_eq!(action, ResolvedAction::HardErrorMissingFile);
132 }
133
134 #[test]
135 fn hard_error_rule_still_redirects_when_present() {
136 let config = sample_config();
137 let action = config.resolve("data/strings/en.txt", |p| {
138 p == Path::new("mods/localization/en.txt")
139 });
140 assert_eq!(
141 action,
142 ResolvedAction::Redirect {
143 replacement_path: PathBuf::from("mods/localization/en.txt")
144 }
145 );
146 }
147
148 #[test]
149 fn first_matching_rule_wins() {
150 let mut config = sample_config();
151 config.rules.push(RedirectionRule {
153 glob_pattern: "data/**".into(),
154 target: Some(RuleTarget::Path(PathBuf::from("mods/catch_all"))),
155 mode: RedirectionMode::GracefulFallback,
156 patches: vec![],
157 enable_if: None,
158 });
159 let action = config.resolve("data/levels/level1.dat", |p| {
160 p == Path::new("mods/custom_level/level1.dat")
161 });
162 assert_eq!(
163 action,
164 ResolvedAction::Redirect {
165 replacement_path: PathBuf::from("mods/custom_level/level1.dat")
166 }
167 );
168 }
169
170 #[test]
171 fn roundtrip_through_json_file() {
172 let dir = tempfile::tempdir().unwrap();
173 let path = dir.path().join("hook_config.json");
174 let config = sample_config();
175 config.write_to_path(&path).unwrap();
176 let loaded = HookConfig::load_from_path(&path).unwrap();
177 assert_eq!(config, loaded);
178 }
179
180 fn rule_with_pattern(glob_pattern: &str) -> HookConfig {
181 HookConfig {
182 rules: vec![RedirectionRule {
183 glob_pattern: glob_pattern.into(),
184 target: Some(RuleTarget::Path(PathBuf::from("mods/x"))),
185 mode: RedirectionMode::GracefulFallback,
186 patches: vec![],
187 enable_if: None,
188 }],
189 }
190 }
191
192 #[test]
193 fn single_star_does_not_cross_path_separators() {
194 let config = rule_with_pattern("data/levels/*.dat");
195 assert!(
196 config
197 .find_matching_rule("data/levels/level1.dat")
198 .is_some()
199 );
200 assert!(
201 config
202 .find_matching_rule("data/levels/sub/level1.dat")
203 .is_none()
204 );
205 }
206
207 #[test]
208 fn double_star_crosses_path_separators() {
209 let config = rule_with_pattern("data/**/level1.dat");
210 assert!(config.find_matching_rule("data/level1.dat").is_some());
211 assert!(config.find_matching_rule("data/a/b/level1.dat").is_some());
212 }
213
214 #[test]
215 fn matching_is_case_insensitive() {
216 let config = rule_with_pattern("Data/Levels/*.DAT");
217 assert!(
218 config
219 .find_matching_rule("data/levels/level1.dat")
220 .is_some()
221 );
222 }
223
224 #[test]
225 fn invalid_glob_pattern_never_matches() {
226 let config = rule_with_pattern("data/levels/**.dat");
229 assert!(
230 config
231 .find_matching_rule("data/levels/level1.dat")
232 .is_none()
233 );
234 }
235
236 fn directory_rule(glob_pattern: &str, target: &str) -> HookConfig {
237 HookConfig {
238 rules: vec![RedirectionRule {
239 glob_pattern: glob_pattern.into(),
240 target: Some(RuleTarget::Path(PathBuf::from(target))),
241 mode: RedirectionMode::GracefulFallback,
242 patches: vec![],
243 enable_if: None,
244 }],
245 }
246 }
247
248 #[test]
249 fn directory_tree_redirection_preserves_relative_structure() {
250 let config = directory_rule(
254 "path/to/directory/**",
255 "replacement/path/to/other/directory",
256 );
257 let action = config.resolve("path/to/directory/sub/dir/asset.dds", |p| {
258 p == Path::new("replacement/path/to/other/directory/sub/dir/asset.dds")
259 });
260 assert_eq!(
261 action,
262 ResolvedAction::Redirect {
263 replacement_path: PathBuf::from(
264 "replacement/path/to/other/directory/sub/dir/asset.dds"
265 )
266 }
267 );
268 }
269
270 #[test]
271 fn directory_redirection_target_trailing_slash_is_irrelevant() {
272 let with_slash = directory_rule("path/to/directory/**", "replacement/dir/");
275 let without_slash = directory_rule("path/to/directory/**", "replacement/dir");
276 for config in [with_slash, without_slash] {
277 let action = config.resolve("path/to/directory/asset.dds", |_| true);
278 assert_eq!(
279 action,
280 ResolvedAction::Redirect {
281 replacement_path: PathBuf::from("replacement/dir/asset.dds")
282 }
283 );
284 }
285 }
286
287 #[test]
298 fn directory_tree_redirection_matches_case_insensitively() {
299 let config = directory_rule(
300 "Data/Levels/Alpine/Tracks/Easy/**",
301 "D:/SupremeLight/Data/Levels/Practice/Tracks/Easy/",
302 );
303 let action = config.resolve("data/levels/alpine/tracks/easy/replays/replay_0.dat", |p| {
304 p == Path::new("D:/SupremeLight/Data/Levels/Practice/Tracks/Easy/replays/replay_0.dat")
305 });
306 assert_eq!(
307 action,
308 ResolvedAction::Redirect {
309 replacement_path: PathBuf::from(
310 "D:/SupremeLight/Data/Levels/Practice/Tracks/Easy/replays/replay_0.dat"
311 )
312 }
313 );
314 }
315
316 #[test]
317 fn flat_single_component_pattern_still_reduces_to_basename() {
318 let config = directory_rule("data/levels/*.dat", "mods/custom_level");
321 let action = config.resolve("data/levels/level1.dat", |p| {
322 p == Path::new("mods/custom_level/level1.dat")
323 });
324 assert_eq!(
325 action,
326 ResolvedAction::Redirect {
327 replacement_path: PathBuf::from("mods/custom_level/level1.dat")
328 }
329 );
330 }
331
332 #[test]
333 fn backslash_paths_are_normalized_and_match_properly() {
334 let config = sample_config();
335 let action = config.resolve("data\\strings\\en.txt", |p| {
337 p == Path::new("mods/localization/en.txt")
338 });
339 assert_eq!(
340 action,
341 ResolvedAction::Redirect {
342 replacement_path: PathBuf::from("mods/localization/en.txt")
343 }
344 );
345 }
346
347 #[test]
348 fn literal_glob_target_is_used_directly_not_joined() {
349 let config = HookConfig {
355 rules: vec![RedirectionRule {
356 glob_pattern: "Saved_Data/Alpine_Tracks_Easy_high_times.txt".into(),
357 target: Some(RuleTarget::Path(PathBuf::from(
358 "Saved_Data/Practice_Tracks_Easy_high_times.txt",
359 ))),
360 mode: RedirectionMode::HardError,
361 patches: vec![],
362 enable_if: None,
363 }],
364 };
365 let action = config.resolve("Saved_Data/Alpine_Tracks_Easy_high_times.txt", |p| {
366 p == Path::new("Saved_Data/Practice_Tracks_Easy_high_times.txt")
367 });
368 assert_eq!(
369 action,
370 ResolvedAction::Redirect {
371 replacement_path: PathBuf::from("Saved_Data/Practice_Tracks_Easy_high_times.txt")
372 }
373 );
374 }
375
376 #[test]
377 fn is_literal_glob_detects_wildcards() {
378 assert!(rule_with_pattern("data/levels/level1.dat").rules[0].is_literal_glob());
379 assert!(!rule_with_pattern("data/levels/*.dat").rules[0].is_literal_glob());
380 assert!(!rule_with_pattern("data/levels/**").rules[0].is_literal_glob());
381 assert!(!rule_with_pattern("data/levels/level?.dat").rules[0].is_literal_glob());
382 }
383
384 #[test]
385 fn omitted_target_falls_back_in_graceful_mode_without_probing_disk() {
386 let config = HookConfig {
387 rules: vec![RedirectionRule {
388 glob_pattern: "data/levels/level1.dat".into(),
389 target: None,
390 mode: RedirectionMode::GracefulFallback,
391 patches: vec![],
392 enable_if: None,
393 }],
394 };
395 let action = config.resolve("data/levels/level1.dat", |_| {
397 panic!("file_exists should not be called when target is None")
398 });
399 assert_eq!(action, ResolvedAction::FallbackToOriginal);
400 }
401
402 #[test]
403 fn omitted_target_hard_blocks_in_hard_error_mode() {
404 let config = HookConfig {
405 rules: vec![RedirectionRule {
406 glob_pattern: "Saved_Data/high_times.txt".into(),
407 target: None,
408 mode: RedirectionMode::HardError,
409 patches: vec![],
410 enable_if: None,
411 }],
412 };
413 let action = config.resolve("Saved_Data/high_times.txt", |_| {
414 panic!("file_exists should not be called when target is None")
415 });
416 assert_eq!(action, ResolvedAction::HardErrorMissingFile);
417 }
418
419 #[test]
420 fn target_field_is_omittable_in_json() {
421 let json = r#"{"rules":[{"glob_pattern":"data/levels/*.dat","mode":"graceful_fallback"}]}"#;
422 let config: HookConfig = serde_json::from_str(json).unwrap();
423 assert_eq!(config.rules.len(), 1);
424 assert_eq!(config.rules[0].target, None);
425 assert!(config.rules[0].patches.is_empty());
426 }
427
428 fn boolean_param(id: &str) -> ModParameter {
431 ModParameter {
432 id: id.to_string(),
433 name: id.to_string(),
434 help_text: String::new(),
435 kind: ParameterKind::Boolean,
436 enable_if: None,
437 hidden: false,
438 validation: None,
439 }
440 }
441
442 fn dropdown_param(id: &str, choices: &[&str]) -> ModParameter {
443 ModParameter {
444 id: id.to_string(),
445 name: id.to_string(),
446 help_text: String::new(),
447 kind: ParameterKind::Dropdown {
448 choices: choices.iter().map(|s| (*s).to_string()).collect(),
449 },
450 enable_if: None,
451 hidden: false,
452 validation: None,
453 }
454 }
455
456 fn number_param(id: &str, min: f32, max: f32) -> ModParameter {
457 ModParameter {
458 id: id.to_string(),
459 name: id.to_string(),
460 help_text: String::new(),
461 kind: ParameterKind::Number {
462 min,
463 max,
464 step: None,
465 },
466 enable_if: None,
467 hidden: false,
468 validation: None,
469 }
470 }
471
472 fn text_param(id: &str) -> ModParameter {
473 ModParameter {
474 id: id.to_string(),
475 name: id.to_string(),
476 help_text: String::new(),
477 kind: ParameterKind::Text,
478 enable_if: None,
479 hidden: false,
480 validation: None,
481 }
482 }
483
484 fn gated_rule(glob_pattern: &str, target: &str, enable_if: &str) -> RedirectionRule {
485 RedirectionRule {
486 glob_pattern: glob_pattern.into(),
487 target: Some(RuleTarget::Path(PathBuf::from(target))),
488 mode: RedirectionMode::GracefulFallback,
489 patches: vec![],
490 enable_if: Some(enable_if.to_string()),
491 }
492 }
493
494 #[test]
495 fn mod_json_round_trips_every_parameter_kind() {
496 let config = ModConfig {
497 name: "Custom Boards Pack".to_string(),
498 author: Some("someone".to_string()),
499 license: Some("MIT".to_string()),
500 description: Some("Adds custom boards".to_string()),
501 version: Some("1.0.0".to_string()),
502 rules: vec![gated_rule(
503 "Data/Boards/style.dat",
504 ".supreme/mods/custom_boards/styles/${Board_Style}.dat",
505 "Board_Style",
506 )],
507 parameters: vec![
508 boolean_param("Transparent_Board"),
509 dropdown_param("Board_Style", &["Wood", "Carbon", "Neon"]),
510 number_param("Fov_Boost", 0.0, 20.0),
511 text_param("Custom_Label"),
512 ],
513 configuration: HashMap::from([
516 ("Board_Style".to_string(), serde_json::json!("Wood")),
517 ("Custom_Label".to_string(), serde_json::json!("Hello")),
518 ]),
519 ui: None,
520 };
521
522 let json = serde_json::to_string_pretty(&config).unwrap();
523 let loaded: ModConfig = serde_json::from_str(&json).unwrap();
524 assert_eq!(config, loaded);
525
526 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
529 assert_eq!(value["parameters"][0]["type"], "boolean");
530 assert_eq!(value["parameters"][1]["type"], "dropdown");
531 assert_eq!(value["parameters"][1]["choices"][0], "Wood");
532 assert_eq!(value["parameters"][2]["type"], "number");
533 assert_eq!(value["parameters"][2]["min"], 0.0);
534 assert_eq!(value["parameters"][3]["type"], "text");
535 assert_eq!(value["configuration"]["Board_Style"], "Wood");
536 }
537
538 #[test]
539 fn only_name_is_mandatory() {
540 let json = r#"{"name":"Bare Mod"}"#;
541 let config: ModConfig = serde_json::from_str(json).unwrap();
542 assert_eq!(config.name, "Bare Mod");
543 assert_eq!(config.author, None);
544 assert!(config.rules.is_empty());
545 assert!(config.parameters.is_empty());
546 assert!(config.configuration.is_empty());
547 }
548
549 #[test]
550 fn gating_reads_boolean_and_dropdown_truthiness() {
551 let config = ModConfig {
552 name: "Test".to_string(),
553 rules: vec![
554 gated_rule("a", "target_a", "Enabled"),
555 gated_rule("b", "target_b", "Board_Style != \"\""),
556 ],
557 parameters: vec![
558 boolean_param("Enabled"),
559 dropdown_param("Board_Style", &["Wood", "Carbon"]),
560 ],
561 ..ModConfig::default()
562 };
563 let mut configuration = HashMap::from([
564 ("Enabled".to_string(), serde_json::json!(false)),
565 ("Board_Style".to_string(), serde_json::json!("")),
566 ]);
567 assert!(config.resolved_rules(&configuration).is_empty());
569
570 configuration.insert("Enabled".to_string(), serde_json::json!(true));
571 configuration.insert("Board_Style".to_string(), serde_json::json!("Wood"));
572 assert_eq!(config.resolved_rules(&configuration).len(), 2);
573 }
574
575 #[test]
576 fn unset_parameter_reads_as_off() {
577 let config = ModConfig {
578 name: "Test".to_string(),
579 rules: vec![gated_rule("a", "target_a", "Never_Configured")],
580 parameters: vec![boolean_param("Never_Configured")],
581 ..ModConfig::default()
582 };
583 assert!(config.resolved_rules(&HashMap::new()).is_empty());
584 }
585
586 #[test]
587 fn mod_json_configuration_supplies_defaults_when_no_override_exists() {
588 let config = ModConfig {
589 name: "Test".to_string(),
590 rules: vec![gated_rule("a", "target_a", "Enabled")],
591 parameters: vec![boolean_param("Enabled")],
592 configuration: HashMap::from([("Enabled".to_string(), serde_json::json!(true))]),
595 author: None,
596 license: None,
597 description: None,
598 version: None,
599 ui: None,
600 };
601
602 assert_eq!(config.resolved_rules(&HashMap::new()).len(), 1);
605 }
606
607 #[test]
608 fn override_takes_precedence_over_mod_json_default() {
609 let config = ModConfig {
610 name: "Test".to_string(),
611 rules: vec![gated_rule("a", "target_a", "Enabled")],
612 parameters: vec![boolean_param("Enabled")],
613 configuration: HashMap::from([("Enabled".to_string(), serde_json::json!(true))]),
614 author: None,
615 license: None,
616 description: None,
617 version: None,
618 ui: None,
619 };
620 let overrides = HashMap::from([("Enabled".to_string(), serde_json::json!(false))]);
621
622 assert!(config.resolved_rules(&overrides).is_empty());
625 }
626
627 #[test]
628 fn effective_configuration_merges_defaults_with_overrides() {
629 let config = ModConfig {
630 name: "Test".to_string(),
631 configuration: HashMap::from([
632 ("A".to_string(), serde_json::json!("default_a")),
633 ("B".to_string(), serde_json::json!("default_b")),
634 ]),
635 ..ModConfig::default()
636 };
637 let overrides = HashMap::from([("B".to_string(), serde_json::json!("override_b"))]);
638
639 let effective = config.effective_configuration(&overrides);
640 assert_eq!(effective.get("A"), Some(&serde_json::json!("default_a")));
641 assert_eq!(effective.get("B"), Some(&serde_json::json!("override_b")));
643 }
644
645 #[test]
646 fn interpolation_fills_glob_pattern_target_and_patch_text() {
647 let config = ModConfig {
648 name: "Test".to_string(),
649 rules: vec![RedirectionRule {
650 glob_pattern: "Data/${Board_Style}/*.dat".into(),
651 target: Some(RuleTarget::Path(PathBuf::from("mods/${Board_Style}"))),
652 mode: RedirectionMode::GracefulFallback,
653 patches: vec![MemoryPatch::ReplaceString {
654 replacements: vec![StringReplacement {
655 find: "${Board_Style}_placeholder".into(),
656 replace: "Loaded ${Board_Style}".into(),
657 enable_if: None,
658 }],
659 }],
660 enable_if: None,
661 }],
662 parameters: vec![dropdown_param("Board_Style", &["Wood", "Carbon"])],
663 ..ModConfig::default()
664 };
665 let configuration = HashMap::from([("Board_Style".to_string(), serde_json::json!("Wood"))]);
666
667 let resolved = config.resolved_rules(&configuration);
668 assert_eq!(resolved.len(), 1);
669 assert_eq!(resolved[0].glob_pattern, "Data/Wood/*.dat");
670 assert_eq!(
671 resolved[0].target,
672 Some(RuleTarget::Path(PathBuf::from("mods/Wood")))
673 );
674 let MemoryPatch::ReplaceString { replacements } = &resolved[0].patches[0] else {
675 panic!("expected ReplaceString");
676 };
677 assert_eq!(replacements[0].find, "Wood_placeholder");
678 assert_eq!(replacements[0].replace, "Loaded Wood");
679 }
680
681 #[test]
682 fn one_parameter_can_gate_while_another_substitutes() {
683 let config = ModConfig {
686 name: "Test".to_string(),
687 rules: vec![gated_rule(
688 "Data/Boards/**",
689 "mods/${Board_Style}",
690 "Transparent_Board",
691 )],
692 parameters: vec![
693 boolean_param("Transparent_Board"),
694 dropdown_param("Board_Style", &["Wood", "Carbon"]),
695 ],
696 ..ModConfig::default()
697 };
698 let configuration = HashMap::from([
699 ("Transparent_Board".to_string(), serde_json::json!(true)),
700 ("Board_Style".to_string(), serde_json::json!("Carbon")),
701 ]);
702 let resolved = config.resolved_rules(&configuration);
703 assert_eq!(resolved.len(), 1);
704 assert_eq!(
705 resolved[0].target,
706 Some(RuleTarget::Path(PathBuf::from("mods/Carbon")))
707 );
708 }
709
710 #[test]
711 fn unknown_placeholder_is_left_unchanged() {
712 let config = ModConfig {
713 name: "Test".to_string(),
714 rules: vec![RedirectionRule {
715 glob_pattern: "Data/${Nonexistent}/*.dat".into(),
716 target: None,
717 mode: RedirectionMode::GracefulFallback,
718 patches: vec![],
719 enable_if: None,
720 }],
721 ..ModConfig::default()
722 };
723 assert_eq!(
726 config.resolved_rules(&HashMap::new())[0].glob_pattern,
727 "Data/${Nonexistent}/*.dat"
728 );
729 }
730
731 #[test]
732 fn unterminated_interpolation_is_left_literal() {
733 let config = ModConfig {
734 name: "Test".to_string(),
735 rules: vec![RedirectionRule {
736 glob_pattern: "Data/${unterminated".into(),
737 target: None,
738 mode: RedirectionMode::GracefulFallback,
739 patches: vec![],
740 enable_if: None,
741 }],
742 ..ModConfig::default()
743 };
744 assert_eq!(
747 config.resolved_rules(&HashMap::new())[0].glob_pattern,
748 "Data/${unterminated"
749 );
750 }
751
752 #[test]
753 fn glob_special_characters_in_a_substituted_value_are_escaped() {
754 let config = ModConfig {
755 name: "Test".to_string(),
756 rules: vec![RedirectionRule {
757 glob_pattern: "Data/${Label}.dat".into(),
758 target: None,
759 mode: RedirectionMode::GracefulFallback,
760 patches: vec![],
761 enable_if: None,
762 }],
763 parameters: vec![text_param("Label")],
764 ..ModConfig::default()
765 };
766 let configuration =
767 HashMap::from([("Label".to_string(), serde_json::json!("weird*name?[x]"))]);
768 let resolved = config.resolved_rules(&configuration);
769 assert_eq!(resolved[0].glob_pattern, "Data/weird[*]name[?][[]x[]].dat");
770 let pattern = Pattern::new(&resolved[0].glob_pattern).unwrap();
773 assert!(pattern.matches_with("Data/weird*name?[x].dat", MATCH_OPTIONS));
774 assert!(!pattern.matches_with("Data/weirdXnameY.dat", MATCH_OPTIONS));
775 }
776
777 #[test]
778 fn replace_bytes_patch_is_never_interpolated() {
779 let config = ModConfig {
780 name: "Test".to_string(),
781 rules: vec![RedirectionRule {
782 glob_pattern: "a".into(),
783 target: None,
784 mode: RedirectionMode::GracefulFallback,
785 patches: vec![MemoryPatch::ReplaceBytes {
786 replacements: vec![BytesReplacement {
787 find: b"${Label}".to_vec(),
788 replace: b"unchanged".to_vec(),
789 enable_if: None,
790 }],
791 }],
792 enable_if: None,
793 }],
794 parameters: vec![text_param("Label")],
795 ..ModConfig::default()
796 };
797 let configuration =
798 HashMap::from([("Label".to_string(), serde_json::json!("substituted"))]);
799 let resolved = config.resolved_rules(&configuration);
800 let MemoryPatch::ReplaceBytes { replacements } = &resolved[0].patches[0] else {
801 panic!("expected ReplaceBytes");
802 };
803 assert_eq!(replacements[0].find, b"${Label}");
804 }
805
806 fn fast_start_style_config() -> ModConfig {
811 ModConfig {
812 name: "Fast count-down".to_string(),
813 rules: vec![RedirectionRule {
814 glob_pattern: "Data/General/Object_Classes.txt".into(),
815 target: None,
816 mode: RedirectionMode::GracefulFallback,
817 patches: vec![MemoryPatch::ReplaceString {
818 replacements: vec![
819 StringReplacement {
820 find: "player_start_time\t= 3;".into(),
821 replace: "player_start_time = 1;".into(),
822 enable_if: Some("Fast_Start".to_string()),
823 },
824 StringReplacement {
825 find: "go_end_time\t= 3;".into(),
826 replace: "go_end_time\t= 0.9;".into(),
827 enable_if: Some("Fast_Start".to_string()),
828 },
829 StringReplacement {
830 find: "GET READY".into(),
831 replace: "${First_Start_Text}".into(),
832 enable_if: None,
833 },
834 StringReplacement {
835 find: "GO!!!".into(),
836 replace: "${Second_Start_Text}".into(),
837 enable_if: None,
838 },
839 ],
840 }],
841 enable_if: None,
842 }],
843 parameters: vec![
844 boolean_param("Fast_Start"),
845 text_param("First_Start_Text"),
846 text_param("Second_Start_Text"),
847 ],
848 configuration: HashMap::from([
849 ("First_Start_Text".to_string(), serde_json::json!("Ready")),
850 ("Second_Start_Text".to_string(), serde_json::json!("Go!")),
851 ]),
852 author: None,
853 license: None,
854 description: None,
855 version: None,
856 ui: None,
857 }
858 }
859
860 #[test]
861 fn per_replacement_enable_if_is_independent_of_the_rules_own_gating() {
862 let config = fast_start_style_config();
863
864 let resolved = config.resolved_rules(&HashMap::new());
868 assert_eq!(resolved.len(), 1);
869
870 let MemoryPatch::ReplaceString { replacements } = &resolved[0].patches[0] else {
871 panic!("expected ReplaceString");
872 };
873 assert_eq!(replacements.len(), 2);
877 assert_eq!(replacements[0].replace, "Ready");
878 assert_eq!(replacements[1].replace, "Go!");
879 }
880
881 #[test]
882 fn per_replacement_enable_if_turned_on_keeps_every_replacement() {
883 let config = fast_start_style_config();
884 let overrides = HashMap::from([("Fast_Start".to_string(), serde_json::json!(true))]);
885
886 let resolved = config.resolved_rules(&overrides);
887 let MemoryPatch::ReplaceString { replacements } = &resolved[0].patches[0] else {
888 panic!("expected ReplaceString");
889 };
890 assert_eq!(replacements.len(), 4);
893 assert_eq!(replacements[0].replace, "player_start_time = 1;");
894 assert_eq!(replacements[3].replace, "Go!");
895 }
896
897 #[test]
898 fn per_replacement_enable_if_overrides_the_text_default_too() {
899 let config = fast_start_style_config();
900 let overrides =
901 HashMap::from([("First_Start_Text".to_string(), serde_json::json!("Steady"))]);
902
903 let resolved = config.resolved_rules(&overrides);
904 let MemoryPatch::ReplaceString { replacements } = &resolved[0].patches[0] else {
905 panic!("expected ReplaceString");
906 };
907 assert_eq!(replacements[0].replace, "Steady");
910 assert_eq!(replacements[1].replace, "Go!");
911 }
912
913 #[test]
914 fn validate_checks_replacement_level_enable_if_same_as_rule_level() {
915 let config = ModConfig {
916 name: "Test".to_string(),
917 rules: vec![RedirectionRule {
918 glob_pattern: "a".into(),
919 target: None,
920 mode: RedirectionMode::GracefulFallback,
921 patches: vec![MemoryPatch::ReplaceString {
922 replacements: vec![StringReplacement {
923 find: "x".into(),
924 replace: "y".into(),
925 enable_if: Some("Nonexistent".to_string()),
926 }],
927 }],
928 enable_if: None,
929 }],
930 ..ModConfig::default()
931 };
932 let errors = config.validate().unwrap_err();
933 assert!(errors.iter().any(|e| e.contains("Nonexistent")));
934 }
935
936 #[test]
937 fn dropdown_needs_at_least_one_choice() {
938 let param = dropdown_param("Board_Style", &[]);
939 assert!(param.validate().is_err());
940 }
941
942 #[test]
943 fn number_min_must_not_exceed_max() {
944 let param = number_param("Fov_Boost", 10.0, 5.0);
945 assert!(param.validate().is_err());
946 assert!(number_param("Fov_Boost", 0.0, 20.0).validate().is_ok());
947 }
948
949 #[test]
950 fn mandatory_name_is_enforced_by_validate() {
951 let config = ModConfig::default();
952 assert!(
953 config
954 .validate()
955 .unwrap_err()
956 .iter()
957 .any(|e| e.contains("name"))
958 );
959 }
960
961 #[test]
962 fn enable_if_must_reference_a_known_parameter() {
963 let config = ModConfig {
964 name: "Test".to_string(),
965 rules: vec![gated_rule("a", "b", "Typo_Id")],
966 parameters: vec![boolean_param("Correct_Id")],
967 ..ModConfig::default()
968 };
969 let errors = config.validate().unwrap_err();
970 assert!(errors.iter().any(|e| e.contains("Typo_Id")));
971 }
972
973 #[test]
974 fn enable_if_can_reference_number_parameters() {
975 let config = ModConfig {
978 name: "Test".to_string(),
979 rules: vec![gated_rule("a", "b", "Fov_Boost >= 10.0")],
980 parameters: vec![number_param("Fov_Boost", 0.0, 20.0)],
981 ..ModConfig::default()
982 };
983 assert!(config.validate().is_ok());
984
985 let invalid_config = ModConfig {
987 name: "Test".to_string(),
988 rules: vec![gated_rule("a", "b", "Fov_Boost")],
989 parameters: vec![number_param("Fov_Boost", 0.0, 20.0)],
990 ..ModConfig::default()
991 };
992 assert!(invalid_config.validate().is_err());
993 }
994
995 #[test]
996 fn enable_if_referencing_boolean_or_dropdown_is_valid() {
997 let config = ModConfig {
998 name: "Test".to_string(),
999 rules: vec![
1001 gated_rule("a", "b", "Flag"),
1002 gated_rule("c", "d", "Choice != \"\""),
1003 ],
1004 parameters: vec![boolean_param("Flag"), dropdown_param("Choice", &["X"])],
1005 ..ModConfig::default()
1006 };
1007 assert!(config.validate().is_ok());
1008 }
1009
1010 fn write_mod_config(mods_dir: &Path, mod_name: &str, rules: &HookConfig) {
1014 let config = ModConfig {
1015 name: mod_name.to_string(),
1016 rules: rules.rules.clone(),
1017 ..ModConfig::default()
1018 };
1019 config
1020 .write_to_path(&mods_dir.join(mod_name).join(MOD_FILE_NAME))
1021 .unwrap();
1022 }
1023
1024 fn write_enabled_mods(mods_dir: &Path, names: &[&str]) {
1028 let state = ModsState {
1029 active: ModsPreset {
1030 order: names.iter().map(|s| s.to_string()).collect(),
1031 enabled: names.iter().map(|s| s.to_string()).collect(),
1032 configuration: ModConfigurations::new(),
1033 },
1034 presets: HashMap::new(),
1035 };
1036 save_mods_state(mods_dir, &state).unwrap();
1037 }
1038
1039 #[test]
1040 fn first_enabled_mod_with_a_matching_rule_wins() {
1041 let dir = tempfile::tempdir().unwrap();
1042 let mods_dir = dir.path();
1043
1044 write_enabled_mods(mods_dir, &["low_priority_mod", "high_priority_mod"]);
1045 write_mod_config(
1046 mods_dir,
1047 "high_priority_mod",
1048 &directory_rule("data/levels/*.dat", "mods/high"),
1049 );
1050 write_mod_config(
1051 mods_dir,
1052 "low_priority_mod",
1053 &directory_rule("data/levels/*.dat", "mods/low"),
1054 );
1055
1056 let (config, skipped) = HookConfig::load_from_mods_dir(mods_dir).unwrap();
1059 assert!(skipped.is_empty());
1060 let action = config.resolve("data/levels/level1.dat", |_| true);
1061 assert_eq!(
1062 action,
1063 ResolvedAction::Redirect {
1064 replacement_path: PathBuf::from("mods/low/level1.dat")
1065 }
1066 );
1067 }
1068
1069 #[test]
1070 fn mod_order_controls_redirected_reads_and_writes_to_existing_replacements() {
1071 let dir = tempfile::tempdir().unwrap();
1072 let game = dir.path().join("game");
1073 let mods = game.join(".supreme/mods");
1074 std::fs::create_dir_all(game.join("data")).unwrap();
1075 std::fs::create_dir_all(game.join("replacements")).unwrap();
1076 std::fs::write(game.join("data/file.dat"), b"original").unwrap();
1077 std::fs::write(game.join("replacements/first.dat"), b"first").unwrap();
1078 std::fs::write(game.join("replacements/second.dat"), b"second").unwrap();
1079 for (name, target) in [
1080 ("first", "replacements/first.dat"),
1081 ("second", "replacements/second.dat"),
1082 ] {
1083 write_mod_config(
1084 &mods,
1085 name,
1086 &HookConfig {
1087 rules: vec![RedirectionRule {
1088 glob_pattern: "data/file.dat".into(),
1089 target: Some(RuleTarget::Path(target.into())),
1090 mode: RedirectionMode::GracefulFallback,
1091 patches: vec![],
1092 enable_if: None,
1093 }],
1094 },
1095 );
1096 }
1097 for (names, expected) in [
1098 (["first", "second"], "first.dat"),
1099 (["second", "first"], "second.dat"),
1100 ] {
1101 write_enabled_mods(&mods, &names);
1102 let (resolver, skipped) =
1103 HookResolver::load(&mods, game.clone(), dir.path().join("temp"));
1104 assert!(skipped.is_empty());
1105 let ResolvedAction::Redirect { replacement_path } =
1106 resolver.resolve_path_for_write("data/file.dat")
1107 else {
1108 panic!("write should redirect");
1109 };
1110 assert_eq!(replacement_path.file_name().unwrap(), expected);
1111 assert_eq!(
1113 std::fs::read(&replacement_path).unwrap(),
1114 if expected == "first.dat" {
1115 b"first".as_slice()
1116 } else {
1117 b"second".as_slice()
1118 }
1119 );
1120 assert_eq!(
1121 resolver.resolve_path("data/file.dat"),
1122 ResolvedAction::Redirect { replacement_path }
1123 );
1124 }
1125 assert_eq!(
1126 std::fs::read(game.join("data/file.dat")).unwrap(),
1127 b"original"
1128 );
1129 }
1130
1131 #[test]
1132 fn mod_order_controls_patch_sequence_across_mods() {
1133 let dir = tempfile::tempdir().unwrap();
1134 let game = dir.path().join("game");
1135 let mods = game.join(".supreme/mods");
1136 std::fs::create_dir_all(game.join("data")).unwrap();
1137 std::fs::write(game.join("data/file.txt"), b"x").unwrap();
1138 for (name, find, replace) in [("append", "x", "xy"), ("collapse", "xy", "Z")] {
1139 write_mod_config(
1140 &mods,
1141 name,
1142 &HookConfig {
1143 rules: vec![RedirectionRule {
1144 glob_pattern: "data/file.txt".into(),
1145 target: None,
1146 mode: RedirectionMode::GracefulFallback,
1147 enable_if: None,
1148 patches: vec![MemoryPatch::ReplaceString {
1149 replacements: vec![StringReplacement {
1150 find: find.into(),
1151 replace: replace.into(),
1152 enable_if: None,
1153 }],
1154 }],
1155 }],
1156 },
1157 );
1158 }
1159 for (names, expected, temp) in [
1160 (["append", "collapse"], b"Z".as_slice(), "one"),
1161 (["collapse", "append"], b"xy".as_slice(), "two"),
1162 ] {
1163 write_enabled_mods(&mods, &names);
1164 let (resolver, skipped) =
1165 HookResolver::load(&mods, game.clone(), dir.path().join(temp));
1166 assert!(skipped.is_empty());
1167 let ResolvedAction::Redirect { replacement_path } =
1168 resolver.resolve_path("data/file.txt")
1169 else {
1170 panic!("patch should stage a redirect");
1171 };
1172 assert_eq!(std::fs::read(replacement_path).unwrap(), expected);
1173 }
1174 }
1175
1176 #[test]
1177 fn redirect_chain_restarts_at_first_mod_for_each_new_path() {
1178 let dir = tempfile::tempdir().unwrap();
1179 let game = dir.path().join("game");
1180 let mods = game.join(".supreme/mods");
1181 for folder in ["data", "mid", "final"] {
1182 std::fs::create_dir_all(game.join(folder)).unwrap();
1183 }
1184 std::fs::write(game.join("mid/file.txt"), b"intermediate").unwrap();
1185 std::fs::write(game.join("final/file.txt"), b"terminal").unwrap();
1186 write_mod_config(
1187 &mods,
1188 "start",
1189 &HookConfig {
1190 rules: vec![RedirectionRule {
1191 glob_pattern: "data/file.txt".into(),
1192 target: Some(RuleTarget::Path("mid/file.txt".into())),
1193 mode: RedirectionMode::GracefulFallback,
1194 patches: vec![],
1195 enable_if: None,
1196 }],
1197 },
1198 );
1199 write_mod_config(
1200 &mods,
1201 "finish",
1202 &HookConfig {
1203 rules: vec![RedirectionRule {
1204 glob_pattern: "mid/file.txt".into(),
1205 target: Some(RuleTarget::Path("final/file.txt".into())),
1206 mode: RedirectionMode::GracefulFallback,
1207 patches: vec![],
1208 enable_if: None,
1209 }],
1210 },
1211 );
1212 write_enabled_mods(&mods, &["finish", "start"]);
1216 let (resolver, skipped) = HookResolver::load(&mods, game.clone(), dir.path().join("temp"));
1217 assert!(skipped.is_empty());
1218 assert_eq!(
1219 resolver.resolve_path("data/file.txt"),
1220 ResolvedAction::Redirect {
1221 replacement_path: game.join("final/file.txt")
1222 }
1223 );
1224 assert_eq!(
1225 resolver.resolve_path_for_write("data/file.txt"),
1226 ResolvedAction::Redirect {
1227 replacement_path: game.join("final/file.txt")
1228 }
1229 );
1230 }
1231
1232 #[test]
1233 fn mod_with_no_matching_rule_falls_through_to_next_mod() {
1234 let dir = tempfile::tempdir().unwrap();
1235 let mods_dir = dir.path();
1236
1237 write_enabled_mods(mods_dir, &["textures_mod", "levels_mod"]);
1238 write_mod_config(
1239 mods_dir,
1240 "textures_mod",
1241 &directory_rule("textures/**", "mods/textures"),
1242 );
1243 write_mod_config(
1244 mods_dir,
1245 "levels_mod",
1246 &directory_rule("data/levels/*.dat", "mods/levels"),
1247 );
1248
1249 let (config, skipped) = HookConfig::load_from_mods_dir(mods_dir).unwrap();
1250 assert!(skipped.is_empty());
1251 let action = config.resolve("data/levels/level1.dat", |_| true);
1252 assert_eq!(
1253 action,
1254 ResolvedAction::Redirect {
1255 replacement_path: PathBuf::from("mods/levels/level1.dat")
1256 }
1257 );
1258 }
1259
1260 #[test]
1261 fn missing_mod_json_is_skipped_not_fatal() {
1262 let dir = tempfile::tempdir().unwrap();
1263 let mods_dir = dir.path();
1264
1265 write_enabled_mods(mods_dir, &["ghost_mod", "real_mod"]);
1268 write_mod_config(
1269 mods_dir,
1270 "real_mod",
1271 &directory_rule("data/levels/*.dat", "mods/real"),
1272 );
1273
1274 let (config, skipped) = HookConfig::load_from_mods_dir(mods_dir).unwrap();
1275 assert_eq!(skipped.len(), 1);
1276 assert_eq!(skipped[0].0, "ghost_mod");
1277 let action = config.resolve("data/levels/level1.dat", |_| true);
1278 assert_eq!(
1279 action,
1280 ResolvedAction::Redirect {
1281 replacement_path: PathBuf::from("mods/real/level1.dat")
1282 }
1283 );
1284 }
1285
1286 #[test]
1287 fn invalid_mod_config_is_skipped_not_partially_applied() {
1288 let dir = tempfile::tempdir().unwrap();
1289 let mods_dir = dir.path();
1290
1291 write_enabled_mods(mods_dir, &["broken_mod", "real_mod"]);
1294 let broken = ModConfig {
1295 name: "Broken Mod".to_string(),
1296 rules: vec![RedirectionRule {
1297 glob_pattern: "data/levels/*.dat".into(),
1298 target: Some(RuleTarget::Path(PathBuf::from("mods/broken"))),
1299 mode: RedirectionMode::GracefulFallback,
1300 patches: vec![],
1301 enable_if: Some("Typo_Boost".to_string()),
1302 }],
1303 parameters: vec![ModParameter {
1304 id: "Fov_Boost".to_string(),
1305 name: "FOV boost".to_string(),
1306 help_text: "".to_string(),
1307 kind: ParameterKind::Number {
1308 min: 0.0,
1309 max: 10.0,
1310 step: None,
1311 },
1312 enable_if: None,
1313 hidden: false,
1314 validation: None,
1315 }],
1316 ..ModConfig::default()
1317 };
1318 broken
1319 .write_to_path(&mods_dir.join("broken_mod").join(MOD_FILE_NAME))
1320 .unwrap();
1321 write_mod_config(
1322 mods_dir,
1323 "real_mod",
1324 &directory_rule("data/textures/*.dat", "mods/real"),
1325 );
1326
1327 let (config, skipped) = HookConfig::load_from_mods_dir(mods_dir).unwrap();
1328
1329 assert_eq!(skipped.len(), 1);
1330 assert_eq!(skipped[0].0, "broken_mod");
1331 assert!(matches!(skipped[0].1, HookConfigError::Invalid { .. }));
1332
1333 assert!(
1335 config
1336 .find_matching_rule("data/levels/level1.dat")
1337 .is_none()
1338 );
1339 assert!(
1340 config
1341 .find_matching_rule("data/textures/rock.dat")
1342 .is_some()
1343 );
1344 }
1345
1346 #[test]
1347 fn missing_mods_state_file_is_an_error() {
1348 let dir = tempfile::tempdir().unwrap();
1349 assert!(HookConfig::load_from_mods_dir(dir.path()).is_err());
1350 }
1351
1352 #[test]
1360 fn load_mods_state_missing_file_is_default_not_error() {
1361 let dir = tempfile::tempdir().unwrap();
1362 assert_eq!(load_mods_state(dir.path()).unwrap(), ModsState::default());
1365 }
1366
1367 #[test]
1368 fn load_mods_state_reads_saved_order() {
1369 let dir = tempfile::tempdir().unwrap();
1370 write_enabled_mods(dir.path(), &["zeta_mod", "alpha_mod"]);
1371 assert_eq!(
1372 load_mods_state(dir.path()).unwrap().active.enabled,
1373 vec!["zeta_mod".to_string(), "alpha_mod".to_string()]
1374 );
1375 }
1376
1377 #[test]
1378 fn load_mods_state_invalid_json_is_an_error() {
1379 let dir = tempfile::tempdir().unwrap();
1380 std::fs::write(dir.path().join(MODS_STATE_FILE_NAME), b"not json").unwrap();
1381 assert!(load_mods_state(dir.path()).is_err());
1382 }
1383
1384 #[test]
1385 fn save_mods_state_creates_mods_dir_and_round_trips() {
1386 let dir = tempfile::tempdir().unwrap();
1387 let mods_dir = dir.path().join(".supreme/mods");
1390
1391 let state = ModsState {
1392 active: ModsPreset {
1393 order: vec!["board_mod".to_string(), "trick_mod".to_string()],
1394 enabled: vec!["board_mod".to_string(), "trick_mod".to_string()],
1395 configuration: ModConfigurations::new(),
1396 },
1397 presets: HashMap::new(),
1398 };
1399 save_mods_state(&mods_dir, &state).unwrap();
1400
1401 assert_eq!(load_mods_state(&mods_dir).unwrap(), state);
1402 }
1403
1404 #[test]
1405 fn save_mods_state_overwrites_previous_contents() {
1406 let dir = tempfile::tempdir().unwrap();
1407 write_enabled_mods(dir.path(), &["old_mod"]);
1408
1409 let new_state = ModsState {
1410 active: ModsPreset {
1411 order: vec!["new_mod".to_string()],
1412 enabled: vec!["new_mod".to_string()],
1413 configuration: ModConfigurations::new(),
1414 },
1415 presets: HashMap::new(),
1416 };
1417 save_mods_state(dir.path(), &new_state).unwrap();
1418
1419 assert_eq!(
1420 load_mods_state(dir.path()).unwrap().active.enabled,
1421 vec!["new_mod".to_string()]
1422 );
1423 }
1424
1425 #[test]
1426 fn load_from_mods_dir_reads_active_configuration_from_mods_state_separately_from_mod_json() {
1427 let dir = tempfile::tempdir().unwrap();
1428 let mods_dir = dir.path();
1429
1430 write_enabled_mods(mods_dir, &["board_mod"]);
1431 let config = ModConfig {
1432 name: "Board Mod".to_string(),
1433 rules: vec![gated_rule(
1434 "Data/Boards/style.dat",
1435 "mods/board/${Board_Style}.dat",
1436 "Board_Style != \"\"",
1437 )],
1438 parameters: vec![dropdown_param("Board_Style", &["Wood", "Carbon"])],
1439 ..ModConfig::default()
1440 };
1441 config
1442 .write_to_path(&mods_dir.join("board_mod").join(MOD_FILE_NAME))
1443 .unwrap();
1444
1445 let (unconfigured, _) = HookConfig::load_from_mods_dir(mods_dir).unwrap();
1448 assert!(
1449 unconfigured
1450 .find_matching_rule("Data/Boards/style.dat")
1451 .is_none()
1452 );
1453
1454 let mut state = load_mods_state(mods_dir).unwrap();
1459 state.active.configuration.insert(
1460 "board_mod".to_string(),
1461 HashMap::from([("Board_Style".to_string(), serde_json::json!("Carbon"))]),
1462 );
1463 save_mods_state(mods_dir, &state).unwrap();
1464
1465 let (configured, skipped) = HookConfig::load_from_mods_dir(mods_dir).unwrap();
1466 assert!(skipped.is_empty());
1467 let action = configured.resolve("Data/Boards/style.dat", |_| true);
1468 assert_eq!(
1469 action,
1470 ResolvedAction::Redirect {
1471 replacement_path: PathBuf::from("mods/board/Carbon.dat")
1472 }
1473 );
1474 }
1475}