Skip to main content

supreme_hook_config/
lib.rs

1//! Shared data contract for the file-hooking modding system.
2//!
3//! This crate has no Windows-specific dependencies: it is compiled into
4//! both the `launcher` (which writes a `HookConfig` out to disk) and the
5//! `supreme_hook_proxy` DLL (which reads it back and applies it). Keeping
6//! it platform-neutral means the redirection/patch logic can be unit
7//! tested on any host, even though the DLL that consumes it only runs on
8//! Windows.
9//!
10//! Module layout:
11//! - [`error`], the shared [`HookConfigError`] type.
12//! - [`mods_state`], plain load/save I/O for the mods directory's single
13//!   state file (`mods.json`), and the `MOD_FILE_NAME` constant for
14//!   each mod's own `mod.json`.
15//! - [`mod_config`], [`ModConfig`], one mod's authored `mod.json`.
16//! - [`ui`], the optional `ui` section of a `mod.json` ([`ModUi`]):
17//!   launcher buttons/popups rendered generically by the Mods page.
18//! - [`hook_config`], [`HookConfig`], the merged/resolved shape the proxy
19//!   DLL consumes, and how a mods directory becomes one.
20//! - [`dir_listing`], [`patch`], [`resolver`], [`script_engine`], see
21//!   their own module docs.
22
23pub 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// `MATCH_OPTIONS` isn't part of the crate's public API, it's an
53// implementation detail of `hook_config`'s matching, but one test below
54// exercises it directly against a raw `glob::Pattern`, so it's re-exported
55// crate-internally rather than duplicated.
56#[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        // Add a second, broader rule that would also match levels.
152        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        // A `**` that doesn't span a full path component is invalid glob
227        // syntax; such a rule should simply never match rather than panic.
228        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        // path/to/directory/** -> replacement/path/to/other/directory,
251        // keeping everything below the matched prefix intact instead of
252        // flattening it down to just the file's basename.
253        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        // A trailing slash on target shouldn't change the resulting path;
273        // PathBuf::join handles both forms identically.
274        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    /// Regression test: rule authors write patterns in whatever case reads
288    /// naturally (`Data/Levels/...`), while a `rel_key` built from a live NT
289    /// path is always lowercased before it ever reaches this code. Matching
290    /// (`find_matching_rule`) has always accounted for that, see
291    /// `matching_is_case_insensitive`, but the subdirectory-preserving
292    /// suffix computation used to do a plain byte-prefix check and silently
293    /// fell back to just the file's basename on a case-only mismatch,
294    /// dropping every directory level the pattern's fixed prefix covered.
295    /// This is the exact rule/rel_key shape reported flattening
296    /// `.../tracks/easy/replays/replay_0.dat` into `.../tracks/easy/replay_0.dat`.
297    #[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        // No subdirectory portion below the wildcard here, so this should
319        // behave exactly as it did before directory-tree support existed.
320        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        // Test that "data\strings\en.txt" matches "data/strings/*.txt" and redirects correctly
336        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        // Regression test: a glob with no wildcard at all names exactly
350        // one path, so `target` is the replacement file itself, nothing
351        // should be joined onto it (the original bug joined the matched
352        // file name onto `target`, turning a file path into a bogus
353        // nested path like ".../Practice_..._high_times.txt/Alpine_..._high_times.txt").
354        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        // file_exists must never be called: there's no path to probe.
396        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    // --- ModConfig / ModParameter -----------------------------------
429
430    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            // The author's own defaults, distinct from any per-user
514            // override in the active preset's configuration.
515            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        // Confirm the flat wire shape the launcher/mod authors actually
527        // see, `type` alongside `id`/`name`/`help_text`, not nested.
528        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        // Both parameters currently "off": neither rule survives.
568        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            // The author's own default, shipped in mod.json, not an
593            // override from the active preset's configuration.
594            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        // No overrides supplied at all: the rule is still on, from the
603        // mod's own default alone.
604        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        // A person explicitly turned it off, that overrides the author's
623        // default of "on".
624        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        // B has both a default and an override, the override wins.
642        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        // `enable_if` and the placeholder used for interpolation don't
684        // have to be the same parameter.
685        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        // `${Nonexistent}` references no declared parameter, so Rhai
724        // interpolation fails and the original text is returned unchanged.
725        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        // A `${` with no closing `}` is malformed: Rhai interpolation
745        // fails and the original text is returned unchanged.
746        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        // And it must still parse as a valid glob pattern that matches the
771        // literal string it was built from, not a wildcard.
772        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    /// Modeled closely on the real "Fast count-down" mod that motivated
807    /// per-replacement `enable_if`: one unconditional rule whose patch
808    /// mixes replacements gated by a boolean (`Fast_Start`) with
809    /// unconditional ones that only use `${param_id}` interpolation.
810    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        // The rule itself has no enable_if at all, so it always survives
865        // regardless of Fast_Start, only the individual replacements
866        // inside it are conditional.
867        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        // Fast_Start unset (falsy): only the two unconditional, always-on
874        // splash-text replacements survive, using their mod.json defaults,
875        // since no override was supplied.
876        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        // All four survive now: the two Fast_Start-gated ones plus the two
891        // unconditional ones.
892        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        // First_Start_Text overridden away from its "Ready" default;
908        // Second_Start_Text still falls back to its "Go!" default.
909        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        // The new Rhai expressions allow Number and Text parameters to be used
976        // in explicit comparisons (e.g., `resolution >= 1080`), which are valid.
977        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        // But a bare number identifier is invalid because it doesn't evaluate to a boolean.
986        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            // Booleans can be bare identifiers, but Dropdowns must use an explicit check
1000            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    /// Wraps `rules` (built via [`directory_rule`]) into a minimal but
1011    /// valid `ModConfig`, `name` defaults to `mod_name` since these tests
1012    /// don't care about metadata, and writes it to `mod.json`.
1013    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    /// Writes a `mods.json` whose active configuration enables
1025    /// exactly `names`, with `order` set to match, so
1026    /// `active.priority_order()` reads back as `names`, in order.
1027    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        // Priority follows mods.json's active enabled order, not
1057        // alphabetical or insertion order of the write calls above.
1058        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            // Shipped replacement data is never overwritten by the seed.
1112            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        // Finish is *before* start in the merged list. It still applies
1213        // after start redirects into mid/file.txt, because every stage
1214        // starts scanning at the beginning again.
1215        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        // "ghost_mod" is enabled but was deleted from disk; "real_mod"
1266        // should still load normally.
1267        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        // "broken_mod" parses fine but is semantically invalid: its rule's
1292        // enable_if references a parameter that doesn't exist.
1293        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        // broken_mod contributed nothing, not even a permanently-off rule.
1334        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    // --- load_mods_state / save_mods_state ----------------------------
1353    //
1354    // Deliberately more forgiving than `HookConfig::load_from_mods_dir`
1355    // above: these back the launcher's Mods page, where "nothing saved
1356    // yet" (e.g. right after install, before `.supreme/mods/` exists at
1357    // all) is a normal state to render, not a load failure.
1358
1359    #[test]
1360    fn load_mods_state_missing_file_is_default_not_error() {
1361        let dir = tempfile::tempdir().unwrap();
1362        // Note: `dir` itself exists, but nothing has been written into it,
1363        // covers both "mods dir absent" and "mods dir present, file absent".
1364        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        // `mods_dir` is a not-yet-created subdirectory, exercising the
1388        // `create_dir_all` path rather than an already-existing tempdir.
1389        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        // No configuration set on the active configuration yet, the
1446        // parameter reads as unset, so the gated rule shouldn't apply.
1447        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        // A configuration written entirely separately from mod.json, via
1455        // the same state file the launcher's Mods page writes, supplies
1456        // the value, proving load_from_mods_dir actually reads it and
1457        // threads it through gating and interpolation end to end.
1458        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}