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