Skip to main content

utils/
settings.rs

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}