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 {
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}