Skip to main content

rebecca_core/
plan.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::project_artifacts::{ProjectArtifactContextMatch, ProjectArtifactDiscoveryDiagnostic};
7use crate::scan::{
8    MeasuredScan, ScanBackendEvidence, ScanBackendKind, ScanEstimateCaveat, ScanEstimateConfidence,
9};
10use crate::warnings::WarningSummary;
11use crate::{DeleteMode, PlanRequest, TargetStatus};
12
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum CleanupTargetDeletionStyle {
16    #[default]
17    PreserveRootContents,
18    DeleteWholePath,
19}
20
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum EstimateSource {
24    #[default]
25    Unknown,
26    FreshScan,
27    ScanCache,
28    NotMeasured,
29}
30
31impl EstimateSource {
32    pub fn label(self) -> &'static str {
33        match self {
34            Self::Unknown => "unknown",
35            Self::FreshScan => "fresh-scan",
36            Self::ScanCache => "scan-cache",
37            Self::NotMeasured => "not-measured",
38        }
39    }
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43pub struct EstimateProvenance {
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub estimate_backend: Option<ScanBackendKind>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub estimate_backend_source: Option<String>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub estimate_confidence: Option<ScanEstimateConfidence>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub estimate_fallback_reason: Option<String>,
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub estimate_caveats: Vec<ScanEstimateCaveat>,
54    #[serde(default, skip_serializing_if = "ScanBackendEvidence::is_empty")]
55    pub estimate_backend_evidence: ScanBackendEvidence,
56}
57
58impl EstimateProvenance {
59    pub fn from_measured_scan(scan: &MeasuredScan) -> Self {
60        Self {
61            estimate_backend: Some(scan.backend),
62            estimate_backend_source: scan.backend_source.clone(),
63            estimate_confidence: Some(scan.confidence),
64            estimate_fallback_reason: scan.fallback_reason.clone(),
65            estimate_caveats: scan.caveats.clone(),
66            estimate_backend_evidence: scan.backend_evidence.clone(),
67        }
68    }
69
70    pub fn from_backend_confidence(
71        backend: ScanBackendKind,
72        confidence: ScanEstimateConfidence,
73    ) -> Self {
74        Self::from_backend_confidence_and_source(backend, confidence, None)
75    }
76
77    pub fn from_backend_confidence_and_source(
78        backend: ScanBackendKind,
79        confidence: ScanEstimateConfidence,
80        source: Option<String>,
81    ) -> Self {
82        Self {
83            estimate_backend: Some(backend),
84            estimate_backend_source: source,
85            estimate_confidence: Some(confidence),
86            estimate_fallback_reason: None,
87            estimate_caveats: Vec::new(),
88            estimate_backend_evidence: ScanBackendEvidence::default(),
89        }
90    }
91
92    pub fn with_backend_evidence(mut self, evidence: ScanBackendEvidence) -> Self {
93        self.estimate_backend_evidence.merge(evidence);
94        self
95    }
96
97    pub fn is_empty(&self) -> bool {
98        self.estimate_backend.is_none()
99            && self.estimate_backend_source.is_none()
100            && self.estimate_confidence.is_none()
101            && self.estimate_fallback_reason.is_none()
102            && self.estimate_caveats.is_empty()
103            && self.estimate_backend_evidence.is_empty()
104    }
105
106    pub fn has_human_visible_detail(&self, estimate_source: EstimateSource) -> bool {
107        matches!(
108            estimate_source,
109            EstimateSource::Unknown | EstimateSource::ScanCache
110        ) || self.estimate_backend.is_some_and(|backend| {
111            backend != ScanBackendKind::PortableRecursive
112                || !self.estimate_caveats.is_empty()
113                || self.estimate_fallback_reason.is_some()
114                || self.estimate_backend_source.is_some()
115                || !self.estimate_backend_evidence.is_empty()
116        }) || self.estimate_fallback_reason.is_some()
117            || self.estimate_backend_source.is_some()
118            || !self.estimate_caveats.is_empty()
119            || !self.estimate_backend_evidence.is_empty()
120    }
121}
122
123#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
124pub struct CleanupSummary {
125    pub total_targets: usize,
126    pub allowed_targets: usize,
127    pub skipped_targets: usize,
128    pub blocked_targets: usize,
129    pub failed_targets: usize,
130    pub completed_targets: usize,
131    pub estimated_bytes: u64,
132    pub freed_bytes: u64,
133    pub pending_reclaim_bytes: u64,
134    #[serde(default)]
135    pub issue_matrix: Vec<CleanupIssueSummary>,
136    #[serde(default)]
137    pub warning_matrix: Vec<WarningSummary>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct CleanupIssueSummary {
142    pub status: TargetStatus,
143    pub reason_code: CleanupTargetIssueReason,
144    pub targets: usize,
145    pub estimated_bytes: u64,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct CleanupTarget {
150    pub rule_id: String,
151    pub path: PathBuf,
152    pub estimated_bytes: u64,
153    #[serde(default)]
154    pub estimate_source: EstimateSource,
155    #[serde(default, flatten)]
156    pub estimate_provenance: EstimateProvenance,
157    pub mode: DeleteMode,
158    pub status: TargetStatus,
159    pub reason: Option<String>,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub reason_code: Option<CleanupTargetIssueReason>,
162    pub restore_hint: Option<String>,
163    #[serde(default)]
164    pub deletion_style: CleanupTargetDeletionStyle,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub modified_at_unix_seconds: Option<u64>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub project_artifact: Option<ProjectArtifactContextMatch>,
169    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170    pub warnings: Vec<String>,
171    #[serde(default, skip_serializing_if = "Vec::is_empty")]
172    pub evidence: Vec<CleanupTargetEvidence>,
173    pub freed_bytes: u64,
174    pub pending_reclaim_bytes: u64,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct CleanupTargetEvidence {
179    pub kind: CleanupTargetEvidenceKind,
180    pub status: TargetStatus,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub reason_code: Option<CleanupTargetIssueReason>,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub warning: Option<String>,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub reason: Option<String>,
187}
188
189impl CleanupTargetEvidence {
190    pub fn issue(
191        status: TargetStatus,
192        reason_code: CleanupTargetIssueReason,
193        reason: impl Into<String>,
194    ) -> Self {
195        Self {
196            kind: CleanupTargetEvidenceKind::Issue,
197            status,
198            reason_code: Some(reason_code),
199            warning: None,
200            reason: Some(reason.into()),
201        }
202    }
203
204    pub fn warning(status: TargetStatus, warning: impl Into<String>) -> Self {
205        Self {
206            kind: CleanupTargetEvidenceKind::Warning,
207            status,
208            reason_code: None,
209            warning: Some(warning.into()),
210            reason: None,
211        }
212    }
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "kebab-case")]
217pub enum CleanupTargetEvidenceKind {
218    Issue,
219    Warning,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
223#[serde(rename_all = "kebab-case")]
224pub enum CleanupTargetIssueReason {
225    SafetyOptInRequired,
226    WarningGateRequired,
227    TargetDiscoverySkipped,
228    TargetDiscoveryFailed,
229    DuplicateTargetPath,
230    SafetyPolicySkipped,
231    ExecutionTargetMissing,
232    ExecutionTargetShadowed,
233    SafetyPolicyBlocked,
234    ProjectArtifactRecentlyModified,
235    ReclaimLimitSatisfied,
236    ScanFailed,
237    ScanPermissionDenied,
238    ExecutionFailed,
239    ExecutionPermissionDenied,
240    Unclassified,
241}
242
243impl CleanupTargetIssueReason {
244    pub fn label(self) -> &'static str {
245        match self {
246            Self::SafetyOptInRequired => "safety-opt-in-required",
247            Self::WarningGateRequired => "warning-gate-required",
248            Self::TargetDiscoverySkipped => "target-discovery-skipped",
249            Self::TargetDiscoveryFailed => "target-discovery-failed",
250            Self::DuplicateTargetPath => "duplicate-target-path",
251            Self::SafetyPolicySkipped => "safety-policy-skipped",
252            Self::ExecutionTargetMissing => "execution-target-missing",
253            Self::ExecutionTargetShadowed => "execution-target-shadowed",
254            Self::SafetyPolicyBlocked => "safety-policy-blocked",
255            Self::ProjectArtifactRecentlyModified => "project-artifact-recently-modified",
256            Self::ReclaimLimitSatisfied => "reclaim-limit-satisfied",
257            Self::ScanFailed => "scan-failed",
258            Self::ScanPermissionDenied => "scan-permission-denied",
259            Self::ExecutionFailed => "execution-failed",
260            Self::ExecutionPermissionDenied => "execution-permission-denied",
261            Self::Unclassified => "unclassified",
262        }
263    }
264}
265
266impl CleanupTarget {
267    pub fn allowed(
268        rule_id: impl Into<String>,
269        path: PathBuf,
270        estimated_bytes: u64,
271        mode: DeleteMode,
272    ) -> Self {
273        Self {
274            rule_id: rule_id.into(),
275            path,
276            estimated_bytes,
277            estimate_source: EstimateSource::FreshScan,
278            estimate_provenance: EstimateProvenance::default(),
279            mode,
280            status: TargetStatus::Allowed,
281            reason: None,
282            reason_code: None,
283            restore_hint: None,
284            deletion_style: CleanupTargetDeletionStyle::default(),
285            modified_at_unix_seconds: None,
286            project_artifact: None,
287            warnings: Vec::new(),
288            evidence: Vec::new(),
289            freed_bytes: 0,
290            pending_reclaim_bytes: 0,
291        }
292    }
293
294    pub fn skipped(
295        rule_id: impl Into<String>,
296        path: PathBuf,
297        mode: DeleteMode,
298        reason: impl Into<String>,
299    ) -> Self {
300        Self::skipped_with_reason_code(
301            rule_id,
302            path,
303            mode,
304            CleanupTargetIssueReason::Unclassified,
305            reason,
306        )
307    }
308
309    pub fn skipped_with_reason_code(
310        rule_id: impl Into<String>,
311        path: PathBuf,
312        mode: DeleteMode,
313        reason_code: CleanupTargetIssueReason,
314        reason: impl Into<String>,
315    ) -> Self {
316        let (reason, issue_evidence) =
317            Self::issue_reason_and_evidence(TargetStatus::Skipped, reason_code, reason);
318        Self {
319            rule_id: rule_id.into(),
320            path,
321            estimated_bytes: 0,
322            estimate_source: EstimateSource::NotMeasured,
323            estimate_provenance: EstimateProvenance::default(),
324            mode,
325            status: TargetStatus::Skipped,
326            reason: Some(reason.clone()),
327            reason_code: Some(reason_code),
328            restore_hint: None,
329            deletion_style: CleanupTargetDeletionStyle::default(),
330            modified_at_unix_seconds: None,
331            project_artifact: None,
332            warnings: Vec::new(),
333            evidence: vec![issue_evidence],
334            freed_bytes: 0,
335            pending_reclaim_bytes: 0,
336        }
337    }
338
339    pub fn blocked(
340        rule_id: impl Into<String>,
341        path: PathBuf,
342        mode: DeleteMode,
343        reason: impl Into<String>,
344    ) -> Self {
345        Self::blocked_with_reason_code(
346            rule_id,
347            path,
348            mode,
349            CleanupTargetIssueReason::Unclassified,
350            reason,
351        )
352    }
353
354    pub fn blocked_with_reason_code(
355        rule_id: impl Into<String>,
356        path: PathBuf,
357        mode: DeleteMode,
358        reason_code: CleanupTargetIssueReason,
359        reason: impl Into<String>,
360    ) -> Self {
361        let (reason, issue_evidence) =
362            Self::issue_reason_and_evidence(TargetStatus::Blocked, reason_code, reason);
363        Self {
364            rule_id: rule_id.into(),
365            path,
366            estimated_bytes: 0,
367            estimate_source: EstimateSource::NotMeasured,
368            estimate_provenance: EstimateProvenance::default(),
369            mode,
370            status: TargetStatus::Blocked,
371            reason: Some(reason.clone()),
372            reason_code: Some(reason_code),
373            restore_hint: None,
374            deletion_style: CleanupTargetDeletionStyle::default(),
375            modified_at_unix_seconds: None,
376            project_artifact: None,
377            warnings: Vec::new(),
378            evidence: vec![issue_evidence],
379            freed_bytes: 0,
380            pending_reclaim_bytes: 0,
381        }
382    }
383
384    pub fn failed(
385        rule_id: impl Into<String>,
386        path: PathBuf,
387        mode: DeleteMode,
388        estimated_bytes: u64,
389        reason: impl Into<String>,
390    ) -> Self {
391        Self::failed_with_reason_code(
392            rule_id,
393            path,
394            mode,
395            estimated_bytes,
396            CleanupTargetIssueReason::Unclassified,
397            reason,
398        )
399    }
400
401    pub fn failed_with_reason_code(
402        rule_id: impl Into<String>,
403        path: PathBuf,
404        mode: DeleteMode,
405        estimated_bytes: u64,
406        reason_code: CleanupTargetIssueReason,
407        reason: impl Into<String>,
408    ) -> Self {
409        let (reason, issue_evidence) =
410            Self::issue_reason_and_evidence(TargetStatus::Failed, reason_code, reason);
411        Self {
412            rule_id: rule_id.into(),
413            path,
414            estimated_bytes,
415            estimate_source: if estimated_bytes == 0 {
416                EstimateSource::NotMeasured
417            } else {
418                EstimateSource::FreshScan
419            },
420            estimate_provenance: EstimateProvenance::default(),
421            mode,
422            status: TargetStatus::Failed,
423            reason: Some(reason.clone()),
424            reason_code: Some(reason_code),
425            restore_hint: None,
426            deletion_style: CleanupTargetDeletionStyle::default(),
427            modified_at_unix_seconds: None,
428            project_artifact: None,
429            warnings: Vec::new(),
430            evidence: vec![issue_evidence],
431            freed_bytes: 0,
432            pending_reclaim_bytes: 0,
433        }
434    }
435
436    pub fn with_deletion_style(mut self, deletion_style: CleanupTargetDeletionStyle) -> Self {
437        self.deletion_style = deletion_style;
438        self
439    }
440
441    pub fn with_modified_at_unix_seconds(mut self, modified_at_unix_seconds: Option<u64>) -> Self {
442        self.modified_at_unix_seconds = modified_at_unix_seconds;
443        self
444    }
445
446    pub fn with_project_artifact_context(
447        mut self,
448        context: Option<ProjectArtifactContextMatch>,
449    ) -> Self {
450        self.project_artifact = context;
451        self
452    }
453
454    pub fn with_estimate_source(mut self, estimate_source: EstimateSource) -> Self {
455        self.estimate_source = estimate_source;
456        self
457    }
458
459    pub fn with_estimate_provenance(mut self, estimate_provenance: EstimateProvenance) -> Self {
460        self.estimate_provenance = estimate_provenance;
461        self
462    }
463
464    pub fn with_restore_hint(mut self, restore_hint: Option<String>) -> Self {
465        self.restore_hint = restore_hint;
466        self
467    }
468
469    pub fn with_warnings(mut self, warnings: Vec<String>) -> Self {
470        self.evidence
471            .retain(|evidence| evidence.kind != CleanupTargetEvidenceKind::Warning);
472        self.evidence.extend(
473            warnings
474                .iter()
475                .cloned()
476                .map(|warning| CleanupTargetEvidence::warning(self.status, warning)),
477        );
478        self.warnings = warnings;
479        self
480    }
481
482    pub fn mark_completed(
483        &mut self,
484        freed_bytes: u64,
485        pending_reclaim_bytes: u64,
486        reason: Option<String>,
487    ) {
488        self.status = TargetStatus::Completed;
489        self.reason = reason;
490        self.reason_code = None;
491        self.freed_bytes = freed_bytes;
492        self.pending_reclaim_bytes = pending_reclaim_bytes;
493        self.remove_issue_evidence_and_sync_warnings();
494    }
495
496    pub fn mark_skipped_with_reason_code(
497        &mut self,
498        reason_code: CleanupTargetIssueReason,
499        reason: impl Into<String>,
500    ) {
501        self.mark_issue_state(TargetStatus::Skipped, reason_code, reason);
502    }
503
504    pub fn mark_blocked_with_reason_code(
505        &mut self,
506        reason_code: CleanupTargetIssueReason,
507        reason: impl Into<String>,
508    ) {
509        self.mark_issue_state(TargetStatus::Blocked, reason_code, reason);
510    }
511
512    pub fn mark_failed_with_reason_code(
513        &mut self,
514        reason_code: CleanupTargetIssueReason,
515        reason: impl Into<String>,
516    ) {
517        self.mark_issue_state(TargetStatus::Failed, reason_code, reason);
518    }
519
520    fn mark_issue_state(
521        &mut self,
522        status: TargetStatus,
523        reason_code: CleanupTargetIssueReason,
524        reason: impl Into<String>,
525    ) {
526        let (reason, issue_evidence) = Self::issue_reason_and_evidence(status, reason_code, reason);
527        self.status = status;
528        self.reason = Some(reason);
529        self.reason_code = Some(reason_code);
530        self.freed_bytes = 0;
531        self.pending_reclaim_bytes = 0;
532        self.remove_issue_evidence_and_sync_warnings();
533        self.evidence.push(issue_evidence);
534    }
535
536    fn issue_reason_and_evidence(
537        status: TargetStatus,
538        reason_code: CleanupTargetIssueReason,
539        reason: impl Into<String>,
540    ) -> (String, CleanupTargetEvidence) {
541        debug_assert!(status.is_issue());
542        let reason = reason.into();
543        let issue_evidence = CleanupTargetEvidence::issue(status, reason_code, reason.clone());
544        (reason, issue_evidence)
545    }
546
547    fn remove_issue_evidence_and_sync_warnings(&mut self) {
548        let status = self.status;
549        self.evidence.retain_mut(|evidence| match evidence.kind {
550            CleanupTargetEvidenceKind::Issue => false,
551            CleanupTargetEvidenceKind::Warning => {
552                evidence.status = status;
553                true
554            }
555        });
556    }
557
558    fn for_each_issue_evidence(
559        &self,
560        mut visit: impl FnMut(TargetStatus, CleanupTargetIssueReason),
561    ) {
562        let mut emitted = false;
563        for evidence in &self.evidence {
564            if evidence.kind == CleanupTargetEvidenceKind::Issue
565                && evidence.status.is_issue()
566                && let Some(reason_code) = evidence.reason_code
567            {
568                visit(evidence.status, reason_code);
569                emitted = true;
570            }
571        }
572
573        if !emitted
574            && self.status.is_issue()
575            && let Some(reason_code) = self.reason_code
576        {
577            visit(self.status, reason_code);
578        }
579    }
580
581    fn for_each_warning_evidence(&self, mut visit: impl FnMut(&str)) {
582        let mut emitted = false;
583        for evidence in &self.evidence {
584            if evidence.kind == CleanupTargetEvidenceKind::Warning
585                && let Some(warning) = evidence.warning.as_deref()
586            {
587                visit(warning);
588                emitted = true;
589            }
590        }
591
592        if !emitted {
593            for warning in &self.warnings {
594                visit(warning);
595            }
596        }
597    }
598}
599
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
601pub struct CleanupPlan {
602    pub request: PlanRequest,
603    pub summary: CleanupSummary,
604    pub targets: Vec<CleanupTarget>,
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    pub execution_report: Option<crate::execution::ExecutionReport>,
607    #[serde(default, skip_serializing_if = "Vec::is_empty")]
608    pub discovery_diagnostics: Vec<ProjectArtifactDiscoveryDiagnostic>,
609}
610
611impl CleanupPlan {
612    pub fn empty(request: PlanRequest) -> Self {
613        Self {
614            request,
615            summary: CleanupSummary::default(),
616            targets: Vec::new(),
617            execution_report: None,
618            discovery_diagnostics: Vec::new(),
619        }
620    }
621
622    pub fn recompute_summary(&mut self) {
623        let mut summary = CleanupSummary::default();
624        let mut issue_matrix = BTreeMap::new();
625        let mut warning_matrix = BTreeMap::new();
626
627        for target in &self.targets {
628            summary.total_targets += 1;
629            summary.estimated_bytes = summary
630                .estimated_bytes
631                .saturating_add(target.estimated_bytes);
632            summary.freed_bytes = summary.freed_bytes.saturating_add(target.freed_bytes);
633            summary.pending_reclaim_bytes = summary
634                .pending_reclaim_bytes
635                .saturating_add(target.pending_reclaim_bytes);
636
637            match target.status {
638                TargetStatus::Allowed => summary.allowed_targets += 1,
639                TargetStatus::Skipped => summary.skipped_targets += 1,
640                TargetStatus::Blocked => summary.blocked_targets += 1,
641                TargetStatus::Failed => summary.failed_targets += 1,
642                TargetStatus::Completed => summary.completed_targets += 1,
643            }
644
645            target.for_each_issue_evidence(|status, reason_code| {
646                let bucket = issue_matrix
647                    .entry((status, reason_code))
648                    .or_insert_with(|| CleanupIssueSummary {
649                        status,
650                        reason_code,
651                        targets: 0,
652                        estimated_bytes: 0,
653                    });
654                bucket.targets = bucket.targets.saturating_add(1);
655                bucket.estimated_bytes = bucket
656                    .estimated_bytes
657                    .saturating_add(target.estimated_bytes);
658            });
659
660            target.for_each_warning_evidence(|warning| {
661                let bucket =
662                    warning_matrix
663                        .entry(warning.to_owned())
664                        .or_insert_with(|| WarningSummary {
665                            warning: warning.to_owned(),
666                            targets: 0,
667                            estimated_bytes: 0,
668                        });
669                bucket.targets = bucket.targets.saturating_add(1);
670                bucket.estimated_bytes = bucket
671                    .estimated_bytes
672                    .saturating_add(target.estimated_bytes);
673            });
674        }
675
676        summary.issue_matrix = issue_matrix.into_values().collect();
677        summary.warning_matrix = warning_matrix.into_values().collect();
678        self.summary = summary;
679    }
680}