Skip to main content

yog_config/
lib.rs

1//! Mod configuration — typed key/value config files under `<game_dir>/yog-config/`.
2//!
3//! File format: `key = value` pairs, one per line. Lines starting with `#` are
4//! comments. Whitespace around keys and values is stripped.
5//!
6//! ```text
7//! # mymod configuration
8//! max_players = 20
9//! welcome_message = Hello World!
10//! pvp_enabled = true
11//! damage_multiplier = 1.5
12//! ```
13//!
14//! Obtain a game dir from `srv.game_dir()` inside an event handler.
15
16use std::collections::HashMap;
17use std::io::{self, BufRead, Write};
18use std::path::{Path, PathBuf};
19
20/// A key/value configuration file backed by `<game_dir>/yog-config/<mod_id>.cfg`.
21pub struct Config {
22    path: PathBuf,
23    data: HashMap<String, String>,
24}
25
26impl Config {
27    /// Load (or create) a config file for `mod_id` under `game_dir`.
28    ///
29    /// The file lives at `<game_dir>/yog-config/<mod_id>.cfg`.
30    /// `:` and `/` in `mod_id` are replaced with `_` in the filename.
31    pub fn load(game_dir: &str, mod_id: &str) -> Self {
32        let safe = mod_id.replace([':', '/'], "_");
33        let dir = Path::new(game_dir).join("yog-config");
34        let path = dir.join(format!("{safe}.cfg"));
35        let data = Self::parse(&path).unwrap_or_default();
36        Self { path, data }
37    }
38
39    fn parse(path: &Path) -> io::Result<HashMap<String, String>> {
40        let file = std::fs::File::open(path)?;
41        let mut map = HashMap::new();
42        for line in io::BufReader::new(file).lines() {
43            let line = line?;
44            let t = line.trim();
45            if t.is_empty() || t.starts_with('#') {
46                continue;
47            }
48            if let Some((k, v)) = t.split_once('=') {
49                map.insert(k.trim().to_string(), v.trim().to_string());
50            }
51        }
52        Ok(map)
53    }
54
55    /// Get a value as a string slice.
56    pub fn get(&self, key: &str) -> Option<&str> {
57        self.data.get(key).map(String::as_str)
58    }
59
60    /// Get a string value, or `default` if the key is absent.
61    pub fn get_or<'a>(&'a self, key: &str, default: &'a str) -> &'a str {
62        self.get(key).unwrap_or(default)
63    }
64
65    /// Get a value parsed as `i64`.
66    pub fn get_int(&self, key: &str) -> Option<i64> {
67        self.get(key)?.trim().parse().ok()
68    }
69
70    /// Get an integer value, or `default` if absent or unparseable.
71    pub fn get_int_or(&self, key: &str, default: i64) -> i64 {
72        self.get_int(key).unwrap_or(default)
73    }
74
75    /// Get a value parsed as `f64`.
76    pub fn get_float(&self, key: &str) -> Option<f64> {
77        self.get(key)?.trim().parse().ok()
78    }
79
80    /// Get a float value, or `default` if absent or unparseable.
81    pub fn get_float_or(&self, key: &str, default: f64) -> f64 {
82        self.get_float(key).unwrap_or(default)
83    }
84
85    /// Get a value parsed as `bool`.
86    ///
87    /// Truthy: `true`, `yes`, `1`, `on`. Falsy: `false`, `no`, `0`, `off`.
88    pub fn get_bool(&self, key: &str) -> Option<bool> {
89        match self.get(key)?.trim().to_lowercase().as_str() {
90            "true" | "yes" | "1" | "on" => Some(true),
91            "false" | "no" | "0" | "off" => Some(false),
92            _ => None,
93        }
94    }
95
96    /// Get a bool value, or `default` if absent or unrecognised.
97    pub fn get_bool_or(&self, key: &str, default: bool) -> bool {
98        self.get_bool(key).unwrap_or(default)
99    }
100
101    /// Set (or overwrite) a key. Call [`save`] to persist.
102    pub fn set(&mut self, key: impl Into<String>, value: impl ToString) {
103        self.data.insert(key.into(), value.to_string());
104    }
105
106    /// Remove a key. Returns the old value if present.
107    pub fn remove(&mut self, key: &str) -> Option<String> {
108        self.data.remove(key)
109    }
110
111    /// Returns `true` if the key exists in the config.
112    pub fn contains(&self, key: &str) -> bool {
113        self.data.contains_key(key)
114    }
115
116    /// Persist the current state to disk. Creates `yog-config/` if needed.
117    ///
118    /// Comments from the original file are not preserved; keys are written
119    /// in alphabetical order for determinism.
120    pub fn save(&self) -> io::Result<()> {
121        if let Some(p) = self.path.parent() {
122            std::fs::create_dir_all(p)?;
123        }
124        let mut file = std::fs::File::create(&self.path)?;
125        writeln!(file, "# Yog mod configuration — auto-generated")?;
126        let mut keys: Vec<_> = self.data.keys().collect();
127        keys.sort();
128        for k in keys {
129            writeln!(file, "{} = {}", k, self.data[k])?;
130        }
131        Ok(())
132    }
133
134    /// Save only if the config file does not yet exist (write defaults on first run).
135    pub fn save_defaults(&self) -> io::Result<()> {
136        if !self.path.exists() {
137            self.save()
138        } else {
139            Ok(())
140        }
141    }
142
143    /// Number of stored entries.
144    pub fn len(&self) -> usize {
145        self.data.len()
146    }
147
148    pub fn is_empty(&self) -> bool {
149        self.data.is_empty()
150    }
151
152    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
153        self.data.iter().map(|(k, v)| (k.as_str(), v.as_str()))
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn values_survive_a_save_and_parse_as_typed() {
163        let dir = std::env::temp_dir().join(format!("yog-config-test-{}", std::process::id()));
164        let _ = std::fs::remove_dir_all(&dir);
165        let game = dir.to_string_lossy();
166        let mut c = Config::load(&game, "my:mod/x");
167        assert!(c.is_empty());
168        c.set("speed", 2.5);
169        c.set("lives", 3);
170        c.set("on", "yes");
171        c.set("name", "a = b");
172        c.save().unwrap();
173        assert!(dir.join("yog-config/my_mod_x.cfg").is_file(), "`:` and `/` become `_`");
174        let c = Config::load(&game, "my:mod/x");
175        assert_eq!(c.get_float("speed"), Some(2.5));
176        assert_eq!(c.get_int_or("lives", 0), 3);
177        assert_eq!(c.get_bool("on"), Some(true));
178        assert_eq!(c.get("name"), Some("a = b"), "only the first `=` splits");
179        assert!(c.get_bool_or("missing", true));
180        assert_eq!(c.get_int("name"), None);
181        let _ = std::fs::remove_dir_all(&dir);
182    }
183}