1use dashmap::DashMap;
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, OnceLock};
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum SettingsError {
9 #[error("I/O error: {0}")]
10 Io(#[from] std::io::Error),
11 #[error("JSON error: {0}")]
12 Json(#[from] serde_json::Error),
13}
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct Settings {
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub download_dir: Option<String>,
20 #[serde(
25 default,
26 alias = "webuiLanguage",
27 skip_serializing_if = "Option::is_none"
28 )]
29 pub display_language: Option<String>,
30}
31
32static SETTINGS_CACHE: OnceLock<DashMap<String, Settings>> = OnceLock::new();
33
34fn cache() -> &'static DashMap<String, Settings> {
35 SETTINGS_CACHE.get_or_init(DashMap::new)
36}
37
38fn store_lock() -> &'static Mutex<()> {
41 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
42 LOCK.get_or_init(|| Mutex::new(()))
43}
44
45fn settings_key(app_data_dir: &Path) -> String {
46 app_data_dir.to_string_lossy().to_string()
47}
48
49pub fn settings_path(app_data_dir: &Path) -> PathBuf {
50 lingxia_app_context::app_state_file(app_data_dir, "settings.json")
51}
52
53pub fn load(app_data_dir: &Path) -> Result<Settings, SettingsError> {
54 let key = settings_key(app_data_dir);
55 if let Some(entry) = cache().get(&key) {
56 return Ok(entry.value().clone());
57 }
58
59 let path = settings_path(app_data_dir);
60 let settings = match std::fs::read(&path) {
61 Ok(bytes) => match serde_json::from_slice::<Settings>(&bytes) {
62 Ok(settings) => settings,
63 Err(err) => {
64 log::error!("corrupt {}: {err}; using defaults", path.display());
67 let _ = std::fs::rename(&path, path.with_extension("json.corrupt"));
68 Settings::default()
69 }
70 },
71 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Settings::default(),
72 Err(err) => return Err(SettingsError::Io(err)),
73 };
74
75 cache().insert(key, settings.clone());
76 Ok(settings)
77}
78
79pub fn save(app_data_dir: &Path, settings: &Settings) -> Result<(), SettingsError> {
80 let path = settings_path(app_data_dir);
81 if let Some(parent) = path.parent() {
82 std::fs::create_dir_all(parent)?;
83 }
84 let bytes = serde_json::to_vec_pretty(settings)?;
85 let tmp = path.with_extension("json.tmp");
87 std::fs::write(&tmp, bytes)?;
88 replace_saved_file(&tmp, &path)?;
89 cache().insert(settings_key(app_data_dir), settings.clone());
90 Ok(())
91}
92
93#[cfg(not(windows))]
94fn replace_saved_file(tmp: &Path, path: &Path) -> Result<(), SettingsError> {
95 Ok(std::fs::rename(tmp, path)?)
96}
97
98#[cfg(windows)]
99fn replace_saved_file(tmp: &Path, path: &Path) -> Result<(), SettingsError> {
100 let backup = path.with_extension("json.bak");
101 if backup.exists() {
102 std::fs::remove_file(&backup)?;
103 }
104 let had_previous = path.exists();
105 if had_previous {
106 std::fs::rename(path, &backup)?;
107 }
108 if let Err(err) = std::fs::rename(tmp, path) {
109 if had_previous {
110 let _ = std::fs::rename(&backup, path);
111 }
112 return Err(SettingsError::Io(err));
113 }
114 if had_previous {
115 let _ = std::fs::remove_file(backup);
116 }
117 Ok(())
118}
119
120pub fn get_download_dir(app_data_dir: &Path) -> Result<Option<PathBuf>, SettingsError> {
121 Ok(load(app_data_dir)?
122 .download_dir
123 .filter(|value| !value.trim().is_empty())
124 .map(PathBuf::from))
125}
126
127pub fn set_download_dir(
128 app_data_dir: &Path,
129 path: Option<impl AsRef<Path>>,
130) -> Result<(), SettingsError> {
131 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
132 let mut settings = load(app_data_dir)?;
133 settings.download_dir = path.map(|value| value.as_ref().to_string_lossy().to_string());
134 save(app_data_dir, &settings)
135}
136
137pub fn get_display_language(app_data_dir: &Path) -> Result<Option<String>, SettingsError> {
138 Ok(load(app_data_dir)?
139 .display_language
140 .filter(|value| !value.trim().is_empty()))
141}
142
143pub fn set_display_language(
144 app_data_dir: &Path,
145 language: Option<&str>,
146) -> Result<(), SettingsError> {
147 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
148 let mut settings = load(app_data_dir)?;
149 settings.display_language = language.map(str::to_string);
150 save(app_data_dir, &settings)
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn display_language_round_trips_with_other_settings() {
159 let dir = tempfile::tempdir().unwrap();
160 set_download_dir(dir.path(), Some(dir.path().join("downloads"))).unwrap();
161 set_display_language(dir.path(), Some("zh-CN")).unwrap();
162
163 assert_eq!(
164 get_display_language(dir.path()).unwrap().as_deref(),
165 Some("zh-CN")
166 );
167 assert_eq!(
168 get_download_dir(dir.path()).unwrap(),
169 Some(dir.path().join("downloads"))
170 );
171 }
172
173 #[test]
174 fn display_language_reads_the_pre_rename_stored_key() {
175 let dir = tempfile::tempdir().unwrap();
176 let path = settings_path(dir.path());
177 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
178 std::fs::write(&path, r#"{ "webuiLanguage": "zh-CN" }"#).unwrap();
179
180 assert_eq!(
181 get_display_language(dir.path()).unwrap().as_deref(),
182 Some("zh-CN")
183 );
184 }
185
186 #[test]
187 fn corrupt_settings_file_recovers_to_defaults() {
188 let dir = tempfile::tempdir().unwrap();
189 let path = settings_path(dir.path());
190 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
191 std::fs::write(&path, "{ not json").unwrap();
192
193 assert!(load(dir.path()).unwrap().download_dir.is_none());
194 assert!(!path.exists());
195 assert!(path.with_extension("json.corrupt").exists());
196
197 set_display_language(dir.path(), Some("en-US")).unwrap();
199 assert_eq!(
200 get_display_language(dir.path()).unwrap().as_deref(),
201 Some("en-US")
202 );
203 }
204}