Skip to main content

supreme_hook_config/
mods_state.rs

1//! On-disk I/O for the mods directory's single state file: [`ModsState`]
2//! (persisted as `mods.json`), which replaces the older
3//! `enabled_mods.json` + `mod_configuration.json` pair. Consolidating them
4//! fixes a specific bug in that older layout: with two files, a mod's
5//! *display* position was reconstructed at load time from which bucket it
6//! happened to be in (enabled vs. not), so toggling a mod would visibly
7//! reshuffle the whole list.
8//!
9//! [`ModsPreset`] is the one shape used both for the live, currently-applied
10//! configuration ([`ModsState::active`]) and for every saved, not-currently
11//! -active preset in [`ModsState::presets`], deliberately the same type,
12//! not two structs that happen to share field names. Each carries its own
13//! full `order` (display position, doubling as DLL merge-priority order,
14//! see [`ModsPreset::priority_order`]) alongside `enabled` (membership) and
15//! `configuration` (per-mod overrides). `order` is never touched by
16//! enabling/disabling a mod, only by an explicit reorder or by
17//! [`ModsState::sync_active_order`] reconciling it against what's actually
18//! installed on disk.
19//!
20//! Applying a saved preset ([`ModsState::apply_preset`]) is a plain
21//! `active = preset.clone`, the preset's `order` becomes the new display
22//! order outright, dropping any locally-installed mod the preset doesn't
23//! mention. Those dropped mods aren't lost: the next
24//! [`ModsState::sync_active_order`] call (part of every `discover_mods`
25//! scan) re-appends every installed mod not already in `active.order`, so
26//! the launcher's list still shows every installed mod after switching
27//! presets, just with the preset's chosen mods first, in the preset's
28//! order, and everything else appended after.
29
30use crate::error::HookConfigError;
31use std::collections::HashMap;
32use std::path::Path;
33
34/// Every mod's current parameter *overrides*, values a person has
35/// actually set via the launcher, keyed by mod directory name, then by
36/// [`crate::mod_config::ModParameter::id`], layered on top of that mod's
37/// own [`crate::mod_config::ModConfig::configuration`] defaults at
38/// resolution time (see
39/// [`crate::mod_config::ModConfig::effective_configuration`]).
40pub type ModConfigurations = HashMap<String, HashMap<String, serde_json::Value>>;
41
42/// The on-disk mods directory, relative to the game's root directory.
43/// Layout:
44/// - `<mods_dir>/mods.json`, the single state file described by
45///   [`ModsState`].
46/// - `<mods_dir>/<mod_name>/mod.json`, that mod's own metadata, rules,
47///   parameters, and default values for them, see
48///   [`crate::mod_config::ModConfig`]. This is the author's distributable
49///   definition: reinstalling or updating a mod means replacing this whole
50///   file (and the mod's other assets), which is exactly why per-user
51///   *overrides* live in [`ModsState`] instead, an update can safely
52///   replace even a mod's defaults without touching what a person has
53///   personally changed.
54pub const DEFAULT_MODS_DIR_NAME: &str = ".supreme/mods";
55/// File name, within [`DEFAULT_MODS_DIR_NAME`], holding [`ModsState`].
56pub const MODS_STATE_FILE_NAME: &str = "mods.json";
57/// File name, within each mod's own subdirectory, holding that mod's
58/// metadata, rules and parameters.
59pub const MOD_FILE_NAME: &str = "mod.json";
60
61/// A full mod configuration: every known mod's display order, which of
62/// them are enabled, and their settings. Used both as the live,
63/// currently-applied configuration ([`ModsState::active`]) and as a named,
64/// saved shortcut ([`ModsState::presets`]), the same shape either way, so
65/// "the active configuration" and "a saved preset" can never structurally
66/// drift apart.
67#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default, PartialEq)]
68pub struct ModsPreset {
69    /// Every mod's directory name this configuration knows about, in
70    /// display order, including disabled ones. For the *active*
71    /// configuration this also doubles as DLL merge-priority order (see
72    /// [`ModsPreset::priority_order`]): dragging a mod in the launcher's
73    /// list both moves it on screen and changes where it merges relative
74    /// to other enabled mods, deliberately, since those are the same
75    /// action. For a saved-but-inactive preset, `order` is simply frozen
76    /// at save time until the preset is applied.
77    #[serde(default)]
78    pub order: Vec<String>,
79    /// Which mods (by directory name) are enabled, membership only,
80    /// order is meaningless here. This configuration's actual priority
81    /// order is always `order` filtered to this set, computed on demand
82    /// by [`ModsPreset::priority_order`], never stored separately, so it
83    /// can never drift from what `order` shows.
84    #[serde(default)]
85    pub enabled: Vec<String>,
86    /// Every enabled mod's current parameter overrides, keyed by mod
87    /// directory name. A mod with no entry here simply has no overrides,
88    /// every parameter falls back to that mod's own
89    /// [`crate::mod_config::ModConfig::configuration`] default, or fully
90    /// unset if it doesn't have one either.
91    #[serde(default)]
92    pub configuration: ModConfigurations,
93}
94
95impl ModsPreset {
96    /// This configuration's effective merge-priority order: every name in
97    /// [`Self::order`] that's also in [`Self::enabled`], in `order`'s
98    /// order. Deliberately computed rather than stored, display order
99    /// *is* priority order, with no second copy that could disagree with
100    /// what the launcher's list shows.
101    pub fn priority_order(&self) -> Vec<&str> {
102        let enabled_set: std::collections::HashSet<&str> =
103            self.enabled.iter().map(String::as_str).collect();
104        self.order
105            .iter()
106            .map(String::as_str)
107            .filter(|name| enabled_set.contains(name))
108            .collect()
109    }
110
111    /// Reconciles [`Self::order`] against `discovered`, the mod
112    /// directory names actually found under `mods_dir` right now. Mods
113    /// present in `discovered` but not yet in `order` are appended at the
114    /// end, in `discovered`'s order (stable but otherwise arbitrary among
115    /// newly-seen mods, typically already alphabetical, since callers
116    /// usually pass a sorted directory listing). Mods in `order` but no
117    /// longer in `discovered` are dropped. Existing entries keep their
118    /// relative position.
119    ///
120    /// This never touches [`Self::enabled`], membership isn't affected
121    /// by a mod folder simply appearing or disappearing on disk, only by
122    /// an explicit enable/disable.
123    pub fn sync_order<I, S>(&mut self, discovered: I)
124    where
125        I: IntoIterator<Item = S>,
126        S: Into<String>,
127    {
128        let discovered: Vec<String> = discovered.into_iter().map(Into::into).collect();
129        let discovered_set: std::collections::HashSet<&str> =
130            discovered.iter().map(String::as_str).collect();
131
132        self.order
133            .retain(|name| discovered_set.contains(name.as_str()));
134
135        let known: std::collections::HashSet<String> = self.order.iter().cloned().collect();
136        for name in discovered {
137            if !known.contains(&name) {
138                self.order.push(name);
139            }
140        }
141    }
142
143    /// Sets which mods are enabled to exactly `enabled_names`,
144    /// membership only; their relative priority is still whatever `order`
145    /// says (see [`Self::priority_order`]), unaffected by the order
146    /// `enabled_names` happens to be given in. Call this after a checkbox
147    /// toggle. Does not touch `order`, dragging to reorder is a
148    /// separate, direct assignment to that field.
149    pub fn set_enabled<'a>(&mut self, enabled_names: impl IntoIterator<Item = &'a str>) {
150        self.enabled = enabled_names.into_iter().map(str::to_string).collect();
151    }
152}
153
154/// The full persisted state of a game's mods directory: the currently-
155/// active configuration, and any named presets saved for later.
156///
157/// No launcher UI surfaces `presets` yet, the field exists so the format
158/// doesn't need to change again once that lands.
159#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default, PartialEq)]
160pub struct ModsState {
161    /// The live, currently-applied configuration, what the game actually
162    /// uses, and what the launcher's Mods page displays and edits
163    /// directly. See [`ModsPreset`] for its shape.
164    #[serde(default)]
165    pub active: ModsPreset,
166    /// Named presets a person has saved, keyed by preset name. Applying
167    /// one ([`ModsState::apply_preset`]) replaces `active` wholesale.
168    #[serde(default)]
169    pub presets: HashMap<String, ModsPreset>,
170}
171
172impl ModsState {
173    /// Reconciles [`ModsState::active`]'s order against `discovered`, see
174    /// [`ModsPreset::sync_order`]. A thin forward to the active
175    /// configuration specifically; a saved-but-inactive preset in
176    /// `presets` is never touched by this, since its `order` should stay
177    /// frozen exactly as saved until it's applied.
178    pub fn sync_active_order<I, S>(&mut self, discovered: I)
179    where
180        I: IntoIterator<Item = S>,
181        S: Into<String>,
182    {
183        self.active.sync_order(discovered);
184    }
185
186    /// Applies a saved preset as the new active configuration: `active`
187    /// becomes an exact copy of `preset`, same `order`, `enabled`, and
188    /// `configuration`. This can drop locally-installed mods the preset
189    /// doesn't mention from `active.order` entirely; they aren't lost,
190    /// they're just no longer part of this configuration until the next
191    /// [`ModsState::sync_active_order`] call (part of every
192    /// `discover_mods` scan) re-appends every installed mod not already
193    /// present, so the launcher's list still shows every installed mod,
194    /// the preset's own mods first, in the preset's order, then everything
195    /// else appended after.
196    ///
197    /// `presets` itself is untouched, applying a preset doesn't consume
198    /// or modify it, so it can be applied again later.
199    pub fn apply_preset(&mut self, preset: &ModsPreset) {
200        self.active = preset.clone();
201    }
202}
203
204/// Reads [`ModsState`] from `<mods_dir>/mods.json`. A missing file
205/// reads as a fresh, empty state rather than an error, the common state
206/// before `.supreme/mods/` has ever been created, e.g. right after a fresh
207/// install.
208///
209/// # Errors
210///
211/// Returns an error if the file exists but isn't valid JSON.
212pub fn load_mods_state(mods_dir: &Path) -> Result<ModsState, HookConfigError> {
213    let path = mods_dir.join(MODS_STATE_FILE_NAME);
214    match std::fs::read(&path) {
215        Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
216        Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(ModsState::default()),
217        Err(source) => Err(HookConfigError::Read { path, source }),
218    }
219}
220
221/// Writes `state` to `<mods_dir>/mods.json`, creating `mods_dir` if
222/// it doesn't exist yet.
223///
224/// # Errors
225///
226/// Returns an error if `mods_dir` can't be created or the file can't be
227/// written.
228pub fn save_mods_state(mods_dir: &Path, state: &ModsState) -> Result<(), HookConfigError> {
229    std::fs::create_dir_all(mods_dir).map_err(|source| HookConfigError::Write {
230        path: mods_dir.to_path_buf(),
231        source,
232    })?;
233    let path = mods_dir.join(MODS_STATE_FILE_NAME);
234    let json = serde_json::to_vec_pretty(state)?;
235    std::fs::write(&path, json).map_err(|source| HookConfigError::Write { path, source })
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn missing_file_reads_as_default_state() {
244        let dir = tempfile::tempdir().unwrap();
245        assert_eq!(load_mods_state(dir.path()).unwrap(), ModsState::default());
246    }
247
248    #[test]
249    fn invalid_json_is_an_error() {
250        let dir = tempfile::tempdir().unwrap();
251        std::fs::write(dir.path().join(MODS_STATE_FILE_NAME), b"not json").unwrap();
252        assert!(load_mods_state(dir.path()).is_err());
253    }
254
255    #[test]
256    fn round_trips_through_disk() {
257        let dir = tempfile::tempdir().unwrap();
258        let mods_dir = dir.path().join(".supreme/mods");
259
260        let mut state = ModsState::default();
261        state.active.order = vec!["a_mod".to_string(), "b_mod".to_string()];
262        state.active.enabled = vec!["b_mod".to_string()];
263        state.active.configuration.insert(
264            "b_mod".to_string(),
265            HashMap::from([("Flag".to_string(), serde_json::json!(true))]),
266        );
267        state.presets.insert(
268            "My Preset".to_string(),
269            ModsPreset {
270                order: vec!["a_mod".to_string()],
271                enabled: vec!["a_mod".to_string()],
272                configuration: ModConfigurations::new(),
273            },
274        );
275
276        save_mods_state(&mods_dir, &state).unwrap();
277        assert_eq!(load_mods_state(&mods_dir).unwrap(), state);
278    }
279
280    #[test]
281    fn sync_active_order_appends_new_and_drops_missing_preserving_relative_order() {
282        let mut state = ModsState::default();
283        state.active.order = vec![
284            "zebra_mod".to_string(),
285            "alpha_mod".to_string(),
286            "removed_mod".to_string(),
287        ];
288
289        // "removed_mod" is gone; "new_mod" just appeared. Discovery order
290        // here is alphabetical, as a sorted dir listing would produce.
291        state.sync_active_order(["alpha_mod", "new_mod", "zebra_mod"]);
292
293        assert_eq!(
294            state.active.order,
295            vec![
296                "zebra_mod".to_string(),
297                "alpha_mod".to_string(),
298                "new_mod".to_string(),
299            ]
300        );
301    }
302
303    #[test]
304    fn sync_active_order_is_idempotent_when_nothing_changed() {
305        let mut state = ModsState::default();
306        state.active.order = vec!["b_mod".to_string(), "a_mod".to_string()];
307        let before = state.active.order.clone();
308
309        state.sync_active_order(["a_mod", "b_mod"]);
310
311        assert_eq!(state.active.order, before);
312    }
313
314    #[test]
315    fn sync_active_order_never_touches_saved_presets() {
316        let mut state = ModsState::default();
317        state.active.order = vec!["alpha_mod".to_string()];
318        state.presets.insert(
319            "Racing".to_string(),
320            ModsPreset {
321                order: vec!["ghost_mod".to_string()],
322                enabled: vec!["ghost_mod".to_string()],
323                configuration: ModConfigurations::new(),
324            },
325        );
326
327        // "ghost_mod" isn't discovered on disk at all, only the active
328        // configuration should be reconciled against that; the saved
329        // preset's frozen order must stay exactly as saved.
330        state.sync_active_order(["alpha_mod"]);
331
332        assert_eq!(
333            state.presets.get("Racing").unwrap().order,
334            vec!["ghost_mod".to_string()]
335        );
336    }
337
338    #[test]
339    fn toggling_enabled_never_reorders_active_order() {
340        // The regression this whole module exists to fix: disabling then
341        // re-enabling a mod must not move it in the display list.
342        let mut state = ModsState::default();
343        state.active.order = vec![
344            "alpha_mod".to_string(),
345            "beta_mod".to_string(),
346            "gamma_mod".to_string(),
347        ];
348        state
349            .active
350            .set_enabled(["alpha_mod", "beta_mod", "gamma_mod"]);
351        let order_before = state.active.order.clone();
352
353        // Disable beta_mod (simulating a UI toggle: membership shrinks,
354        // order is untouched).
355        state.active.set_enabled(["alpha_mod", "gamma_mod"]);
356        assert_eq!(state.active.order, order_before);
357        assert_eq!(
358            state.active.priority_order(),
359            vec!["alpha_mod", "gamma_mod"]
360        );
361
362        // Re-enable it, priority order follows order's original relative
363        // position, not the order re-enabling happened in.
364        state
365            .active
366            .set_enabled(["gamma_mod", "alpha_mod", "beta_mod"]);
367        assert_eq!(state.active.order, order_before);
368        assert_eq!(
369            state.active.priority_order(),
370            vec!["alpha_mod", "beta_mod", "gamma_mod"]
371        );
372    }
373
374    #[test]
375    fn priority_order_follows_order_not_enabled_order() {
376        let mut state = ModsState::default();
377        state.active.order = vec![
378            "c_mod".to_string(),
379            "a_mod".to_string(),
380            "b_mod".to_string(),
381        ];
382
383        // Deliberately a different order than active.order, priority
384        // must still follow active.order, since that's the one true order
385        // for the active configuration.
386        state.active.set_enabled(["a_mod", "c_mod"]);
387
388        assert_eq!(state.active.priority_order(), vec!["c_mod", "a_mod"]);
389    }
390
391    #[test]
392    fn dragging_to_reorder_changes_priority_because_it_changes_order() {
393        // The whole point: for the active configuration there is no
394        // separate priority list to keep in sync, reordering the display
395        // list directly *is* reordering priority.
396        let mut state = ModsState::default();
397        state.active.order = vec!["alpha_mod".to_string(), "beta_mod".to_string()];
398        state.active.set_enabled(["alpha_mod", "beta_mod"]);
399        assert_eq!(state.active.priority_order(), vec!["alpha_mod", "beta_mod"]);
400
401        // Drag beta_mod above alpha_mod in the launcher's list.
402        state.active.order = vec!["beta_mod".to_string(), "alpha_mod".to_string()];
403
404        assert_eq!(state.active.priority_order(), vec!["beta_mod", "alpha_mod"]);
405    }
406
407    #[test]
408    fn apply_preset_replaces_active_wholesale() {
409        let mut state = ModsState {
410            active: ModsPreset {
411                order: vec!["alpha_mod".to_string(), "beta_mod".to_string()],
412                enabled: vec!["alpha_mod".to_string()],
413                configuration: ModConfigurations::new(),
414            },
415            ..Default::default()
416        };
417
418        let preset = ModsPreset {
419            order: vec!["gamma_mod".to_string(), "delta_mod".to_string()],
420            enabled: vec!["gamma_mod".to_string(), "delta_mod".to_string()],
421            configuration: HashMap::from([(
422                "gamma_mod".to_string(),
423                HashMap::from([("Speed".to_string(), serde_json::json!(9.0))]),
424            )]),
425        };
426        state.apply_preset(&preset);
427
428        assert_eq!(state.active, preset);
429    }
430
431    #[test]
432    fn apply_preset_drops_locally_installed_mods_the_preset_does_not_mention() {
433        // A preset is a complete description of a configuration,
434        // applying it means exactly what it says, not merging with
435        // whatever was active before. Any locally-installed mod it
436        // doesn't mention is the launcher's (sync_active_order's) job to
437        // re-surface, not apply_preset's.
438        let mut state = ModsState::default();
439        state.active.order = vec![
440            "alpha_mod".to_string(),
441            "beta_mod".to_string(),
442            "gamma_mod".to_string(),
443        ];
444        state.active.set_enabled(["alpha_mod"]);
445
446        let preset = ModsPreset {
447            order: vec!["gamma_mod".to_string()],
448            enabled: vec!["gamma_mod".to_string()],
449            configuration: ModConfigurations::new(),
450        };
451        state.apply_preset(&preset);
452
453        assert_eq!(state.active.order, vec!["gamma_mod".to_string()]);
454        assert_eq!(state.active.enabled, vec!["gamma_mod".to_string()]);
455    }
456
457    #[test]
458    fn apply_preset_then_sync_active_order_re_appends_dropped_local_mods() {
459        // The full flow the launcher relies on: apply_preset narrows
460        // active.order down to just the preset's mods, then the next
461        // discover_mods scan's sync_active_order call re-appends every
462        // other installed mod at the end, so the list still shows
463        // everything, preset mods first in the preset's order, then
464        // everything else.
465        let mut state = ModsState::default();
466        state.active.order = vec![
467            "alpha_mod".to_string(),
468            "beta_mod".to_string(),
469            "gamma_mod".to_string(),
470        ];
471
472        let preset = ModsPreset {
473            order: vec!["gamma_mod".to_string()],
474            enabled: vec!["gamma_mod".to_string()],
475            configuration: ModConfigurations::new(),
476        };
477        state.apply_preset(&preset);
478
479        // Still-installed mods, in whatever order a sorted directory
480        // listing would produce.
481        state.sync_active_order(["alpha_mod", "beta_mod", "gamma_mod"]);
482
483        assert_eq!(
484            state.active.order,
485            vec![
486                "gamma_mod".to_string(),
487                "alpha_mod".to_string(),
488                "beta_mod".to_string(),
489            ]
490        );
491        // Enabled membership from the preset is untouched by the resync.
492        assert_eq!(state.active.enabled, vec!["gamma_mod".to_string()]);
493    }
494
495    #[test]
496    fn apply_preset_sets_active_configuration_and_does_not_touch_presets_map() {
497        let mut state = ModsState::default();
498        state.active.order = vec!["board_mod".to_string()];
499        state.presets.insert(
500            "Racing".to_string(),
501            ModsPreset {
502                order: vec!["board_mod".to_string()],
503                enabled: vec!["board_mod".to_string()],
504                configuration: HashMap::from([(
505                    "board_mod".to_string(),
506                    HashMap::from([("Speed".to_string(), serde_json::json!(9.0))]),
507                )]),
508            },
509        );
510
511        let preset = state.presets.get("Racing").unwrap().clone();
512        state.apply_preset(&preset);
513
514        assert_eq!(
515            state
516                .active
517                .configuration
518                .get("board_mod")
519                .and_then(|c| c.get("Speed")),
520            Some(&serde_json::json!(9.0))
521        );
522        // Applying a preset doesn't consume or alter the saved copy.
523        assert!(state.presets.contains_key("Racing"));
524    }
525}