Skip to main content

openmw_config/config/
gamesetting.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (c) 2025 Dave Corley (S3kshun8)
3
4use std::{borrow::Cow, fmt};
5
6use crate::{ConfigError, GameSetting, GameSettingMeta, bail_config};
7
8/// A `fallback=` setting whose value is an RGB colour triple (`r,g,b` with each component 0–255).
9#[derive(Debug, Clone)]
10pub struct ColorGameSetting {
11    meta: GameSettingMeta,
12    key: String,
13    value: (u8, u8, u8),
14    raw_value: String,
15}
16
17impl std::fmt::Display for ColorGameSetting {
18    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19        write!(
20            f,
21            "{}fallback={},{}",
22            self.meta.comment, self.key, self.raw_value
23        )
24    }
25}
26
27/// A `fallback=` setting whose value did not parse as a number or colour triple.
28#[derive(Debug, Clone)]
29pub struct StringGameSetting {
30    meta: GameSettingMeta,
31    key: String,
32    value: String,
33}
34
35impl std::fmt::Display for StringGameSetting {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        write!(
38            f,
39            "{}fallback={},{}",
40            self.meta.comment, self.key, self.value
41        )
42    }
43}
44
45/// A `fallback=` setting whose value parsed as a floating-point number (contains a `.`).
46#[derive(Debug, Clone)]
47pub struct FloatGameSetting {
48    meta: GameSettingMeta,
49    key: String,
50    value: f64,
51    raw_value: String,
52}
53
54impl std::fmt::Display for FloatGameSetting {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(
57            f,
58            "{}fallback={},{}",
59            self.meta.comment, self.key, self.raw_value
60        )
61    }
62}
63
64/// A `fallback=` setting whose value parsed as a 64-bit integer.
65#[derive(Debug, Clone)]
66pub struct IntGameSetting {
67    meta: GameSettingMeta,
68    key: String,
69    value: i64,
70    raw_value: String,
71}
72
73impl std::fmt::Display for IntGameSetting {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        write!(
76            f,
77            "{}fallback={},{}",
78            self.meta.comment, self.key, self.raw_value
79        )
80    }
81}
82
83/// A typed `fallback=Key,Value` entry from an `openmw.cfg` file.
84///
85/// The value is parsed into the most specific type that fits:
86/// - Three comma-separated integers in 0–255 → [`Color`](Self::Color)
87/// - A number containing `.` that parses as `f64` → [`Float`](Self::Float)
88/// - A number that parses as `i64` → [`Int`](Self::Int)
89/// - Anything else → [`String`](Self::String)
90///
91/// [`PartialEq`] comparisons are key-only *within the same variant*, matching `OpenMW`'s
92/// last-defined-wins deduplication semantics used by [`game_settings`](crate::OpenMWConfiguration::game_settings).
93#[derive(Debug, Clone)]
94#[non_exhaustive]
95pub enum GameSettingType {
96    /// An RGB colour triple (`r,g,b`).
97    Color(ColorGameSetting),
98    /// A plain string value (catch-all for values that aren't numeric or colour).
99    String(StringGameSetting),
100    /// A floating-point value.
101    Float(FloatGameSetting),
102    /// A 64-bit integer value.
103    Int(IntGameSetting),
104}
105
106impl GameSettingType {
107    /// Returns the setting key — the text before the first comma in a `fallback=Key,Value` entry.
108    ///
109    /// ```
110    /// use std::path::PathBuf;
111    /// use openmw_config::GameSettingType;
112    /// let setting = GameSettingType::try_from(
113    ///     ("iMaxLevel,50".to_string(), PathBuf::default(), &mut String::new())
114    /// ).unwrap();
115    /// assert_eq!(setting.key(), "iMaxLevel");
116    /// ```
117    #[must_use]
118    pub fn key(&self) -> &String {
119        match self {
120            GameSettingType::Color(setting) => &setting.key,
121            GameSettingType::String(setting) => &setting.key,
122            GameSettingType::Float(setting) => &setting.key,
123            GameSettingType::Int(setting) => &setting.key,
124        }
125    }
126
127    /// Borrowed string view of [`Self::key`].
128    #[must_use]
129    pub fn key_str(&self) -> &str {
130        match self {
131            GameSettingType::Color(setting) => &setting.key,
132            GameSettingType::String(setting) => &setting.key,
133            GameSettingType::Float(setting) => &setting.key,
134            GameSettingType::Int(setting) => &setting.key,
135        }
136    }
137
138    /// Returns the setting value — the text after the first comma in a `fallback=Key,Value` entry.
139    ///
140    /// ```
141    /// use std::path::PathBuf;
142    /// use openmw_config::GameSettingType;
143    /// let setting = GameSettingType::try_from(
144    ///     ("iMaxLevel,50".to_string(), PathBuf::default(), &mut String::new())
145    /// ).unwrap();
146    /// assert_eq!(setting.value(), "50");
147    /// ```
148    #[must_use]
149    pub fn value(&self) -> Cow<'_, str> {
150        match self {
151            GameSettingType::Color(setting) => {
152                let _ = setting.value;
153                Cow::Borrowed(&setting.raw_value)
154            }
155            GameSettingType::String(setting) => Cow::Borrowed(&setting.value),
156            GameSettingType::Float(setting) => {
157                let _ = setting.value;
158                Cow::Borrowed(&setting.raw_value)
159            }
160            GameSettingType::Int(setting) => {
161                let _ = setting.value;
162                Cow::Borrowed(&setting.raw_value)
163            }
164        }
165    }
166}
167
168impl std::fmt::Display for GameSettingType {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        match self {
171            GameSettingType::Color(s) => write!(f, "{s}"),
172            GameSettingType::Float(s) => write!(f, "{s}"),
173            GameSettingType::String(s) => write!(f, "{s}"),
174            GameSettingType::Int(s) => write!(f, "{s}"),
175        }
176    }
177}
178
179impl GameSetting for GameSettingType {
180    fn meta(&self) -> &GameSettingMeta {
181        match self {
182            GameSettingType::Color(s) => &s.meta,
183            GameSettingType::String(s) => &s.meta,
184            GameSettingType::Float(s) => &s.meta,
185            GameSettingType::Int(s) => &s.meta,
186        }
187    }
188}
189
190impl PartialEq for GameSettingType {
191    fn eq(&self, other: &Self) -> bool {
192        use GameSettingType::{Color, Float, Int, String};
193
194        match (self, other) {
195            (Color(a), Color(b)) => a.key == b.key,
196            (String(a), String(b)) => a.key == b.key,
197            (Float(a), Float(b)) => a.key == b.key,
198            (Int(a), Int(b)) => a.key == b.key,
199            // Mismatched types should never be considered equal
200            _ => false,
201        }
202    }
203}
204
205impl PartialEq<&str> for GameSettingType {
206    fn eq(&self, other: &&str) -> bool {
207        use GameSettingType::{Color, Float, Int, String};
208
209        match self {
210            Color(a) => a.key == *other,
211            String(a) => a.key == *other,
212            Float(a) => a.key == *other,
213            Int(a) => a.key == *other,
214        }
215    }
216}
217
218impl Eq for GameSettingType {}
219
220impl TryFrom<(String, std::path::PathBuf, &mut String)> for GameSettingType {
221    type Error = ConfigError;
222
223    fn try_from(
224        (original_value, source_config, queued_comment): (String, std::path::PathBuf, &mut String),
225    ) -> Result<Self, ConfigError> {
226        let Some((key, value)) = original_value.split_once(',') else {
227            bail_config!(invalid_game_setting, original_value, source_config);
228        };
229
230        let key = key.to_string();
231        let value = value.to_string();
232
233        let meta = GameSettingMeta {
234            source_config,
235            comment: queued_comment.clone(),
236        };
237
238        queued_comment.clear();
239
240        if let Some(color) = parse_color_value(&value) {
241            return Ok(GameSettingType::Color(ColorGameSetting {
242                meta,
243                key,
244                value: color,
245                raw_value: value,
246            }));
247        }
248
249        if value.contains('.')
250            && let Ok(f) = value.parse::<f64>()
251        {
252            return Ok(GameSettingType::Float(FloatGameSetting {
253                meta,
254                key,
255                value: f,
256                raw_value: value,
257            }));
258        }
259
260        if let Ok(i) = value.parse::<i64>() {
261            return Ok(GameSettingType::Int(IntGameSetting {
262                meta,
263                key,
264                value: i,
265                raw_value: value,
266            }));
267        }
268
269        Ok(GameSettingType::String(StringGameSetting {
270            meta,
271            key,
272            value,
273        }))
274    }
275}
276
277fn parse_color_value(value: &str) -> Option<(u8, u8, u8)> {
278    let mut parts = value.split(',').map(str::trim);
279    let r = parts.next()?.parse::<u8>().ok()?;
280    let g = parts.next()?.parse::<u8>().ok()?;
281    let b = parts.next()?.parse::<u8>().ok()?;
282
283    if parts.next().is_some() {
284        return None;
285    }
286
287    Some((r, g, b))
288}
289
290#[cfg(test)]
291mod tests {
292    use std::path::PathBuf;
293
294    use super::*;
295
296    fn default_meta() -> GameSettingMeta {
297        GameSettingMeta {
298            source_config: PathBuf::default(),
299            comment: String::default(),
300        }
301    }
302
303    #[test]
304    fn test_value_string_setting() {
305        let setting = GameSettingType::String(StringGameSetting {
306            meta: default_meta(),
307            key: "greeting".into(),
308            value: "hello world".into(),
309        });
310
311        assert_eq!(setting.value(), "hello world");
312    }
313
314    #[test]
315    fn test_value_int_setting() {
316        let setting = GameSettingType::Int(IntGameSetting {
317            meta: default_meta(),
318            key: "MaxEyesOfTodd".into(),
319            value: 3,
320            raw_value: "3".into(),
321        });
322
323        assert_eq!(setting.value(), "3");
324    }
325
326    #[test]
327    fn test_value_float_setting() {
328        let setting = GameSettingType::Float(FloatGameSetting {
329            meta: default_meta(),
330            key: "FLightAttenuationEnfuckulation".into(),
331            value: 0.75,
332            raw_value: "0.75".into(),
333        });
334
335        assert_eq!(setting.value(), "0.75");
336    }
337
338    #[test]
339    fn test_value_color_setting() {
340        let setting = GameSettingType::Color(ColorGameSetting {
341            meta: default_meta(),
342            key: "hud_color".into(),
343            value: (255, 128, 64),
344            raw_value: "255,128,64".into(),
345        });
346
347        assert_eq!(setting.value(), "255,128,64");
348    }
349
350    #[test]
351    fn test_to_string_for_string_setting() {
352        let setting = GameSettingType::String(StringGameSetting {
353            meta: default_meta(),
354            key: "sGreeting".into(),
355            value: "Hello, Nerevar.".into(),
356        });
357
358        assert_eq!(setting.to_string(), "fallback=sGreeting,Hello, Nerevar.");
359    }
360
361    #[test]
362    fn test_to_string_for_int_setting() {
363        let setting = GameSettingType::Int(IntGameSetting {
364            meta: default_meta(),
365            key: "iMaxSpeed".into(),
366            value: 42,
367            raw_value: "42".into(),
368        });
369
370        assert_eq!(setting.to_string(), "fallback=iMaxSpeed,42");
371    }
372
373    #[test]
374    fn test_to_string_for_float_setting() {
375        let setting = GameSettingType::Float(FloatGameSetting {
376            meta: default_meta(),
377            key: "fJumpHeight".into(),
378            value: 1.75,
379            raw_value: "1.75".into(),
380        });
381
382        assert_eq!(setting.to_string(), "fallback=fJumpHeight,1.75");
383    }
384
385    #[test]
386    fn test_to_string_for_color_setting() {
387        let setting = GameSettingType::Color(ColorGameSetting {
388            meta: default_meta(),
389            key: "iHUDColor".into(),
390            value: (128, 64, 255),
391            raw_value: "128,64,255".into(),
392        });
393
394        assert_eq!(setting.to_string(), "fallback=iHUDColor,128,64,255");
395    }
396
397    #[test]
398    fn test_commented_string() {
399        let setting = GameSettingType::Color(ColorGameSetting {
400            meta: GameSettingMeta {
401                source_config: PathBuf::from("$HOME/.config/openmw/openmw.cfg"),
402                comment: String::from("#Monochrome UI Settings\n#\n#\n#\n#######\n##\n##\n##\n"),
403            },
404            key: "iHUDColor".into(),
405            value: (128, 64, 255),
406            raw_value: "128,64,255".into(),
407        });
408
409        assert_eq!(
410            setting.to_string(),
411            "#Monochrome UI Settings\n#\n#\n#\n#######\n##\n##\n##\nfallback=iHUDColor,128,64,255"
412        );
413    }
414
415    // --- TryFrom parsing ---
416
417    fn parse(s: &str) -> Result<GameSettingType, crate::ConfigError> {
418        GameSettingType::try_from((s.to_string(), PathBuf::default(), &mut String::new()))
419    }
420
421    #[test]
422    fn test_parse_string_value() {
423        let setting = parse("sMyKey,hello world").unwrap();
424        assert!(matches!(setting, GameSettingType::String(_)));
425        assert_eq!(setting.key(), "sMyKey");
426        assert_eq!(setting.key_str(), "sMyKey");
427        assert_eq!(setting.value(), "hello world");
428    }
429
430    #[test]
431    fn test_parse_integer_value() {
432        let setting = parse("iSpeed,42").unwrap();
433        assert!(matches!(setting, GameSettingType::Int(_)));
434        assert_eq!(setting.value(), "42");
435    }
436
437    #[test]
438    fn test_parse_negative_integer() {
439        let setting = parse("iDepth,-100").unwrap();
440        assert!(matches!(setting, GameSettingType::Int(_)));
441        assert_eq!(setting.value(), "-100");
442    }
443
444    #[test]
445    fn test_parse_float_value() {
446        let setting = parse("fGravity,9.81").unwrap();
447        assert!(matches!(setting, GameSettingType::Float(_)));
448        assert_eq!(setting.value(), "9.81");
449    }
450
451    #[test]
452    fn test_parse_color_value() {
453        let setting = parse("iSkyColor,100,149,237").unwrap();
454        assert!(matches!(setting, GameSettingType::Color(_)));
455        assert_eq!(setting.value(), "100,149,237");
456    }
457
458    #[test]
459    fn test_parse_missing_comma_errors() {
460        assert!(parse("NoCommaAtAll").is_err());
461    }
462
463    #[test]
464    fn test_parse_value_with_comma_stays_string() {
465        // A string value that contains a comma should not be misidentified as color
466        let setting = parse("sMessage,Hello, traveller").unwrap();
467        assert!(matches!(setting, GameSettingType::String(_)));
468        assert_eq!(setting.value(), "Hello, traveller");
469    }
470
471    #[test]
472    fn test_parse_ambiguous_two_number_value_is_string() {
473        // Two comma-separated numbers is NOT a valid color (needs 3), must fall back to String
474        let setting = parse("sKey,10,20").unwrap();
475        assert!(matches!(setting, GameSettingType::String(_)));
476    }
477
478    #[test]
479    fn test_parse_color_out_of_u8_range_is_string() {
480        // Values > 255 can't be u8 so the whole thing should parse as String
481        let setting = parse("sBig,256,0,0").unwrap();
482        assert!(matches!(setting, GameSettingType::String(_)));
483    }
484
485    #[test]
486    fn test_parse_comment_consumed() {
487        let mut comment = String::from("# some note\n");
488        let setting =
489            GameSettingType::try_from(("iVal,1".to_string(), PathBuf::default(), &mut comment))
490                .unwrap();
491        assert_eq!(setting.meta().comment, "# some note\n");
492        assert!(comment.is_empty(), "comment should be consumed");
493    }
494
495    // --- Equality ---
496
497    #[test]
498    fn test_same_key_same_type_are_equal() {
499        let a = parse("iKey,1").unwrap();
500        let b = parse("iKey,2").unwrap();
501        assert_eq!(a, b, "equality is key-only within the same type");
502    }
503
504    #[test]
505    fn test_different_keys_not_equal() {
506        let a = parse("iKey,1").unwrap();
507        let b = parse("iOther,1").unwrap();
508        assert_ne!(a, b);
509    }
510
511    #[test]
512    fn test_mismatched_types_not_equal() {
513        // "1" parses as Int; "1.0" parses as Float — same logical key, different types
514        let int_setting = parse("iKey,1").unwrap();
515        let float_setting = parse("iKey,1.0").unwrap();
516        assert_ne!(int_setting, float_setting);
517    }
518
519    #[test]
520    fn test_eq_with_str_key() {
521        let setting = parse("iMaxLevel,50").unwrap();
522        assert_eq!(setting, "iMaxLevel");
523        assert_ne!(setting, "iOtherKey");
524    }
525}