Skip to main content

cleansys_gui/
state.rs

1//! Application state for the CleanSys Iced GUI.
2
3use cleansys_core::{CleanerCategory, Status};
4
5use crate::theme::ThemeColors;
6
7/// Top-level state for the CleanSys GUI application.
8pub struct CleanSysGui {
9    /// Cleaner categories and items (shared domain model from `cleansys-core`).
10    pub categories: Vec<CleanerCategory>,
11    /// Rolling log of operation messages shown in the activity panel.
12    pub logs: Vec<String>,
13    /// Total bytes freed across all completed operations in the current run.
14    pub total_bytes_cleaned: u64,
15    /// Whether cleaners are currently running.
16    pub is_running: bool,
17    /// Whether the process is running with root/administrator privileges.
18    pub is_root: bool,
19    /// Whether the sudo authentication dialog is visible.
20    pub needs_password: bool,
21    /// The password currently typed into the authentication dialog.
22    pub password_input: String,
23    /// Error message shown in the authentication dialog, if any.
24    pub password_error: Option<String>,
25    /// Operations queued to run once sudo authentication succeeds.
26    pub pending_root_ops: Vec<(usize, usize)>,
27    /// Index of the currently active category tab.
28    pub active_tab: usize,
29    /// Index of the currently selected UI theme (see `cleansys_core::THEME_NAMES`).
30    pub theme_index: usize,
31    /// Whether the "confirm this run" dialog is visible.
32    pub confirm_run_pending: bool,
33    /// Whether a preview (dry-run) is currently being computed.
34    pub previewing: bool,
35    /// Whether the preview results dialog is visible.
36    pub preview_open: bool,
37    /// Results of the most recent preview run: `(cleaner_name, result)`.
38    pub preview_results: Vec<(String, cleansys_core::CleaningResult)>,
39    /// Total number of operations in the current run (for the progress bar).
40    pub operations_total: usize,
41    /// Number of operations completed so far in the current run.
42    pub operations_completed: usize,
43    /// Whether the "needs Administrator" notice is visible (Windows only;
44    /// Windows has no interactive sudo-password flow, so this replaces the
45    /// password dialog when elevation is required there).
46    pub needs_admin_notice: bool,
47}
48
49impl Default for CleanSysGui {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55impl CleanSysGui {
56    /// Construct a fresh application state with all known cleaners loaded.
57    pub fn new() -> Self {
58        // Unit tests must never read the real ~/.config/cleansys/settings.json
59        // — doing so made tests flaky/order-dependent, since every test in
60        // this crate's test binary shares the same real file on disk (writes
61        // from one test's `save_selections()`/`save_theme()` would leak into
62        // whichever test happened to construct `CleanSysGui::new()` next).
63        let settings = if cfg!(test) {
64            cleansys_core::Settings::default()
65        } else {
66            cleansys_core::load_settings().unwrap_or_default()
67        };
68        let mut categories = cleansys_core::load_categories();
69        for category in &mut categories {
70            for item in &mut category.items {
71                item.selected = settings.is_selected(&category.name, &item.name);
72            }
73        }
74        Self {
75            categories,
76            logs: Vec::new(),
77            total_bytes_cleaned: 0,
78            is_running: false,
79            is_root: cleansys_core::check_root(),
80            needs_password: false,
81            password_input: String::new(),
82            password_error: None,
83            pending_root_ops: Vec::new(),
84            active_tab: 0,
85            theme_index: settings.theme_index(),
86            confirm_run_pending: false,
87            previewing: false,
88            preview_open: false,
89            preview_results: Vec::new(),
90            operations_total: 0,
91            operations_completed: 0,
92            needs_admin_notice: false,
93        }
94    }
95
96    /// Number of currently selected items across all categories.
97    pub fn selected_count(&self) -> usize {
98        self.categories
99            .iter()
100            .flat_map(|c| &c.items)
101            .filter(|i| i.selected)
102            .count()
103    }
104
105    /// Number of currently selected items within a specific category.
106    pub fn selected_count_in(&self, cat_idx: usize) -> usize {
107        self.categories
108            .get(cat_idx)
109            .map(|c| c.items.iter().filter(|i| i.selected).count())
110            .unwrap_or(0)
111    }
112
113    /// True if any selected item requires root and we don't already have it.
114    pub fn selection_needs_root(&self) -> bool {
115        !self.is_root
116            && self
117                .categories
118                .iter()
119                .flat_map(|c| &c.items)
120                .any(|i| i.selected && i.requires_root)
121    }
122
123    /// `(category_index, item_index)` pairs of every currently-selected item.
124    pub fn selected_indices(&self) -> Vec<(usize, usize)> {
125        self.categories
126            .iter()
127            .enumerate()
128            .flat_map(|(ci, c)| {
129                c.items
130                    .iter()
131                    .enumerate()
132                    .filter(|(_, i)| i.selected)
133                    .map(move |(ii, _)| (ci, ii))
134            })
135            .collect()
136    }
137
138    /// Fraction of the current run's operations completed so far, in
139    /// `0.0..=1.0`. `0.0` when no run is in progress.
140    pub fn progress_fraction(&self) -> f32 {
141        if self.operations_total == 0 {
142            0.0
143        } else {
144            (self.operations_completed as f32 / self.operations_total as f32).clamp(0.0, 1.0)
145        }
146    }
147
148    /// Whether this platform supports the interactive sudo-password
149    /// elevation flow (Unix). Windows uses UAC/Administrator tokens instead.
150    pub fn supports_sudo_prompt(&self) -> bool {
151        cleansys_core::utils::supports_sudo_prompt()
152    }
153
154    /// Push a line to the activity log, keeping only the most recent entries.
155    pub fn push_log(&mut self, line: impl Into<String>) {
156        self.logs.push(line.into());
157        if self.logs.len() > 500 {
158            self.logs.remove(0);
159        }
160    }
161
162    /// Mark every selected item as `Status::Pending` in preparation for a run.
163    pub fn mark_selected_pending(&mut self) {
164        for category in &mut self.categories {
165            for item in &mut category.items {
166                if item.selected {
167                    item.status = Some(Status::Pending);
168                }
169            }
170        }
171    }
172
173    /// Derive the full [`ThemeColors`] from the currently active core theme.
174    ///
175    /// Call this at the top of view functions: `let c = state.colors();`
176    pub fn colors(&self) -> ThemeColors {
177        ThemeColors::from_core(&cleansys_core::theme_by_index(self.theme_index))
178    }
179
180    /// Return a custom `iced::Theme` derived from the active core theme, for
181    /// the top-level `iced::application(...).theme(...)` callback.
182    pub fn iced_theme(&self) -> iced::Theme {
183        crate::theme::iced_theme_for(self.theme_index)
184    }
185
186    /// The display name of the currently active theme.
187    pub fn current_theme_name(&self) -> &'static str {
188        cleansys_core::THEME_NAMES
189            .get(self.theme_index)
190            .copied()
191            .unwrap_or("Default")
192    }
193
194    /// Build the full [`cleansys_core::Settings`] snapshot for the current
195    /// state (theme + selected cleaners), for persistence.
196    pub fn current_settings(&self) -> cleansys_core::Settings {
197        let selected_cleaners = self
198            .categories
199            .iter()
200            .flat_map(|c| {
201                let cat_name = c.name.clone();
202                c.items
203                    .iter()
204                    .filter(|i| i.selected)
205                    .map(move |i| cleansys_core::Settings::selection_key(&cat_name, &i.name))
206            })
207            .collect();
208
209        cleansys_core::Settings {
210            theme_name: Some(self.current_theme_name().to_string()),
211            selected_cleaners,
212        }
213    }
214
215    /// Persist the current theme and cleaner selections to `settings.json`
216    /// (best-effort; failures are logged but never surfaced to the UI).
217    ///
218    /// A no-op under `cfg(test)` so unit tests never touch the real
219    /// `~/.config/cleansys/settings.json` (see the comment in [`Self::new`]).
220    pub fn save_theme(&self) {
221        if cfg!(test) {
222            return;
223        }
224        if let Err(e) = cleansys_core::save_settings(&self.current_settings()) {
225            log::warn!("failed to save theme preference: {e}");
226        }
227    }
228
229    /// Persist the current cleaner selections (and theme) to `settings.json`.
230    /// A no-op under `cfg(test)` — see [`Self::save_theme`].
231    pub fn save_selections(&self) {
232        if cfg!(test) {
233            return;
234        }
235        if let Err(e) = cleansys_core::save_settings(&self.current_settings()) {
236            log::warn!("failed to save cleaner selections: {e}");
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn new_loads_categories_and_defaults() {
247        let state = CleanSysGui::new();
248        assert_eq!(state.categories.len(), 2);
249        assert_eq!(state.active_tab, 0);
250        assert_eq!(state.selected_count(), 0);
251        assert!(state.logs.is_empty());
252        assert_eq!(state.total_bytes_cleaned, 0);
253        assert!(!state.is_running);
254        assert!(!state.needs_password);
255    }
256
257    #[test]
258    fn selected_count_tracks_selections() {
259        let mut state = CleanSysGui::new();
260        assert_eq!(state.selected_count(), 0);
261        state.categories[0].items[0].selected = true;
262        assert_eq!(state.selected_count(), 1);
263        assert_eq!(state.selected_count_in(0), 1);
264        assert_eq!(state.selected_count_in(1), 0);
265        state.categories[1].items[0].selected = true;
266        assert_eq!(state.selected_count(), 2);
267    }
268
269    #[test]
270    fn selected_count_in_out_of_range_is_zero() {
271        let state = CleanSysGui::new();
272        assert_eq!(state.selected_count_in(99), 0);
273    }
274
275    /// Find `(cat_idx, item_idx)` of a system item that actually requires
276    /// root on this platform (not every "System Cleaners" entry does —
277    /// e.g. Homebrew on macOS must not run as root).
278    fn first_root_required_item(state: &CleanSysGui) -> (usize, usize) {
279        for (ci, category) in state.categories.iter().enumerate() {
280            for (ii, item) in category.items.iter().enumerate() {
281                if item.requires_root {
282                    return (ci, ii);
283                }
284            }
285        }
286        panic!("expected at least one root-requiring cleaner on this platform");
287    }
288
289    #[test]
290    fn selection_needs_root_when_not_root_and_system_item_selected() {
291        let mut state = CleanSysGui::new();
292        state.is_root = false;
293        assert!(!state.selection_needs_root());
294
295        let (ci, ii) = first_root_required_item(&state);
296        state.categories[ci].items[ii].selected = true;
297        assert!(state.selection_needs_root());
298    }
299
300    #[test]
301    fn selection_needs_root_false_when_already_root() {
302        let mut state = CleanSysGui::new();
303        state.is_root = true;
304        let (ci, ii) = first_root_required_item(&state);
305        state.categories[ci].items[ii].selected = true;
306        assert!(!state.selection_needs_root());
307    }
308
309    #[test]
310    fn selection_needs_root_false_for_user_only_selection() {
311        let mut state = CleanSysGui::new();
312        state.is_root = false;
313        state.categories[0].items[0].selected = true;
314        assert!(!state.selection_needs_root());
315    }
316
317    #[test]
318    fn push_log_caps_at_500_entries() {
319        let mut state = CleanSysGui::new();
320        for i in 0..600 {
321            state.push_log(format!("line {i}"));
322        }
323        assert_eq!(state.logs.len(), 500);
324        // Oldest entries should have been evicted; the log should end with the
325        // most recent line.
326        assert_eq!(state.logs.last().unwrap(), "line 599");
327    }
328
329    #[test]
330    fn mark_selected_pending_only_affects_selected_items() {
331        let mut state = CleanSysGui::new();
332        state.categories[0].items[0].selected = true;
333        state.mark_selected_pending();
334
335        assert!(matches!(
336            state.categories[0].items[0].status,
337            Some(Status::Pending)
338        ));
339        assert!(state.categories[0].items[1].status.is_none());
340    }
341
342    #[test]
343    fn theme_index_defaults_in_range() {
344        let state = CleanSysGui::new();
345        assert!(state.theme_index < cleansys_core::THEME_COUNT);
346    }
347
348    #[test]
349    fn current_theme_name_matches_index() {
350        let mut state = CleanSysGui::new();
351        state.theme_index = cleansys_core::theme_index_by_name("Dracula");
352        assert_eq!(state.current_theme_name(), "Dracula");
353    }
354
355    #[test]
356    fn current_theme_name_falls_back_for_out_of_range_index() {
357        let mut state = CleanSysGui::new();
358        state.theme_index = 9999;
359        assert_eq!(state.current_theme_name(), "Default");
360    }
361
362    #[test]
363    fn colors_does_not_panic_for_any_theme() {
364        let mut state = CleanSysGui::new();
365        for i in 0..cleansys_core::THEME_COUNT {
366            state.theme_index = i;
367            let _ = state.colors();
368            let _ = state.iced_theme();
369        }
370    }
371
372    #[test]
373    fn selected_indices_returns_selected_pairs() {
374        let mut state = CleanSysGui::new();
375        state.categories[0].items[0].selected = true;
376        state.categories[0].items[2].selected = true;
377        let indices = state.selected_indices();
378        assert_eq!(indices, vec![(0, 0), (0, 2)]);
379    }
380
381    #[test]
382    fn progress_fraction_is_zero_with_no_operations() {
383        let state = CleanSysGui::new();
384        assert_eq!(state.progress_fraction(), 0.0);
385    }
386
387    #[test]
388    fn progress_fraction_computes_ratio() {
389        let mut state = CleanSysGui::new();
390        state.operations_total = 4;
391        state.operations_completed = 1;
392        assert_eq!(state.progress_fraction(), 0.25);
393        state.operations_completed = 4;
394        assert_eq!(state.progress_fraction(), 1.0);
395    }
396
397    #[test]
398    fn current_settings_includes_selected_cleaners() {
399        let mut state = CleanSysGui::new();
400        state.categories[0].items[0].selected = true;
401        let name = state.categories[0].items[0].name.clone();
402        let cat_name = state.categories[0].name.clone();
403        let settings = state.current_settings();
404        assert!(settings.is_selected(&cat_name, &name));
405    }
406
407    #[test]
408    fn new_restores_selection_from_settings() {
409        // Can't easily isolate the real config dir in a unit test, but we can
410        // at least confirm current_settings() round-trips through is_selected
411        // the same way new() consults it.
412        let mut state = CleanSysGui::new();
413        state.categories[0].items[1].selected = true;
414        let settings = state.current_settings();
415        let cat_name = state.categories[0].name.clone();
416        let item_name = state.categories[0].items[1].name.clone();
417        assert!(settings.is_selected(&cat_name, &item_name));
418        assert!(!settings.is_selected(&cat_name, "Definitely Not A Real Cleaner"));
419    }
420}