use std::collections::HashMap;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
pub struct Config {
path: PathBuf,
data: HashMap<String, String>,
}
impl Config {
pub fn load(game_dir: &str, mod_id: &str) -> Self {
let safe = mod_id.replace([':', '/'], "_");
let dir = Path::new(game_dir).join("yog-config");
let path = dir.join(format!("{safe}.cfg"));
let data = Self::parse(&path).unwrap_or_default();
Self { path, data }
}
fn parse(path: &Path) -> io::Result<HashMap<String, String>> {
let file = std::fs::File::open(path)?;
let mut map = HashMap::new();
for line in io::BufReader::new(file).lines() {
let line = line?;
let t = line.trim();
if t.is_empty() || t.starts_with('#') {
continue;
}
if let Some((k, v)) = t.split_once('=') {
map.insert(k.trim().to_string(), v.trim().to_string());
}
}
Ok(map)
}
pub fn get(&self, key: &str) -> Option<&str> {
self.data.get(key).map(String::as_str)
}
pub fn get_or<'a>(&'a self, key: &str, default: &'a str) -> &'a str {
self.get(key).unwrap_or(default)
}
pub fn get_int(&self, key: &str) -> Option<i64> {
self.get(key)?.trim().parse().ok()
}
pub fn get_int_or(&self, key: &str, default: i64) -> i64 {
self.get_int(key).unwrap_or(default)
}
pub fn get_float(&self, key: &str) -> Option<f64> {
self.get(key)?.trim().parse().ok()
}
pub fn get_float_or(&self, key: &str, default: f64) -> f64 {
self.get_float(key).unwrap_or(default)
}
pub fn get_bool(&self, key: &str) -> Option<bool> {
match self.get(key)?.trim().to_lowercase().as_str() {
"true" | "yes" | "1" | "on" => Some(true),
"false" | "no" | "0" | "off" => Some(false),
_ => None,
}
}
pub fn get_bool_or(&self, key: &str, default: bool) -> bool {
self.get_bool(key).unwrap_or(default)
}
pub fn set(&mut self, key: impl Into<String>, value: impl ToString) {
self.data.insert(key.into(), value.to_string());
}
pub fn remove(&mut self, key: &str) -> Option<String> {
self.data.remove(key)
}
pub fn contains(&self, key: &str) -> bool {
self.data.contains_key(key)
}
pub fn save(&self) -> io::Result<()> {
if let Some(p) = self.path.parent() {
std::fs::create_dir_all(p)?;
}
let mut file = std::fs::File::create(&self.path)?;
writeln!(file, "# Yog mod configuration — auto-generated")?;
let mut keys: Vec<_> = self.data.keys().collect();
keys.sort();
for k in keys {
writeln!(file, "{} = {}", k, self.data[k])?;
}
Ok(())
}
pub fn save_defaults(&self) -> io::Result<()> {
if !self.path.exists() {
self.save()
} else {
Ok(())
}
}
pub fn len(&self) -> usize {
self.data.len()
}
pub fn is_empty(&self) -> bool {
self.data.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.data.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn values_survive_a_save_and_parse_as_typed() {
let dir = std::env::temp_dir().join(format!("yog-config-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let game = dir.to_string_lossy();
let mut c = Config::load(&game, "my:mod/x");
assert!(c.is_empty());
c.set("speed", 2.5);
c.set("lives", 3);
c.set("on", "yes");
c.set("name", "a = b");
c.save().unwrap();
assert!(dir.join("yog-config/my_mod_x.cfg").is_file(), "`:` and `/` become `_`");
let c = Config::load(&game, "my:mod/x");
assert_eq!(c.get_float("speed"), Some(2.5));
assert_eq!(c.get_int_or("lives", 0), 3);
assert_eq!(c.get_bool("on"), Some(true));
assert_eq!(c.get("name"), Some("a = b"), "only the first `=` splits");
assert!(c.get_bool_or("missing", true));
assert_eq!(c.get_int("name"), None);
let _ = std::fs::remove_dir_all(&dir);
}
}