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}