1use std::collections::HashMap;
17use std::io::{self, BufRead, Write};
18use std::path::{Path, PathBuf};
19
20pub struct Config {
22 path: PathBuf,
23 data: HashMap<String, String>,
24}
25
26impl Config {
27 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 pub fn get(&self, key: &str) -> Option<&str> {
57 self.data.get(key).map(String::as_str)
58 }
59
60 pub fn get_or<'a>(&'a self, key: &str, default: &'a str) -> &'a str {
62 self.get(key).unwrap_or(default)
63 }
64
65 pub fn get_int(&self, key: &str) -> Option<i64> {
67 self.get(key)?.trim().parse().ok()
68 }
69
70 pub fn get_int_or(&self, key: &str, default: i64) -> i64 {
72 self.get_int(key).unwrap_or(default)
73 }
74
75 pub fn get_float(&self, key: &str) -> Option<f64> {
77 self.get(key)?.trim().parse().ok()
78 }
79
80 pub fn get_float_or(&self, key: &str, default: f64) -> f64 {
82 self.get_float(key).unwrap_or(default)
83 }
84
85 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 pub fn get_bool_or(&self, key: &str, default: bool) -> bool {
98 self.get_bool(key).unwrap_or(default)
99 }
100
101 pub fn set(&mut self, key: impl Into<String>, value: impl ToString) {
103 self.data.insert(key.into(), value.to_string());
104 }
105
106 pub fn remove(&mut self, key: &str) -> Option<String> {
108 self.data.remove(key)
109 }
110
111 pub fn contains(&self, key: &str) -> bool {
113 self.data.contains_key(key)
114 }
115
116 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 pub fn save_defaults(&self) -> io::Result<()> {
136 if !self.path.exists() {
137 self.save()
138 } else {
139 Ok(())
140 }
141 }
142
143 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}