1use serde::{Serialize, de::DeserializeOwned};
2use std::fs;
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5use std::time::{SystemTime, UNIX_EPOCH};
6use tracing::warn;
7
8pub struct SettingsStore {
9 home: PathBuf,
10}
11
12impl SettingsStore {
13 pub fn new(env_override: &str, dot_dir: &str) -> Option<Self> {
14 let home = resolve_home(
15 std::env::var(env_override).ok().as_deref(),
16 std::env::var("HOME").ok().as_deref(),
17 std::env::var("USERPROFILE").ok().as_deref(),
18 dot_dir,
19 )?;
20 Some(Self { home })
21 }
22
23 pub fn from_path(home: &Path) -> Self {
24 Self { home: home.to_path_buf() }
25 }
26
27 pub fn home(&self) -> &Path {
28 &self.home
29 }
30
31 pub fn load_or_create<T: Serialize + DeserializeOwned + Default>(&self) -> T {
32 load_or_create_at(&self.home.join("settings.json"))
33 }
34
35 pub fn save<T: Serialize>(&self, settings: &T) -> io::Result<()> {
36 save_to_path(&self.home.join("settings.json"), settings)
37 }
38}
39
40pub fn resolve_home(
41 env_override: Option<&str>,
42 home: Option<&str>,
43 userprofile: Option<&str>,
44 dot_dir: &str,
45) -> Option<PathBuf> {
46 if let Some(value) = env_override
47 && !value.trim().is_empty()
48 {
49 return Some(PathBuf::from(value));
50 }
51
52 let fallback_home = home.or(userprofile)?;
53 Some(PathBuf::from(fallback_home).join(dot_dir))
54}
55
56fn load_or_create_at<T: Serialize + DeserializeOwned + Default>(path: &Path) -> T {
57 let raw = match fs::read_to_string(path) {
58 Ok(raw) => raw,
59 Err(error) if error.kind() == io::ErrorKind::NotFound => {
60 let defaults = T::default();
61 if let Err(error) = save_to_path(path, &defaults) {
62 warn!("Failed to write default settings to {}: {error}", path.display());
63 }
64 return defaults;
65 }
66 Err(error) => {
67 warn!("Failed reading settings {}: {error}", path.display());
68 return T::default();
69 }
70 };
71
72 match serde_json::from_str::<T>(&raw) {
73 Ok(settings) => settings,
74 Err(error) => {
75 warn!("Malformed settings JSON at {}: {error}", path.display());
76 T::default()
77 }
78 }
79}
80
81fn save_to_path<T: Serialize>(path: &Path, settings: &T) -> io::Result<()> {
82 if let Some(parent) = path.parent() {
83 fs::create_dir_all(parent)?;
84 }
85
86 let temp_path = temp_path_for(path);
87 let serialized = serde_json::to_vec_pretty(settings)
88 .map_err(|error| io::Error::other(format!("Failed to serialize settings: {error}")))?;
89
90 {
91 let mut file = fs::File::create(&temp_path)?;
92 file.write_all(&serialized)?;
93 file.write_all(b"\n")?;
94 file.sync_all()?;
95 }
96
97 fs::rename(&temp_path, path)?;
98 Ok(())
99}
100
101fn temp_path_for(path: &Path) -> PathBuf {
102 let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |duration| duration.as_nanos());
103 let pid = std::process::id();
104 path.with_extension(format!("json.tmp.{pid}.{nanos}"))
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use serde::Deserialize;
111 use tempfile::TempDir;
112
113 #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
114 struct FakeSettings {
115 name: String,
116 }
117
118 #[test]
119 fn creates_defaults_when_missing() {
120 let temp_dir = TempDir::new().unwrap();
121 let path = temp_dir.path().join("settings.json");
122
123 let settings: FakeSettings = load_or_create_at(&path);
124
125 assert_eq!(settings, FakeSettings::default());
126 assert!(path.exists());
127 }
128
129 #[test]
130 fn round_trip_serde() {
131 let temp_dir = TempDir::new().unwrap();
132 let path = temp_dir.path().join("settings.json");
133 let settings = FakeSettings { name: "test".to_string() };
134
135 save_to_path(&path, &settings).unwrap();
136 let loaded: FakeSettings = load_or_create_at(&path);
137
138 assert_eq!(loaded, settings);
139 }
140
141 #[test]
142 fn malformed_json_falls_back_to_defaults() {
143 let temp_dir = TempDir::new().unwrap();
144 let path = temp_dir.path().join("settings.json");
145 fs::write(&path, "{not-json").unwrap();
146
147 let loaded: FakeSettings = load_or_create_at(&path);
148
149 assert_eq!(loaded, FakeSettings::default());
150 }
151
152 #[test]
153 fn resolve_home_prefers_env_override() {
154 let resolved = resolve_home(Some("/tmp/custom"), Some("/home/test"), None, ".app").unwrap();
155 assert_eq!(resolved, PathBuf::from("/tmp/custom"));
156 }
157
158 #[test]
159 fn resolve_home_uses_home_fallback() {
160 let resolved = resolve_home(None, Some("/home/test"), None, ".app").unwrap();
161 assert_eq!(resolved, PathBuf::from("/home/test/.app"));
162 }
163
164 #[test]
165 fn resolve_home_uses_userprofile_fallback() {
166 let resolved = resolve_home(None, None, Some("C:\\Users\\test"), ".app").unwrap();
167 assert_eq!(resolved, PathBuf::from("C:\\Users\\test/.app"));
168 }
169
170 #[test]
171 fn resolve_home_ignores_empty_override() {
172 let resolved = resolve_home(Some(" "), Some("/home/test"), None, ".app").unwrap();
173 assert_eq!(resolved, PathBuf::from("/home/test/.app"));
174 }
175
176 #[test]
177 fn resolve_home_returns_none_when_no_home() {
178 assert!(resolve_home(None, None, None, ".app").is_none());
179 }
180
181 #[test]
182 fn atomic_save_overwrites_and_cleans_temp_files() {
183 let temp_dir = TempDir::new().unwrap();
184 let path = temp_dir.path().join("settings.json");
185
186 let first = FakeSettings { name: "first".to_string() };
187 save_to_path(&path, &first).unwrap();
188
189 let second = FakeSettings { name: "second".to_string() };
190 save_to_path(&path, &second).unwrap();
191
192 let loaded: FakeSettings = load_or_create_at(&path);
193 assert_eq!(loaded, second);
194
195 let temp_count = fs::read_dir(temp_dir.path())
196 .unwrap()
197 .filter_map(Result::ok)
198 .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp."))
199 .count();
200 assert_eq!(temp_count, 0, "temporary files should be cleaned up");
201 }
202
203 #[test]
204 fn settings_store_load_and_save() {
205 let temp_dir = TempDir::new().unwrap();
206 let store = SettingsStore::from_path(temp_dir.path());
207
208 let settings: FakeSettings = store.load_or_create();
209 assert_eq!(settings, FakeSettings::default());
210
211 let updated = FakeSettings { name: "updated".to_string() };
212 store.save(&updated).unwrap();
213
214 let loaded: FakeSettings = store.load_or_create();
215 assert_eq!(loaded, updated);
216 }
217}