cleansys_core/
settings.rs1use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11use std::io::Write as _;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct Settings {
17 #[serde(default)]
21 pub theme_name: Option<String>,
22 #[serde(default)]
27 pub selected_cleaners: Vec<String>,
28}
29
30impl Settings {
31 pub fn theme_index(&self) -> usize {
34 self.theme_name
35 .as_deref()
36 .map(crate::theme::theme_index_by_name)
37 .unwrap_or(0)
38 }
39
40 pub fn selection_key(category_name: &str, item_name: &str) -> String {
42 format!("{category_name}: {item_name}")
43 }
44
45 pub fn is_selected(&self, category_name: &str, item_name: &str) -> bool {
47 self.selected_cleaners
48 .iter()
49 .any(|k| k == &Self::selection_key(category_name, item_name))
50 }
51}
52
53pub fn settings_dir() -> Result<PathBuf> {
55 let dirs = directories::BaseDirs::new().context("could not determine config directory")?;
56 Ok(dirs.config_dir().join("cleansys"))
57}
58
59pub fn settings_json_path() -> Result<PathBuf> {
61 Ok(settings_dir()?.join("settings.json"))
62}
63
64pub fn tui_settings_json_path() -> Result<PathBuf> {
66 Ok(settings_dir()?.join("tui-settings.json"))
67}
68
69fn load_from(path: &Path) -> Result<Settings> {
71 if path.exists() {
72 let content = std::fs::read_to_string(path)
73 .with_context(|| format!("failed to read {}", path.display()))?;
74 return match serde_json::from_str::<Settings>(&content) {
75 Ok(s) => Ok(s),
76 Err(e) => {
77 log::warn!("settings file {path:?} is malformed ({e}); using defaults");
78 Ok(Settings::default())
79 }
80 };
81 }
82 Ok(Settings::default())
83}
84
85fn save_to(path: &Path, settings: &Settings) -> Result<()> {
87 let parent = path.parent().unwrap_or_else(|| Path::new("."));
88 std::fs::create_dir_all(parent)
89 .with_context(|| format!("failed to create directory {}", parent.display()))?;
90
91 let content = serde_json::to_string_pretty(settings).context("failed to serialise settings")?;
92
93 let mut tmp = tempfile::NamedTempFile::new_in(parent)
94 .context("failed to create temporary settings file")?;
95 tmp.write_all(content.as_bytes())
96 .context("failed to write settings to temporary file")?;
97 tmp.persist(path)
98 .map_err(|e| anyhow::anyhow!("failed to persist settings file: {e}"))?;
99
100 Ok(())
101}
102
103pub fn load_settings() -> Result<Settings> {
108 load_from(&settings_json_path()?)
109}
110
111pub fn save_settings(settings: &Settings) -> Result<()> {
113 save_to(&settings_json_path()?, settings)
114}
115
116pub fn load_tui_settings() -> Result<Settings> {
118 load_from(&tui_settings_json_path()?)
119}
120
121pub fn save_tui_settings(settings: &Settings) -> Result<()> {
123 save_to(&tui_settings_json_path()?, settings)
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use tempfile::TempDir;
130
131 #[test]
132 fn default_settings_have_no_theme() {
133 let s = Settings::default();
134 assert!(s.theme_name.is_none());
135 assert_eq!(s.theme_index(), 0);
136 assert!(s.selected_cleaners.is_empty());
137 }
138
139 #[test]
140 fn theme_index_resolves_known_name() {
141 let s = Settings {
142 theme_name: Some("Dracula".to_string()),
143 ..Default::default()
144 };
145 assert_eq!(
146 s.theme_index(),
147 crate::theme::theme_index_by_name("Dracula")
148 );
149 }
150
151 #[test]
152 fn theme_index_falls_back_for_unknown_name() {
153 let s = Settings {
154 theme_name: Some("Not A Real Theme".to_string()),
155 ..Default::default()
156 };
157 assert_eq!(s.theme_index(), 0);
158 }
159
160 #[test]
161 fn selection_key_combines_category_and_item() {
162 assert_eq!(
163 Settings::selection_key("User Land Cleaners", "Browser Caches"),
164 "User Land Cleaners: Browser Caches"
165 );
166 }
167
168 #[test]
169 fn is_selected_checks_selected_cleaners() {
170 let s = Settings {
171 selected_cleaners: vec!["User Land Cleaners: Browser Caches".to_string()],
172 ..Default::default()
173 };
174 assert!(s.is_selected("User Land Cleaners", "Browser Caches"));
175 assert!(!s.is_selected("User Land Cleaners", "Trash"));
176 }
177
178 #[test]
179 fn round_trip_save_and_load() {
180 let dir = TempDir::new().unwrap();
181 let path = dir.path().join("settings.json");
182
183 let settings = Settings {
184 theme_name: Some("Nord".to_string()),
185 selected_cleaners: vec!["User Land Cleaners: Trash".to_string()],
186 };
187 save_to(&path, &settings).unwrap();
188
189 let loaded = load_from(&path).unwrap();
190 assert_eq!(loaded.theme_name.as_deref(), Some("Nord"));
191 assert_eq!(
192 loaded.selected_cleaners,
193 vec!["User Land Cleaners: Trash".to_string()]
194 );
195 }
196
197 #[test]
198 fn load_from_missing_file_returns_default() {
199 let dir = TempDir::new().unwrap();
200 let path = dir.path().join("does-not-exist.json");
201 let loaded = load_from(&path).unwrap();
202 assert!(loaded.theme_name.is_none());
203 }
204
205 #[test]
206 fn load_from_malformed_file_returns_default() {
207 let dir = TempDir::new().unwrap();
208 let path = dir.path().join("settings.json");
209 std::fs::write(&path, "not valid json").unwrap();
210 let loaded = load_from(&path).unwrap();
211 assert!(loaded.theme_name.is_none());
212 }
213
214 #[test]
215 fn save_creates_parent_directories() {
216 let dir = TempDir::new().unwrap();
217 let path = dir.path().join("nested").join("dir").join("settings.json");
218 save_to(&path, &Settings::default()).unwrap();
219 assert!(path.exists());
220 }
221}