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}