diskforge_core/rules/
user_rules.rs1use std::path::PathBuf;
2
3use serde::Deserialize;
4
5use crate::sizing;
6use crate::types::{Category, CleanableItem, Risk};
7
8#[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
32pub 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
55pub 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); }
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 assert_eq!(rules[1].name, "Another");
187 assert_eq!(rules[1].risk, "low"); assert!(!rules[1].regenerates); 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}