Skip to main content

openmw_config/config/
gamesetting.rs

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