1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4
5use crate::warnings::{missing_warning_gates, normalize_warning_gate};
6
7pub use crate::path_template::PathTemplate;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "kebab-case")]
11pub enum Platform {
12 Windows,
13 Linux,
14 Macos,
15 Unknown,
16}
17
18impl Platform {
19 pub const fn label(self) -> &'static str {
20 match self {
21 Self::Windows => "windows",
22 Self::Linux => "linux",
23 Self::Macos => "macos",
24 Self::Unknown => "unknown",
25 }
26 }
27
28 pub const fn is_windows(self) -> bool {
29 matches!(self, Self::Windows)
30 }
31
32 pub fn current() -> Self {
33 if cfg!(windows) {
34 Self::Windows
35 } else if cfg!(target_os = "linux") {
36 Self::Linux
37 } else if cfg!(target_os = "macos") {
38 Self::Macos
39 } else {
40 Self::Unknown
41 }
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "kebab-case")]
47pub enum SafetyLevel {
48 Safe,
49 Moderate,
50 Risky,
51 Dangerous,
52}
53
54impl SafetyLevel {
55 pub fn label(self) -> &'static str {
56 match self {
57 Self::Safe => "safe",
58 Self::Moderate => "moderate",
59 Self::Risky => "risky",
60 Self::Dangerous => "dangerous",
61 }
62 }
63
64 pub fn opt_in_flag(self) -> Option<&'static str> {
65 match self {
66 Self::Safe => None,
67 Self::Moderate => Some("--allow-moderate"),
68 Self::Risky | Self::Dangerous => Some("--allow-risky"),
69 }
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "kebab-case")]
75pub enum DeleteMode {
76 DryRun,
77 RecoverableDelete,
78}
79
80impl DeleteMode {
81 pub fn is_dry_run(self) -> bool {
82 matches!(self, Self::DryRun)
83 }
84}
85
86pub const DEFAULT_PROJECT_ARTIFACT_MAX_DEPTH: usize = 6;
87pub const DEFAULT_PROJECT_ARTIFACT_MIN_AGE_DAYS: u64 = 7;
88
89#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "kebab-case")]
91pub enum CleanupWorkflow {
92 #[default]
93 Rules,
94 AppLeftovers,
95 ProjectArtifacts,
96}
97
98impl CleanupWorkflow {
99 pub fn label(self) -> &'static str {
100 match self {
101 Self::Rules => "cleanup",
102 Self::AppLeftovers => "app leftovers",
103 Self::ProjectArtifacts => "project artifacts",
104 }
105 }
106
107 pub fn title(self) -> &'static str {
108 match self {
109 Self::Rules => "Cleanup",
110 Self::AppLeftovers => "App leftovers",
111 Self::ProjectArtifacts => "Project artifacts",
112 }
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(tag = "kind", content = "value", rename_all = "kebab-case")]
118pub enum RuleTargetSpec {
119 Template(PathTemplate),
120 ExactPath(PathBuf),
121 GlobTemplate(PathTemplate),
122 SteamInstallTemplate(PathTemplate),
123 SteamLibraryTemplate(PathTemplate),
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "kebab-case")]
128pub enum RuleSearchKind {
129 File,
130 Glob,
131 SteamInstall,
132 SteamLibrary,
133}
134
135impl RuleSearchKind {
136 pub fn label(self) -> &'static str {
137 match self {
138 Self::File => "file",
139 Self::Glob => "glob",
140 Self::SteamInstall => "steam-install",
141 Self::SteamLibrary => "steam-library",
142 }
143 }
144}
145
146impl RuleTargetSpec {
147 pub fn template(template: impl Into<String>) -> Self {
148 Self::Template(PathTemplate::new(template))
149 }
150
151 pub fn glob_template(template: impl Into<String>) -> Self {
152 Self::GlobTemplate(PathTemplate::new(template))
153 }
154
155 pub fn steam_install_template(template: impl Into<String>) -> Self {
156 Self::SteamInstallTemplate(PathTemplate::new(template))
157 }
158
159 pub fn steam_library_template(template: impl Into<String>) -> Self {
160 Self::SteamLibraryTemplate(PathTemplate::new(template))
161 }
162
163 pub fn placeholder_path(&self) -> PathBuf {
164 match self {
165 Self::Template(template)
166 | Self::GlobTemplate(template)
167 | Self::SteamInstallTemplate(template)
168 | Self::SteamLibraryTemplate(template) => PathBuf::from(template.raw()),
169 Self::ExactPath(path) => path.clone(),
170 }
171 }
172
173 pub fn search_kind(&self) -> RuleSearchKind {
174 match self {
175 Self::Template(_) | Self::ExactPath(_) => RuleSearchKind::File,
176 Self::GlobTemplate(_) => RuleSearchKind::Glob,
177 Self::SteamInstallTemplate(_) => RuleSearchKind::SteamInstall,
178 Self::SteamLibraryTemplate(_) => RuleSearchKind::SteamLibrary,
179 }
180 }
181
182 pub fn dedupe_key(&self, platform: Platform) -> String {
183 let target = match self {
184 Self::Template(template) => format!("template:{}", template.raw()),
185 Self::ExactPath(path) => format!("exact-path:{}", path.display()),
186 Self::GlobTemplate(template) => format!("glob-template:{}", template.raw()),
187 Self::SteamInstallTemplate(template) => {
188 format!("steam-install-template:{}", template.raw())
189 }
190 Self::SteamLibraryTemplate(template) => {
191 format!("steam-library-template:{}", template.raw())
192 }
193 }
194 .replace('\\', "/");
195
196 format!("{}:{}", platform.label(), target.to_ascii_lowercase())
197 }
198}
199
200#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
201pub struct RuleSelection {
202 pub categories: Vec<String>,
203 pub rule_ids: Vec<String>,
204}
205
206impl RuleSelection {
207 pub fn new(categories: Vec<String>, rule_ids: Vec<String>) -> Self {
208 Self {
209 categories,
210 rule_ids,
211 }
212 }
213
214 pub fn rule_ids(&self) -> &[String] {
215 &self.rule_ids
216 }
217
218 pub fn from_request(request: &PlanRequest) -> Self {
219 Self::new(
220 request.selected_categories.clone(),
221 request.selected_rule_ids.clone(),
222 )
223 }
224
225 pub fn matches_rule(&self, rule: &RuleDefinition) -> bool {
226 let selected_category = self.matches_any(&self.categories, &rule.category);
227 let selected_id = self.matches_any(&self.rule_ids, &rule.id);
228
229 selected_category && selected_id
230 }
231
232 pub fn validate_against_rules(
233 &self,
234 rules: &[RuleDefinition],
235 ) -> Result<(), crate::RebeccaError> {
236 for selected in &self.categories {
237 let known = rules
238 .iter()
239 .any(|rule| rule.category.eq_ignore_ascii_case(selected));
240 if !known {
241 return Err(crate::RebeccaError::InvalidCategory(selected.clone()));
242 }
243 }
244
245 for selected in self.rule_ids() {
246 let known = rules
247 .iter()
248 .any(|rule| rule.id.eq_ignore_ascii_case(selected));
249 if !known {
250 return Err(crate::RebeccaError::InvalidRuleId(selected.clone()));
251 }
252 }
253
254 Ok(())
255 }
256
257 fn matches_any(&self, selected: &[String], value: &str) -> bool {
258 selected.is_empty() || selected.iter().any(|item| item.eq_ignore_ascii_case(value))
259 }
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263pub struct RuleDefinition {
264 pub id: String,
265 pub platform: Platform,
266 pub category: String,
267 pub name: String,
268 pub safety_level: SafetyLevel,
269 pub path_templates: Vec<RuleTargetSpec>,
270 pub restore_hint: Option<String>,
271 #[serde(default, skip_serializing_if = "Vec::is_empty")]
272 pub warnings: Vec<String>,
273 pub provenance: RuleProvenance,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct RuleProvenance {
278 pub source: RuleSource,
279 pub license: String,
280 pub notes: String,
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(rename_all = "kebab-case")]
285pub enum RuleSource {
286 Owned,
287 ReferenceOnly,
288}
289
290impl RuleSource {
291 pub fn label(self) -> &'static str {
292 match self {
293 Self::Owned => "owned",
294 Self::ReferenceOnly => "reference-only",
295 }
296 }
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct PlanRequest {
301 pub platform: Platform,
302 pub mode: DeleteMode,
303 #[serde(default)]
304 pub workflow: CleanupWorkflow,
305 #[serde(default, skip_serializing_if = "Vec::is_empty")]
306 pub project_artifact_roots: Vec<PathBuf>,
307 #[serde(
308 default = "default_project_artifact_max_depth",
309 skip_serializing_if = "is_default_project_artifact_max_depth"
310 )]
311 pub project_artifact_max_depth: usize,
312 #[serde(
313 default = "default_project_artifact_min_age_days",
314 skip_serializing_if = "is_default_project_artifact_min_age_days"
315 )]
316 pub project_artifact_min_age_days: u64,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub project_artifact_reclaim_limit_bytes: Option<u64>,
319 #[serde(default, skip_serializing_if = "Vec::is_empty")]
320 pub project_artifact_selectors: Vec<String>,
321 pub selected_categories: Vec<String>,
322 pub selected_rule_ids: Vec<String>,
323 #[serde(default, skip_serializing_if = "Vec::is_empty")]
324 pub allowed_warnings: Vec<String>,
325 pub allow_moderate: bool,
326 pub allow_risky: bool,
327}
328
329impl PlanRequest {
330 pub fn for_platform(platform: Platform, mode: DeleteMode) -> Self {
331 Self {
332 platform,
333 mode,
334 workflow: CleanupWorkflow::Rules,
335 project_artifact_roots: Vec::new(),
336 project_artifact_max_depth: DEFAULT_PROJECT_ARTIFACT_MAX_DEPTH,
337 project_artifact_min_age_days: DEFAULT_PROJECT_ARTIFACT_MIN_AGE_DAYS,
338 project_artifact_reclaim_limit_bytes: None,
339 project_artifact_selectors: Vec::new(),
340 selected_categories: Vec::new(),
341 selected_rule_ids: Vec::new(),
342 allowed_warnings: Vec::new(),
343 allow_moderate: false,
344 allow_risky: false,
345 }
346 }
347
348 pub fn selection(&self) -> RuleSelection {
349 RuleSelection::from_request(self)
350 }
351
352 pub fn with_workflow(mut self, workflow: CleanupWorkflow) -> Self {
353 self.workflow = workflow;
354 self
355 }
356
357 pub fn allows_safety_level(&self, level: SafetyLevel) -> bool {
358 match level {
359 SafetyLevel::Safe => true,
360 SafetyLevel::Moderate => self.allow_moderate || self.allow_risky,
361 SafetyLevel::Risky | SafetyLevel::Dangerous => self.allow_risky,
362 }
363 }
364
365 pub fn allows_warnings(&self, warnings: &[String]) -> bool {
366 self.missing_warning_gates(warnings).is_empty()
367 }
368
369 pub fn missing_warning_gates(&self, warnings: &[String]) -> Vec<String> {
370 missing_warning_gates(warnings, &self.allowed_warnings)
371 }
372
373 pub fn add_allowed_warning(&mut self, warning: impl AsRef<str>) {
374 let warning = normalize_warning_gate(warning.as_ref());
375 if warning.is_empty()
376 || self
377 .allowed_warnings
378 .iter()
379 .any(|existing| existing.eq_ignore_ascii_case(&warning))
380 {
381 return;
382 }
383
384 self.allowed_warnings.push(warning);
385 }
386}
387
388fn default_project_artifact_max_depth() -> usize {
389 DEFAULT_PROJECT_ARTIFACT_MAX_DEPTH
390}
391
392fn is_default_project_artifact_max_depth(value: &usize) -> bool {
393 *value == DEFAULT_PROJECT_ARTIFACT_MAX_DEPTH
394}
395
396fn default_project_artifact_min_age_days() -> u64 {
397 DEFAULT_PROJECT_ARTIFACT_MIN_AGE_DAYS
398}
399
400fn is_default_project_artifact_min_age_days(value: &u64) -> bool {
401 *value == DEFAULT_PROJECT_ARTIFACT_MIN_AGE_DAYS
402}
403
404#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
405#[serde(rename_all = "kebab-case")]
406pub enum TargetStatus {
407 Allowed,
408 Skipped,
409 Blocked,
410 Failed,
411 Completed,
412}
413
414impl TargetStatus {
415 pub fn is_executable(self) -> bool {
416 matches!(self, Self::Allowed)
417 }
418
419 pub fn is_issue(self) -> bool {
420 matches!(self, Self::Skipped | Self::Blocked | Self::Failed)
421 }
422
423 pub fn label(self) -> &'static str {
424 match self {
425 Self::Allowed => "allowed",
426 Self::Skipped => "skipped",
427 Self::Blocked => "blocked",
428 Self::Failed => "failed",
429 Self::Completed => "completed",
430 }
431 }
432}