Skip to main content

rebecca_core/
model.rs

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}