Skip to main content

diskforge_core/rules/
user_rules.rs

1use std::path::PathBuf;
2
3use serde::Deserialize;
4
5use crate::sizing;
6use crate::types::{Category, CleanableItem, Risk};
7
8/// A single user-defined rule from `~/.config/diskforge/rules.toml`
9#[derive(Debug, Deserialize)]
10pub struct UserRule {
11    pub name: String,
12    pub path: String,
13    #[serde(default)]
14    pub category: String,
15    #[serde(default = "default_risk")]
16    pub risk: String,
17    #[serde(default)]
18    pub regenerates: bool,
19    pub hint: Option<String>,
20}
21
22fn default_risk() -> String {
23    "low".into()
24}
25
26#[derive(Debug, Deserialize)]
27struct RulesFile {
28    #[serde(default)]
29    rules: Vec<UserRule>,
30}
31
32/// Load user rules from `~/.config/diskforge/rules.toml`.
33/// Returns an empty vec if the file doesn't exist or can't be parsed.
34pub fn load_user_rules(home: &str) -> Vec<UserRule> {
35    let path = PathBuf::from(format!("{home}/.config/diskforge/rules.toml"));
36    if !path.exists() {
37        return Vec::new();
38    }
39    let content = match std::fs::read_to_string(&path) {
40        Ok(c) => c,
41        Err(e) => {
42            eprintln!("diskforge: warning: could not read rules.toml: {e}");
43            return Vec::new();
44        }
45    };
46    match toml::from_str::<RulesFile>(&content) {
47        Ok(f) => f.rules,
48        Err(e) => {
49            eprintln!("diskforge: warning: could not parse rules.toml: {e}");
50            Vec::new()
51        }
52    }
53}
54
55/// Convert loaded user rules into `CleanableItem`s (skipping paths that don't exist).
56pub fn scan_user_rules(home: &str) -> Vec<CleanableItem> {
57    load_user_rules(home)
58        .into_iter()
59        .filter_map(|rule| {
60            let expanded = rule.path.replace('~', home);
61            let path = PathBuf::from(&expanded);
62            if !path.exists() {
63                return None;
64            }
65            let stats = sizing::dir_stats(&path);
66            if stats.size == 0 {
67                return None;
68            }
69            let risk = parse_risk(&rule.risk);
70            let category = parse_category(&rule.category, &rule.name);
71            Some(CleanableItem {
72                category,
73                path,
74                size: stats.size,
75                risk,
76                regenerates: rule.regenerates,
77                regeneration_hint: rule.hint,
78                last_modified: stats.last_modified,
79                description: format!("{} (user rule)", rule.name),
80                cleanup_command: None,
81            })
82        })
83        .collect()
84}
85
86fn parse_risk(s: &str) -> Risk {
87    match s.to_lowercase().as_str() {
88        "none" => Risk::None,
89        "low" => Risk::Low,
90        "medium" => Risk::Medium,
91        "high" => Risk::High,
92        _ => Risk::Low,
93    }
94}
95
96fn parse_category(category: &str, name: &str) -> Category {
97    match category.to_lowercase().as_str() {
98        "system" | "system cache" => Category::SystemCache,
99        "docker" => Category::Docker,
100        "simulator" | "ios" => Category::Simulator,
101        "android" => Category::Android,
102        "ide" => Category::IdeCache(name.into()),
103        "browser" => Category::BrowserCache(name.into()),
104        "pkg" | "package" | "packagemanager" => Category::PackageManager(name.into()),
105        _ => Category::AppCache(name.into()),
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn risk_parsing() {
115        assert_eq!(parse_risk("none"), Risk::None);
116        assert_eq!(parse_risk("None"), Risk::None);
117        assert_eq!(parse_risk("NONE"), Risk::None);
118        assert_eq!(parse_risk("low"), Risk::Low);
119        assert_eq!(parse_risk("medium"), Risk::Medium);
120        assert_eq!(parse_risk("high"), Risk::High);
121        assert_eq!(parse_risk("banana"), Risk::Low); // default
122    }
123
124    #[test]
125    fn category_parsing() {
126        assert_eq!(
127            parse_category("ide", "VS Code"),
128            Category::IdeCache("VS Code".into())
129        );
130        assert_eq!(
131            parse_category("IDE", "Cursor"),
132            Category::IdeCache("Cursor".into())
133        );
134        assert_eq!(parse_category("docker", "Docker"), Category::Docker);
135        assert_eq!(parse_category("simulator", "Sim"), Category::Simulator);
136        assert_eq!(parse_category("ios", "Sim"), Category::Simulator);
137        assert_eq!(parse_category("android", "NDK"), Category::Android);
138        assert_eq!(
139            parse_category("browser", "Chrome"),
140            Category::BrowserCache("Chrome".into())
141        );
142        assert_eq!(parse_category("system", "Logs"), Category::SystemCache);
143        assert_eq!(
144            parse_category("pkg", "npm"),
145            Category::PackageManager("npm".into())
146        );
147        assert_eq!(
148            parse_category("package", "pip"),
149            Category::PackageManager("pip".into())
150        );
151        assert_eq!(
152            parse_category("whatever", "Foo"),
153            Category::AppCache("Foo".into())
154        );
155    }
156
157    #[test]
158    fn load_valid_toml() {
159        let dir = std::env::temp_dir().join("diskforge_test_rules");
160        let config_dir = dir.join(".config/diskforge");
161        std::fs::create_dir_all(&config_dir).unwrap();
162        std::fs::write(
163            config_dir.join("rules.toml"),
164            r#"
165[[rules]]
166name = "Test Cache"
167path = "~/some/path"
168category = "ide"
169risk = "none"
170regenerates = true
171hint = "rebuild it"
172
173[[rules]]
174name = "Another"
175path = "~/other"
176"#,
177        )
178        .unwrap();
179        let rules = load_user_rules(&dir.to_string_lossy());
180        assert_eq!(rules.len(), 2);
181        assert_eq!(rules[0].name, "Test Cache");
182        assert_eq!(rules[0].risk, "none");
183        assert!(rules[0].regenerates);
184        assert_eq!(rules[0].hint.as_deref(), Some("rebuild it"));
185        // Second rule uses defaults
186        assert_eq!(rules[1].name, "Another");
187        assert_eq!(rules[1].risk, "low"); // default
188        assert!(!rules[1].regenerates); // default false
189        std::fs::remove_dir_all(&dir).ok();
190    }
191
192    #[test]
193    fn load_missing_file() {
194        let rules = load_user_rules("/tmp/diskforge_nonexistent_xyz");
195        assert!(rules.is_empty());
196    }
197
198    #[test]
199    fn load_malformed_toml() {
200        let dir = std::env::temp_dir().join("diskforge_test_bad_toml");
201        let config_dir = dir.join(".config/diskforge");
202        std::fs::create_dir_all(&config_dir).unwrap();
203        std::fs::write(config_dir.join("rules.toml"), "this is not valid toml [[[").unwrap();
204        let rules = load_user_rules(&dir.to_string_lossy());
205        assert!(rules.is_empty());
206        std::fs::remove_dir_all(&dir).ok();
207    }
208}