Skip to main content

banished_mods/
text.rs

1//! The settings file as `name=value` lines, for tools that run without the game (the mod
2//! manager). It keeps every value it reads, so writing the file back changes only what was
3//! set. The game reads the same file through its own `Persistence`.
4
5use std::collections::BTreeMap;
6#[cfg(not(target_arch = "wasm32"))]
7use std::path::PathBuf;
8
9use crate::Settings;
10
11/// The settings as names and values, in name order (as the game writes them).
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
13pub struct TextSettings {
14    values: BTreeMap<String, String>,
15}
16
17impl TextSettings {
18    /// Read `name=value` lines; other lines are ignored.
19    pub fn parse(text: &str) -> TextSettings {
20        let mut s = TextSettings::default();
21        for line in text.lines() {
22            if let Some((k, v)) = line.split_once('=') {
23                s.values.insert(k.trim().to_string(), v.trim().to_string());
24            }
25        }
26        s
27    }
28
29    /// The text of the file, one `name=value` line per value.
30    pub fn serialize(&self) -> String {
31        self.values
32            .iter()
33            .map(|(k, v)| format!("{k}={v}\n"))
34            .collect()
35    }
36
37    /// A text value, or `None` when absent or empty.
38    pub fn get_text(&self, section: &str, name: &str) -> Option<&str> {
39        self.values
40            .get(&format!("{section}{name}"))
41            .map(String::as_str)
42            .filter(|v| !v.is_empty())
43    }
44
45    /// Store a text value.
46    pub fn set_text(&mut self, section: &str, name: &str, value: &str) {
47        self.values
48            .insert(format!("{section}{name}"), value.to_string());
49    }
50
51    /// Every value whose name starts with `prefix`, as (rest of the name, value).
52    pub fn with_prefix<'a>(&'a self, prefix: &'a str) -> impl Iterator<Item = (&'a str, &'a str)> {
53        self.values
54            .range(prefix.to_string()..)
55            .take_while(move |(k, _)| k.starts_with(prefix))
56            .map(move |(k, v)| (&k[prefix.len()..], v.as_str()))
57    }
58
59    /// Read the file at `path`; an absent file is empty settings.
60    #[cfg(not(target_arch = "wasm32"))]
61    pub fn load(path: &std::path::Path) -> std::io::Result<TextSettings> {
62        match std::fs::read_to_string(path) {
63            Ok(text) => Ok(TextSettings::parse(&text)),
64            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TextSettings::default()),
65            Err(e) => Err(e),
66        }
67    }
68
69    /// Write the file at `path`, through a temporary file so that a failed write leaves the
70    /// old file as it was.
71    #[cfg(not(target_arch = "wasm32"))]
72    pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
73        if let Some(dir) = path.parent() {
74            std::fs::create_dir_all(dir)?;
75        }
76        let temp = path.with_extension("txt.new");
77        std::fs::write(&temp, self.serialize())?;
78        std::fs::rename(&temp, path)
79    }
80}
81
82impl Settings for TextSettings {
83    fn get_bool(&self, section: &str, name: &str, value: &mut bool) {
84        if let Some(v) = self.values.get(&format!("{section}{name}")) {
85            *value = v == "true";
86        }
87    }
88
89    fn get_int(&self, section: &str, name: &str, value: &mut i32) {
90        if let Some(n) = self
91            .values
92            .get(&format!("{section}{name}"))
93            .and_then(|v| v.parse().ok())
94        {
95            *value = n;
96        }
97    }
98
99    fn set_bool(&mut self, section: &str, name: &str, value: bool) {
100        self.set_text(section, name, if value { "true" } else { "false" });
101    }
102
103    fn set_int(&mut self, section: &str, name: &str, value: i32) {
104        self.set_text(section, name, &value.to_string());
105    }
106}
107
108/// The game's settings file: `BANISHED_SETTINGS` if set, else `settings.txt` in the
109/// `banished-wasm` folder of the platform's configuration folder, as the game reads it.
110#[cfg(not(target_arch = "wasm32"))]
111pub fn settings_path() -> Option<PathBuf> {
112    if let Ok(p) = std::env::var("BANISHED_SETTINGS") {
113        return (!p.is_empty()).then(|| PathBuf::from(p));
114    }
115    let base = if cfg!(target_os = "windows") {
116        std::env::var_os("APPDATA").map(PathBuf::from)
117    } else if cfg!(target_os = "macos") {
118        std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support"))
119    } else {
120        std::env::var_os("XDG_CONFIG_HOME")
121            .map(PathBuf::from)
122            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
123    }?;
124    Some(base.join("banished-wasm").join("settings.txt"))
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn keeps_what_it_does_not_set() {
133        let mut s = TextSettings::parse("VideoVSync=true\nModEnabled_apiary=false\n");
134        s.set_bool("ModEnabled_", "apiary", true);
135        s.set_int("ModIndex_", "apiary", 2);
136        assert_eq!(
137            s.serialize(),
138            "ModEnabled_apiary=true\nModIndex_apiary=2\nVideoVSync=true\n"
139        );
140        let mut on = false;
141        s.get_bool("ModEnabled_", "apiary", &mut on);
142        assert!(on);
143        let found: Vec<_> = s.with_prefix("Mod").collect();
144        assert_eq!(found.len(), 2);
145    }
146}