Skip to main content

cargo_reclaim/persistence/
document.rs

1use std::fs;
2use std::path::Path;
3use std::time::SystemTime;
4
5use rayon::prelude::*;
6use serde::{Deserialize, Serialize};
7
8use crate::inventory::InventoryOptions;
9use crate::model::{
10    ArtifactClass, PLAN_SCHEMA_VERSION, PathKind, PathSnapshot, Plan, PlanAction, PlanEntry,
11    PlanInput, PlanSkip, PlanSkipReason, PlanTotals, TargetEvidence,
12};
13use crate::planner::{PlannerOptions, WholeTargetMode};
14use crate::policy::PolicyKind;
15use crate::scanner::ScannerOptions;
16
17use super::PERSISTED_PLAN_SCHEMA_VERSION;
18use super::error::{PlanPersistenceError, PlanPersistenceResult};
19use super::fingerprint_path;
20use super::id::PlanId;
21use super::time::PersistedTimestamp;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct PersistedPlan {
25    pub schema_version: u16,
26    pub id: PlanId,
27    #[serde(flatten)]
28    pub body: PersistedPlanBody,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct PersistedPlanBody {
33    pub created_at: PersistedTimestamp,
34    pub expires_at: PersistedTimestamp,
35    pub interactive_selection_modified: bool,
36    pub invocation: PlanInvocation,
37    pub plan: PersistedPlanSnapshot,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SavePlanOptions {
42    pub created_at: SystemTime,
43    pub expires_at: SystemTime,
44    pub interactive_selection_modified: bool,
45    pub invocation: PlanInvocation,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct PlanInvocation {
50    pub command: PlanCommandKind,
51    pub policy: String,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub config_path: Option<String>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub config_version: Option<u16>,
56    pub scanner_options: PersistedScannerOptions,
57    pub inventory_options: PersistedInventoryOptions,
58    #[serde(default)]
59    pub planner_options: PersistedPlannerOptions,
60}
61
62impl PlanInvocation {
63    pub fn new(
64        command: PlanCommandKind,
65        policy: PolicyKind,
66        scanner_options: &ScannerOptions,
67        inventory_options: &InventoryOptions,
68        planner_options: &PlannerOptions,
69    ) -> Self {
70        Self {
71            command,
72            policy: policy_label(policy).to_string(),
73            config_path: None,
74            config_version: None,
75            scanner_options: PersistedScannerOptions::from_options(scanner_options),
76            inventory_options: PersistedInventoryOptions::from_options(inventory_options),
77            planner_options: PersistedPlannerOptions::from_options(planner_options),
78        }
79    }
80
81    pub fn with_config(mut self, path: &Path, version: u16) -> Self {
82        self.config_path = Some(path_string(path));
83        self.config_version = Some(version);
84        self
85    }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum PlanCommandKind {
91    Plan,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct PersistedScannerOptions {
96    pub follow_symlinks: bool,
97    pub allow_name_only_targets: bool,
98    pub cross_filesystems: bool,
99    pub ignored_paths: Vec<String>,
100    pub skipped_paths: Vec<String>,
101}
102
103impl PersistedScannerOptions {
104    fn from_options(options: &ScannerOptions) -> Self {
105        Self {
106            follow_symlinks: options.follow_symlinks,
107            allow_name_only_targets: options.allow_name_only_targets,
108            cross_filesystems: options.cross_filesystems,
109            ignored_paths: options.ignored_paths.iter().map(path_string).collect(),
110            skipped_paths: options.skipped_paths.iter().map(path_string).collect(),
111        }
112    }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct PersistedInventoryOptions {
117    pub follow_symlinks: bool,
118    #[serde(default, skip_serializing_if = "is_false")]
119    pub deep_target_scan: bool,
120    #[serde(default, skip_serializing_if = "is_false")]
121    pub deep_directory_measurement: bool,
122}
123
124impl PersistedInventoryOptions {
125    fn from_options(options: &InventoryOptions) -> Self {
126        Self {
127            follow_symlinks: options.follow_symlinks,
128            deep_target_scan: options.deep_target_scan,
129            deep_directory_measurement: options.deep_directory_measurement,
130        }
131    }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
135pub struct PersistedPlannerOptions {
136    pub recent_write_keep_window_seconds: Option<u64>,
137    pub keep_size_bytes: Option<u64>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub target_size_goal_bytes: Option<u64>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub target_free_disk_bytes: Option<u64>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub minimum_reclaim_bytes: Option<u64>,
144    #[serde(default)]
145    pub keep_rustc_hashes: Vec<u64>,
146    #[serde(default, skip_serializing_if = "is_false")]
147    pub keep_installed_toolchains: bool,
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub keep_toolchains: Vec<String>,
150    #[serde(default, skip_serializing_if = "is_default_whole_target_mode")]
151    pub whole_target_mode: PersistedWholeTargetMode,
152}
153
154impl PersistedPlannerOptions {
155    fn from_options(options: &PlannerOptions) -> Self {
156        Self {
157            recent_write_keep_window_seconds: options
158                .recent_write_keep_window
159                .map(|duration| duration.as_secs()),
160            keep_size_bytes: options.keep_size_bytes,
161            target_size_goal_bytes: options.target_size_goal_bytes,
162            target_free_disk_bytes: options.target_free_disk_bytes,
163            minimum_reclaim_bytes: options.minimum_reclaim_bytes,
164            keep_rustc_hashes: options.keep_rustc_hashes.clone(),
165            keep_installed_toolchains: options.keep_installed_toolchains,
166            keep_toolchains: options.keep_toolchains.clone(),
167            whole_target_mode: PersistedWholeTargetMode::from_mode(options.whole_target_mode),
168        }
169    }
170}
171
172fn is_false(value: &bool) -> bool {
173    !*value
174}
175
176fn is_zero(value: &usize) -> bool {
177    *value == 0
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum PersistedWholeTargetMode {
183    #[default]
184    Off,
185    Confirm,
186    DeleteConfirmed,
187}
188
189impl PersistedWholeTargetMode {
190    fn from_mode(mode: WholeTargetMode) -> Self {
191        match mode {
192            WholeTargetMode::Off => Self::Off,
193            WholeTargetMode::Confirm => Self::Confirm,
194            WholeTargetMode::DeleteConfirmed => Self::DeleteConfirmed,
195        }
196    }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200pub struct PersistedPlanSnapshot {
201    pub schema_version: u16,
202    pub input: PersistedPlanInput,
203    pub entries: Vec<PersistedPlanEntry>,
204    #[serde(default, skip_serializing_if = "Vec::is_empty")]
205    pub skipped_paths: Vec<PersistedPlanSkip>,
206    pub totals: PersistedPlanTotals,
207}
208
209impl PersistedPlanSnapshot {
210    fn from_plan(plan: &Plan) -> PlanPersistenceResult<Self> {
211        Ok(Self {
212            schema_version: plan.schema_version,
213            input: PersistedPlanInput::from_input(&plan.input),
214            entries: plan
215                .entries
216                .par_iter()
217                .map(PersistedPlanEntry::from_entry)
218                .collect::<PlanPersistenceResult<Vec<_>>>()?,
219            skipped_paths: plan
220                .skipped_paths
221                .iter()
222                .map(PersistedPlanSkip::from_skip)
223                .collect(),
224            totals: PersistedPlanTotals::from_totals(plan.totals),
225        })
226    }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230pub struct PersistedPlanInput {
231    pub roots: Vec<String>,
232}
233
234impl PersistedPlanInput {
235    fn from_input(input: &PlanInput) -> Self {
236        Self {
237            roots: input.roots.iter().map(path_string).collect(),
238        }
239    }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243pub struct PersistedPlanSkip {
244    pub path: String,
245    pub reason: String,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub message: Option<String>,
248}
249
250impl PersistedPlanSkip {
251    fn from_skip(skip: &PlanSkip) -> Self {
252        Self {
253            path: path_string(&skip.path),
254            reason: skip_reason_label(skip.reason).to_string(),
255            message: skip.message.clone(),
256        }
257    }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261pub struct PersistedPlanEntry {
262    pub snapshot: PersistedPathSnapshot,
263    pub artifact_class: String,
264    pub evidence: PersistedEvidence,
265    pub action: String,
266    pub policy_reason: String,
267    pub requires_confirmation: bool,
268}
269
270impl PersistedPlanEntry {
271    fn from_entry(entry: &PlanEntry) -> PlanPersistenceResult<Self> {
272        Ok(Self {
273            snapshot: PersistedPathSnapshot::from_snapshot(
274                &entry.snapshot,
275                content_fingerprint_for_entry(entry)?,
276            ),
277            artifact_class: artifact_label(entry.artifact_class).to_string(),
278            evidence: PersistedEvidence::from_evidence(&entry.evidence),
279            action: action_label(&entry.action).to_string(),
280            policy_reason: entry.policy_reason.clone(),
281            requires_confirmation: entry.requires_confirmation,
282        })
283    }
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287pub struct PersistedPathSnapshot {
288    pub path: String,
289    pub size_bytes: u64,
290    pub path_kind: String,
291    pub modified: Option<PersistedTimestamp>,
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub content_fingerprint: Option<String>,
294}
295
296impl PersistedPathSnapshot {
297    fn from_snapshot(snapshot: &PathSnapshot, content_fingerprint: Option<String>) -> Self {
298        Self {
299            path: path_string(&snapshot.path),
300            size_bytes: snapshot.size_bytes,
301            path_kind: path_kind_label(snapshot.path_kind).to_string(),
302            modified: snapshot
303                .modified
304                .and_then(|modified| PersistedTimestamp::from_system_time(modified).ok()),
305            content_fingerprint,
306        }
307    }
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311pub struct PersistedPlanTotals {
312    pub entry_count: usize,
313    pub total_bytes: u64,
314    pub preserved_count: usize,
315    pub delete_candidate_count: usize,
316    #[serde(default, skip_serializing_if = "is_zero")]
317    pub skipped_path_count: usize,
318}
319
320impl PersistedPlanTotals {
321    fn from_totals(totals: PlanTotals) -> Self {
322        Self {
323            entry_count: totals.entry_count,
324            total_bytes: totals.total_bytes,
325            preserved_count: totals.preserved_count,
326            delete_candidate_count: totals.delete_candidate_count,
327            skipped_path_count: totals.skipped_path_count,
328        }
329    }
330}
331
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333pub struct PersistedEvidence {
334    pub kind: String,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub marker: Option<String>,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub source: Option<String>,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub project_manifest: Option<String>,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub matched_name: Option<String>,
343}
344
345impl PersistedEvidence {
346    fn from_evidence(evidence: &TargetEvidence) -> Self {
347        match evidence {
348            TargetEvidence::StrongMarker { marker } => Self {
349                kind: "strong_marker".to_string(),
350                marker: Some(marker.clone()),
351                source: None,
352                project_manifest: None,
353                matched_name: None,
354            },
355            TargetEvidence::ConfiguredPath { source } => Self {
356                kind: "configured_path".to_string(),
357                marker: None,
358                source: Some(source.clone()),
359                project_manifest: None,
360                matched_name: None,
361            },
362            TargetEvidence::ProjectContext { project_manifest } => Self {
363                kind: "project_context".to_string(),
364                marker: None,
365                source: None,
366                project_manifest: Some(path_string(project_manifest)),
367                matched_name: None,
368            },
369            TargetEvidence::WeakNameOnly { matched_name } => Self {
370                kind: "weak_name_only".to_string(),
371                marker: None,
372                source: None,
373                project_manifest: None,
374                matched_name: Some(matched_name.clone()),
375            },
376        }
377    }
378}
379
380pub fn persist_plan(plan: &Plan, options: SavePlanOptions) -> PlanPersistenceResult<PersistedPlan> {
381    if options.expires_at <= options.created_at {
382        return Err(PlanPersistenceError::InvalidTimeRange);
383    }
384
385    let body = PersistedPlanBody {
386        created_at: PersistedTimestamp::from_system_time(options.created_at)?,
387        expires_at: PersistedTimestamp::from_system_time(options.expires_at)?,
388        interactive_selection_modified: options.interactive_selection_modified,
389        invocation: options.invocation,
390        plan: PersistedPlanSnapshot::from_plan(plan)?,
391    };
392    let id = PlanId::from_body(&body)?;
393
394    Ok(PersistedPlan {
395        schema_version: PERSISTED_PLAN_SCHEMA_VERSION,
396        id,
397        body,
398    })
399}
400
401fn content_fingerprint_for_entry(entry: &PlanEntry) -> PlanPersistenceResult<Option<String>> {
402    if !requires_content_fingerprint(entry) {
403        return Ok(None);
404    }
405
406    let path = &entry.snapshot.path;
407    let metadata = fs::symlink_metadata(path).map_err(|error| PlanPersistenceError::Io {
408        path: path.clone(),
409        message: error.to_string(),
410    })?;
411    Ok(Some(fingerprint_path(path, &metadata)?))
412}
413
414fn requires_content_fingerprint(entry: &PlanEntry) -> bool {
415    matches!(
416        entry.action,
417        PlanAction::Delete | PlanAction::RequiresConfirmation
418    ) && entry.artifact_class != ArtifactClass::WholeTarget
419        && entry.artifact_class != ArtifactClass::StaleDeps
420        && entry.artifact_class != ArtifactClass::StaleIncremental
421        && entry.artifact_class != ArtifactClass::DepsOutput
422        && entry.snapshot.path_kind == PathKind::File
423}
424
425pub fn ensure_plan_usable(document: &PersistedPlan, now: SystemTime) -> PlanPersistenceResult<()> {
426    if document.schema_version != PERSISTED_PLAN_SCHEMA_VERSION {
427        return Err(PlanPersistenceError::PersistenceSchemaMismatch {
428            found: document.schema_version,
429            expected: PERSISTED_PLAN_SCHEMA_VERSION,
430        });
431    }
432
433    if document.body.plan.schema_version != PLAN_SCHEMA_VERSION {
434        return Err(PlanPersistenceError::PlanSchemaMismatch {
435            found: document.body.plan.schema_version,
436            expected: PLAN_SCHEMA_VERSION,
437        });
438    }
439
440    let expected_id = PlanId::from_body(&document.body)?;
441    if expected_id != document.id {
442        return Err(PlanPersistenceError::PlanIdMismatch {
443            expected: expected_id.0,
444            found: document.id.0.clone(),
445        });
446    }
447
448    if now >= document.body.expires_at.to_system_time() {
449        return Err(PlanPersistenceError::PlanExpired);
450    }
451
452    Ok(())
453}
454
455fn policy_label(policy: PolicyKind) -> &'static str {
456    match policy {
457        PolicyKind::Observe => "observe",
458        PolicyKind::Conservative => "conservative",
459        PolicyKind::Balanced => "balanced",
460        PolicyKind::Aggressive => "aggressive",
461        PolicyKind::Custom => "custom",
462    }
463}
464
465fn action_label(action: &PlanAction) -> &'static str {
466    match action {
467        PlanAction::Delete => "delete",
468        PlanAction::Preserve => "preserve",
469        PlanAction::SkipActive => "skip_active",
470        PlanAction::SkipLocked => "skip_locked",
471        PlanAction::Unknown => "unknown",
472        PlanAction::RequiresConfirmation => "requires_confirmation",
473    }
474}
475
476fn artifact_label(artifact_class: ArtifactClass) -> &'static str {
477    artifact_class.label()
478}
479
480fn skip_reason_label(reason: PlanSkipReason) -> &'static str {
481    reason.label()
482}
483
484fn is_default_whole_target_mode(mode: &PersistedWholeTargetMode) -> bool {
485    *mode == PersistedWholeTargetMode::Off
486}
487
488fn path_kind_label(path_kind: PathKind) -> &'static str {
489    match path_kind {
490        PathKind::File => "file",
491        PathKind::Directory => "directory",
492        PathKind::Symlink => "symlink",
493        PathKind::Unknown => "unknown",
494    }
495}
496
497fn path_string(path: impl AsRef<Path>) -> String {
498    path.as_ref().display().to_string()
499}