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}