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 aether_home() -> Option<PathBuf> {
41 resolve_home(
42 std::env::var("AETHER_HOME").ok().as_deref(),
43 std::env::var("HOME").ok().as_deref(),
44 std::env::var("USERPROFILE").ok().as_deref(),
45 ".aether",
46 )
47}
48
49pub fn resolve_home(
50 env_override: Option<&str>,
51 home: Option<&str>,
52 userprofile: Option<&str>,
53 dot_dir: &str,
54) -> Option<PathBuf> {
55 if let Some(value) = env_override
56 && !value.trim().is_empty()
57 {
58 return Some(PathBuf::from(value));
59 }
60
61 let fallback_home = home.or(userprofile)?;
62 Some(PathBuf::from(fallback_home).join(dot_dir))
63}
64
65fn load_or_create_at<T: Serialize + DeserializeOwned + Default>(path: &Path) -> T {
66 let raw = match fs::read_to_string(path) {
67 Ok(raw) => raw,
68 Err(error) if error.kind() == io::ErrorKind::NotFound => {
69 let defaults = T::default();
70 if let Err(error) = save_to_path(path, &defaults) {
71 warn!("Failed to write default settings to {}: {error}", path.display());
72 }
73 return defaults;
74 }
75 Err(error) => {
76 warn!("Failed reading settings {}: {error}", path.display());
77 return T::default();
78 }
79 };
80
81 match serde_json::from_str::<T>(&raw) {
82 Ok(settings) => settings,
83 Err(error) => {
84 warn!("Malformed settings JSON at {}: {error}", path.display());
85 T::default()
86 }
87 }
88}
89
90fn save_to_path<T: Serialize>(path: &Path, settings: &T) -> io::Result<()> {
91 if let Some(parent) = path.parent() {
92 fs::create_dir_all(parent)?;
93 }
94
95 let temp_path = temp_path_for(path);
96 let serialized = serde_json::to_vec_pretty(settings)
97 .map_err(|error| io::Error::other(format!("Failed to serialize settings: {error}")))?;
98
99 {
100 let mut file = fs::File::create(&temp_path)?;
101 file.write_all(&serialized)?;
102 file.write_all(b"\n")?;
103 file.sync_all()?;
104 }
105
106 fs::rename(&temp_path, path)?;
107 Ok(())
108}
109
110fn temp_path_for(path: &Path) -> PathBuf {
111 let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |duration| duration.as_nanos());
112 let pid = std::process::id();
113 path.with_extension(format!("json.tmp.{pid}.{nanos}"))
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use serde::Deserialize;
120 use tempfile::TempDir;
121
122 #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
123 struct FakeSettings {
124 name: String,
125 }
126
127 #[test]
128 fn creates_defaults_when_missing() {
129 let temp_dir = TempDir::new().unwrap();
130 let path = temp_dir.path().join("settings.json");
131
132 let settings: FakeSettings = load_or_create_at(&path);
133
134 assert_eq!(settings, FakeSettings::default());
135 assert!(path.exists());
136 }
137
138 #[test]
139 fn round_trip_serde() {
140 let temp_dir = TempDir::new().unwrap();
141 let path = temp_dir.path().join("settings.json");
142 let settings = FakeSettings { name: "test".to_string() };
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 { name: "first".to_string() };
196 save_to_path(&path, &first).unwrap();
197
198 let second = FakeSettings { name: "second".to_string() };
199 save_to_path(&path, &second).unwrap();
200
201 let loaded: FakeSettings = load_or_create_at(&path);
202 assert_eq!(loaded, second);
203
204 let temp_count = fs::read_dir(temp_dir.path())
205 .unwrap()
206 .filter_map(Result::ok)
207 .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp."))
208 .count();
209 assert_eq!(temp_count, 0, "temporary files should be cleaned up");
210 }
211
212 #[test]
213 fn settings_store_load_and_save() {
214 let temp_dir = TempDir::new().unwrap();
215 let store = SettingsStore::from_path(temp_dir.path());
216
217 let settings: FakeSettings = store.load_or_create();
218 assert_eq!(settings, FakeSettings::default());
219
220 let updated = FakeSettings { name: "updated".to_string() };
221 store.save(&updated).unwrap();
222
223 let loaded: FakeSettings = store.load_or_create();
224 assert_eq!(loaded, updated);
225 }
226}