tokenburn_core/features/settings/
ops.rs1use 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
11pub fn config_dir() -> Option<PathBuf> {
14 env_path("TOKENBURN_CONFIG_DIR").or_else(|| dirs::config_dir().map(|d| d.join("tokenburn")))
15}
16
17pub fn settings_path() -> Option<PathBuf> {
19 config_dir().map(|d| d.join("settings.json"))
20}
21
22pub 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
35pub 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
52pub fn theme_index() -> usize {
54 find_theme(&load().theme).unwrap_or(0)
55}
56
57pub fn currency() -> Currency {
59 let s = load();
60 resolve(&s.currency, &s.rates)
61}
62
63pub 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
77pub 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 #[test]
154 fn the_env_override_drives_the_public_load_and_save_api() {
155 let d = tempfile::tempdir().unwrap();
156 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 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 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}