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