Skip to main content

tokenburn_core/features/settings/
ops.rs

1use std::io::Write as _;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5
6use super::types::Settings;
7use crate::features::currency::{find, resolve, Currency};
8use crate::features::theme::{find_theme, THEME_NAMES};
9use crate::utils::files::env_path;
10
11/// `$TOKENBURN_CONFIG_DIR`, else `<config dir>/tokenburn`
12/// (`~/.config/tokenburn`, `~/Library/Application Support/tokenburn`, `%APPDATA%\tokenburn`).
13pub fn config_dir() -> Option<PathBuf> {
14    env_path("TOKENBURN_CONFIG_DIR").or_else(|| dirs::config_dir().map(|d| d.join("tokenburn")))
15}
16
17/// Full path of `settings.json`.
18pub fn settings_path() -> Option<PathBuf> {
19    config_dir().map(|d| d.join("settings.json"))
20}
21
22/// Load settings; a missing or malformed file yields the defaults (the file is
23/// left untouched so a hand edit can be recovered).
24pub fn load() -> Settings {
25    settings_path().map(|p| load_from(&p)).unwrap_or_default()
26}
27
28pub fn load_from(path: &Path) -> Settings {
29    std::fs::read_to_string(path)
30        .ok()
31        .and_then(|t| serde_json::from_str(&t).ok())
32        .unwrap_or_default()
33}
34
35/// Save settings atomically (write a temp file next to the target, then rename).
36pub fn save(settings: &Settings) -> Result<()> {
37    let path = settings_path().context("could not determine the config directory")?;
38    save_to(&path, settings)
39}
40
41pub fn save_to(path: &Path, settings: &Settings) -> Result<()> {
42    let parent = path.parent().unwrap_or(Path::new("."));
43    std::fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
44    let json = serde_json::to_string_pretty(settings)?;
45    let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
46    tmp.write_all(json.as_bytes())?;
47    tmp.persist(path)
48        .map_err(|e| anyhow::anyhow!("writing {}: {e}", path.display()))?;
49    Ok(())
50}
51
52/// Index of the saved theme in [`THEME_NAMES`] (0 when unset or unknown).
53pub fn theme_index() -> usize {
54    find_theme(&load().theme).unwrap_or(0)
55}
56
57/// The saved display currency, with the user's own rate applied (USD when unset or unknown).
58pub fn currency() -> Currency {
59    let s = load();
60    resolve(&s.currency, &s.rates)
61}
62
63/// Persist the display currency by code (case-insensitive; unknown codes are ignored).
64/// The user's `rates` are left untouched.
65pub fn save_currency(code: &str) -> Result<()> {
66    let Some(cur) = find(code) else {
67        return Ok(());
68    };
69    let mut s = load();
70    if s.currency == cur.code {
71        return Ok(());
72    }
73    s.currency = cur.code.to_string();
74    save(&s)
75}
76
77/// Persist the theme at `index` (ignored when out of range).
78pub fn save_theme(index: usize) -> Result<()> {
79    let Some(name) = THEME_NAMES.get(index) else {
80        return Ok(());
81    };
82    let mut s = load();
83    if s.theme == *name {
84        return Ok(());
85    }
86    s.theme = (*name).to_string();
87    save(&s)
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn missing_file_gives_defaults() {
96        let d = tempfile::tempdir().unwrap();
97        assert_eq!(load_from(&d.path().join("nope.json")), Settings::default());
98    }
99
100    #[test]
101    fn round_trips_atomically_and_creates_directories() {
102        let d = tempfile::tempdir().unwrap();
103        let p = d.path().join("deep/er/settings.json");
104        save_to(
105            &p,
106            &Settings {
107                theme: "Nord".into(),
108                ..Settings::default()
109            },
110        )
111        .unwrap();
112        assert_eq!(load_from(&p).theme, "Nord");
113        let leftovers: Vec<_> = std::fs::read_dir(p.parent().unwrap())
114            .unwrap()
115            .flatten()
116            .filter(|e| e.file_name() != "settings.json")
117            .collect();
118        assert!(leftovers.is_empty(), "no stray temp files");
119    }
120
121    #[test]
122    fn malformed_json_falls_back_without_touching_the_file() {
123        let d = tempfile::tempdir().unwrap();
124        let p = d.path().join("settings.json");
125        std::fs::write(&p, "{ not json").unwrap();
126        assert_eq!(load_from(&p), Settings::default());
127        assert_eq!(std::fs::read_to_string(&p).unwrap(), "{ not json");
128    }
129
130    #[test]
131    fn older_files_without_a_currency_default_to_usd_and_empty_rates_are_not_written() {
132        let d = tempfile::tempdir().unwrap();
133        let p = d.path().join("settings.json");
134        std::fs::write(&p, r#"{"theme":"Dracula"}"#).unwrap();
135        let s = load_from(&p);
136        assert_eq!((s.theme.as_str(), s.currency.as_str()), ("Dracula", "USD"));
137        assert!(s.rates.is_empty());
138        save_to(&p, &s).unwrap();
139        assert!(!std::fs::read_to_string(&p).unwrap().contains("rates"));
140    }
141
142    #[test]
143    fn unknown_and_missing_fields_are_tolerated() {
144        let d = tempfile::tempdir().unwrap();
145        let p = d.path().join("settings.json");
146        std::fs::write(&p, r#"{"future_option": true}"#).unwrap();
147        assert_eq!(load_from(&p), Settings::default());
148        std::fs::write(&p, r#"{"theme":"Dracula","future_option":1}"#).unwrap();
149        assert_eq!(load_from(&p).theme, "Dracula");
150    }
151
152    /// `TOKENBURN_CONFIG_DIR` is process-wide: one test owns it.
153    #[test]
154    fn the_env_override_drives_the_public_load_and_save_api() {
155        let d = tempfile::tempdir().unwrap();
156        // SAFETY: the only test in this binary that touches TOKENBURN_CONFIG_DIR.
157        unsafe { std::env::set_var("TOKENBURN_CONFIG_DIR", d.path()) };
158
159        assert_eq!(config_dir().unwrap(), d.path());
160        assert_eq!(settings_path().unwrap(), d.path().join("settings.json"));
161        assert_eq!(load(), Settings::default());
162        assert_eq!(theme_index(), 0);
163
164        save_theme(5).unwrap();
165        assert_eq!(theme_index(), 5);
166        assert_eq!(load().theme, THEME_NAMES[5]);
167
168        // Same theme again, and an out-of-range index, are no-ops.
169        let before = std::fs::metadata(settings_path().unwrap())
170            .unwrap()
171            .modified()
172            .unwrap();
173        save_theme(5).unwrap();
174        save_theme(THEME_NAMES.len() + 10).unwrap();
175        assert_eq!(theme_index(), 5);
176        assert_eq!(
177            std::fs::metadata(settings_path().unwrap())
178                .unwrap()
179                .modified()
180                .unwrap(),
181            before,
182            "unchanged theme must not rewrite the file"
183        );
184
185        save(&Settings {
186            theme: "Nord".into(),
187            ..Settings::default()
188        })
189        .unwrap();
190        assert_eq!(load().theme, "Nord");
191
192        // The currency: default USD, saved by code, the user's own rates untouched.
193        assert_eq!(currency().code, "USD");
194        std::fs::write(
195            settings_path().unwrap(),
196            r#"{"theme":"Nord","rates":{"EUR":0.5}}"#,
197        )
198        .unwrap();
199        save_currency("eur").unwrap();
200        assert_eq!(load().currency, "EUR");
201        let cur = currency();
202        assert_eq!((cur.code, cur.per_usd, cur.custom_rate), ("EUR", 0.5, true));
203        assert_eq!(load().rates.get("EUR"), Some(&0.5), "rates survive a save");
204        save_currency("nonsense").unwrap();
205        assert_eq!(load().currency, "EUR", "an unknown code changes nothing");
206        unsafe { std::env::remove_var("TOKENBURN_CONFIG_DIR") };
207    }
208}