Skip to main content

cleansys_core/
settings.rs

1//! Simple JSON-backed settings persistence, shared by the TUI and GUI.
2//!
3//! Settings are stored at `~/.config/cleansys/settings.json` (GUI) and
4//! `~/.config/cleansys/tui-settings.json` (TUI) — or the platform-appropriate
5//! config directory. Writes are atomic: content is first written to a
6//! `NamedTempFile` in the same directory and then `persist()`-ed into place,
7//! so a crash mid-write can never produce a corrupted file.
8
9use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11use std::io::Write as _;
12use std::path::{Path, PathBuf};
13
14/// Persisted user preferences.
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct Settings {
17    /// Name of the selected theme (see [`crate::theme::THEME_NAMES`]).
18    /// Stored by name (not index) so preset reordering across releases
19    /// doesn't silently change a user's saved theme.
20    #[serde(default)]
21    pub theme_name: Option<String>,
22    /// Names of cleaners that were selected (checked) the last time the
23    /// app was closed, restored on next launch. Stored as `"Category: Item"`
24    /// pairs so identically-named cleaners in different categories don't
25    /// collide.
26    #[serde(default)]
27    pub selected_cleaners: Vec<String>,
28}
29
30impl Settings {
31    /// Resolve the persisted theme name to an index via
32    /// [`crate::theme::theme_index_by_name`], defaulting to `0`.
33    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    /// Build the `"Category: Item"` key used to identify a selected cleaner.
41    pub fn selection_key(category_name: &str, item_name: &str) -> String {
42        format!("{category_name}: {item_name}")
43    }
44
45    /// Whether the given category/item pair was selected last time.
46    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
53/// Returns the settings directory (`~/.config/cleansys/` or equivalent).
54pub 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
59/// Full path to the GUI JSON settings file.
60pub fn settings_json_path() -> Result<PathBuf> {
61    Ok(settings_dir()?.join("settings.json"))
62}
63
64/// Full path to the TUI-specific JSON settings file.
65pub fn tui_settings_json_path() -> Result<PathBuf> {
66    Ok(settings_dir()?.join("tui-settings.json"))
67}
68
69/// Load settings from any JSON path.
70fn 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
85/// Save settings to any JSON path (atomic write via `NamedTempFile`).
86fn 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
103/// Load GUI application settings (`settings.json`).
104///
105/// Returns [`Settings::default`] when the file does not exist yet (first
106/// run) or is malformed (the file is preserved for manual recovery).
107pub fn load_settings() -> Result<Settings> {
108    load_from(&settings_json_path()?)
109}
110
111/// Persist GUI application settings.
112pub fn save_settings(settings: &Settings) -> Result<()> {
113    save_to(&settings_json_path()?, settings)
114}
115
116/// Load TUI-specific settings (`tui-settings.json`).
117pub fn load_tui_settings() -> Result<Settings> {
118    load_from(&tui_settings_json_path()?)
119}
120
121/// Persist TUI-specific settings.
122pub 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}