Skip to main content

cleansys_core/
model.rs

1//! Framework-agnostic domain model shared by the TUI and GUI front-ends.
2//!
3//! Nothing in this module depends on `ratatui`, `crossterm`, or `iced` — it is
4//! pure application state that both front-ends render in their own way.
5
6use crate::cleaners::cleaned_item::{CleanerFn, CleaningResult};
7use crate::cleaners::{system_cleaners, user_cleaners};
8
9/// The outcome of running (or attempting to run) a single cleaner.
10#[derive(Debug, Clone)]
11pub enum Status {
12    /// The cleaner is queued but has not started yet.
13    Pending,
14    /// The cleaner is currently executing.
15    Running,
16    /// The cleaner finished successfully; the string is a human-readable summary.
17    Success(String),
18    /// The cleaner failed; the string is a human-readable error message.
19    Error(String),
20}
21
22impl Status {
23    /// Return a single-glyph representation of this status, using `frame` to
24    /// select an animation frame for the `Running` state (spinner).
25    pub fn get_animation_frame(&self, frame: usize) -> &'static str {
26        match self {
27            Status::Running => {
28                const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
29                SPINNER[frame % SPINNER.len()]
30            }
31            Status::Success(_) => "✓",
32            Status::Error(_) => "✗",
33            Status::Pending => "•",
34        }
35    }
36}
37
38/// A single selectable cleaning operation (e.g. "Browser Caches").
39pub struct CleanerItem {
40    /// Human-readable name of the cleaner.
41    pub name: String,
42    /// Short description of what the cleaner removes.
43    pub description: String,
44    /// Whether this cleaner needs root/administrator privileges to run.
45    pub requires_root: bool,
46    /// Whether the user has selected this cleaner to run.
47    pub selected: bool,
48    /// The function that performs the actual cleaning.
49    /// Takes `skip_confirmation: bool` (when `true`, files are removed
50    /// without an interactive y/n prompt — always `true` from the TUI/GUI,
51    /// which have no stdin prompt loop) and returns the structured set of
52    /// items actually removed, with real per-item sizes.
53    pub function: CleanerFn,
54    /// Bytes freed by the most recent run of this cleaner.
55    pub bytes_cleaned: u64,
56    /// Structured detail (per-file/per-directory paths and sizes) from the
57    /// most recent run of this cleaner, if any.
58    pub last_result: Option<CleaningResult>,
59    /// Current run status, if the cleaner has been queued/run at least once.
60    pub status: Option<Status>,
61}
62
63/// A named group of related [`CleanerItem`]s (e.g. "User Land Cleaners").
64pub struct CleanerCategory {
65    /// Category display name.
66    pub name: String,
67    /// Category description.
68    pub description: String,
69    /// The cleaners that belong to this category.
70    pub items: Vec<CleanerItem>,
71}
72
73/// Build the default set of categories (User + System) with all known
74/// cleaners loaded from [`user_cleaners`] and [`system_cleaners`].
75///
76/// This is shared between the TUI and GUI front-ends so both present the
77/// exact same list of cleaners.
78pub fn load_categories() -> Vec<CleanerCategory> {
79    let mut user_items = Vec::new();
80    for cleaner in user_cleaners::get_cleaners() {
81        user_items.push(CleanerItem {
82            name: cleaner.name.to_string(),
83            description: cleaner.description.to_string(),
84            requires_root: false,
85            selected: false,
86            function: cleaner.function,
87            bytes_cleaned: 0,
88            last_result: None,
89            status: None,
90        });
91    }
92
93    let mut system_items = Vec::new();
94    for cleaner in system_cleaners::get_cleaners() {
95        system_items.push(CleanerItem {
96            name: cleaner.name.to_string(),
97            description: cleaner.description.to_string(),
98            requires_root: cleaner.requires_root,
99            selected: false,
100            function: cleaner.function,
101            bytes_cleaned: 0,
102            last_result: None,
103            status: None,
104        });
105    }
106
107    vec![
108        CleanerCategory {
109            name: "User Land Cleaners".to_string(),
110            description: "Clean user-specific files and caches".to_string(),
111            items: user_items,
112        },
113        CleanerCategory {
114            name: "System Cleaners".to_string(),
115            description: "Clean system files and caches (requires root)".to_string(),
116            items: system_items,
117        },
118    ]
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn load_categories_has_user_and_system() {
127        let categories = load_categories();
128        assert_eq!(categories.len(), 2);
129        assert_eq!(categories[0].name, "User Land Cleaners");
130        assert_eq!(categories[1].name, "System Cleaners");
131        assert!(!categories[0].items.is_empty());
132        assert!(!categories[1].items.is_empty());
133        // System cleaners each declare their own root requirement (e.g.
134        // Homebrew on macOS must not run as root), so not every item in the
135        // System category necessarily requires root — but user cleaners never do.
136        assert!(categories[0].items.iter().all(|i| !i.requires_root));
137    }
138
139    #[test]
140    fn status_animation_frames() {
141        assert_eq!(Status::Pending.get_animation_frame(0), "•");
142        assert_eq!(Status::Success("ok".into()).get_animation_frame(0), "✓");
143        assert_eq!(Status::Error("bad".into()).get_animation_frame(0), "✗");
144        let running = Status::Running;
145        assert_eq!(running.get_animation_frame(0), "⠋");
146        assert_eq!(running.get_animation_frame(1), "⠙");
147    }
148}