Skip to main content

cargo_reclaim/background/
run_log.rs

1use std::error::Error;
2use std::fmt;
3use std::fs::{self, OpenOptions};
4use std::io::{BufRead, BufReader, Write};
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use crate::executor::{ApplyEntryResult, ApplyEntryStatus, ApplyReport, ApplyTotals};
10use crate::model::{Plan, PlanSkip, PlanTotals};
11use crate::persistence::{PersistedTimestamp, PlanId};
12use crate::policy::PolicyKind;
13use crate::watcher::{WatcherDecision, WatcherDecisionState, WatcherTriggerReason};
14
15pub const BACKGROUND_RUN_LOG_SCHEMA_VERSION: u16 = 1;
16
17pub type BackgroundRunLogResult<T> = Result<T, BackgroundRunLogError>;
18
19#[derive(Debug)]
20pub enum BackgroundRunLogError {
21    Io {
22        path: PathBuf,
23        source: std::io::Error,
24    },
25    Serialize {
26        path: PathBuf,
27        source: serde_json::Error,
28    },
29    Json {
30        path: PathBuf,
31        line: usize,
32        source: serde_json::Error,
33    },
34    UnsupportedSchemaVersion {
35        path: PathBuf,
36        line: usize,
37        found: u16,
38        expected: u16,
39    },
40}
41
42impl fmt::Display for BackgroundRunLogError {
43    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::Io { path, source } => {
46                write!(
47                    formatter,
48                    "background run log IO error at {}: {source}",
49                    path.display()
50                )
51            }
52            Self::Serialize { path, source } => {
53                write!(
54                    formatter,
55                    "failed to serialize background run log record for {}: {source}",
56                    path.display()
57                )
58            }
59            Self::Json { path, line, source } => {
60                write!(
61                    formatter,
62                    "failed to parse background run log record at {} line {line}: {source}",
63                    path.display()
64                )
65            }
66            Self::UnsupportedSchemaVersion {
67                path,
68                line,
69                found,
70                expected,
71            } => {
72                write!(
73                    formatter,
74                    "unsupported background run log schema version {found} at {} line {line}; expected {expected}",
75                    path.display()
76                )
77            }
78        }
79    }
80}
81
82impl Error for BackgroundRunLogError {
83    fn source(&self) -> Option<&(dyn Error + 'static)> {
84        match self {
85            Self::Io { source, .. } => Some(source),
86            Self::Serialize { source, .. } | Self::Json { source, .. } => Some(source),
87            Self::UnsupportedSchemaVersion { .. } => None,
88        }
89    }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct BackgroundRunLogRecord {
94    pub schema_version: u16,
95    pub run_id: String,
96    pub recorded_at: PersistedTimestamp,
97    #[serde(rename = "event")]
98    pub kind: BackgroundRunEventKind,
99    pub trigger: Option<BackgroundTriggerSummary>,
100    pub selected_policy: Option<String>,
101    pub plan: Option<BackgroundPlanSummary>,
102    #[serde(default)]
103    pub skipped_projects: Vec<BackgroundSkippedProject>,
104    pub apply: Option<BackgroundApplySummary>,
105    #[serde(default)]
106    pub recommendations: Vec<String>,
107    #[serde(default)]
108    pub problems: Vec<String>,
109}
110
111impl BackgroundRunLogRecord {
112    pub fn new(
113        run_id: impl Into<String>,
114        recorded_at: PersistedTimestamp,
115        kind: BackgroundRunEventKind,
116    ) -> Self {
117        Self {
118            schema_version: BACKGROUND_RUN_LOG_SCHEMA_VERSION,
119            run_id: run_id.into(),
120            recorded_at,
121            kind,
122            trigger: None,
123            selected_policy: None,
124            plan: None,
125            skipped_projects: Vec::new(),
126            apply: None,
127            recommendations: Vec::new(),
128            problems: Vec::new(),
129        }
130    }
131
132    pub fn with_trigger(mut self, trigger: BackgroundTriggerSummary) -> Self {
133        self.trigger = Some(trigger);
134        self
135    }
136
137    pub fn with_selected_policy(mut self, policy: PolicyKind) -> Self {
138        self.selected_policy = Some(policy_label(policy).to_owned());
139        self
140    }
141
142    pub fn with_plan(mut self, plan: BackgroundPlanSummary) -> Self {
143        self.plan = Some(plan);
144        self
145    }
146
147    pub fn with_plan_skipped_paths(mut self, plan: &Plan) -> Self {
148        self.skipped_projects.extend(
149            plan.skipped_paths
150                .iter()
151                .map(BackgroundSkippedProject::from_skip),
152        );
153        self
154    }
155
156    pub fn with_apply(mut self, apply: BackgroundApplySummary) -> Self {
157        self.apply = Some(apply);
158        self
159    }
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum BackgroundRunEventKind {
165    Started,
166    Triggered,
167    PlanBuilt,
168    ApplyCompleted,
169    Skipped,
170    Failed,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct BackgroundTriggerSummary {
175    pub state: String,
176    pub reasons: Vec<BackgroundTriggerReasonSummary>,
177}
178
179impl BackgroundTriggerSummary {
180    pub fn from_watcher_decision(decision: &WatcherDecision) -> Self {
181        Self {
182            state: watcher_decision_state_label(decision.state).to_owned(),
183            reasons: decision
184                .reasons
185                .iter()
186                .map(BackgroundTriggerReasonSummary::from_watcher_trigger_reason)
187                .collect(),
188        }
189    }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(tag = "kind", rename_all = "snake_case")]
194pub enum BackgroundTriggerReasonSummary {
195    TargetSizeExceeded {
196        path: String,
197        size_bytes: u64,
198        max_target_size_bytes: u64,
199    },
200    DiskFreeBelow {
201        free_basis_points: u16,
202        threshold_basis_points: u16,
203    },
204    DiskFreeBytesBelow {
205        free_bytes: u64,
206        min_free_disk_bytes: u64,
207    },
208}
209
210impl BackgroundTriggerReasonSummary {
211    fn from_watcher_trigger_reason(reason: &WatcherTriggerReason) -> Self {
212        match reason {
213            WatcherTriggerReason::TargetSizeExceeded {
214                path,
215                size_bytes,
216                max_target_size_bytes,
217            } => Self::TargetSizeExceeded {
218                path: path.display().to_string(),
219                size_bytes: *size_bytes,
220                max_target_size_bytes: *max_target_size_bytes,
221            },
222            WatcherTriggerReason::DiskFreeBelow {
223                free_basis_points,
224                threshold_basis_points,
225            } => Self::DiskFreeBelow {
226                free_basis_points: *free_basis_points,
227                threshold_basis_points: *threshold_basis_points,
228            },
229            WatcherTriggerReason::DiskFreeBytesBelow {
230                free_bytes,
231                min_free_disk_bytes,
232            } => Self::DiskFreeBytesBelow {
233                free_bytes: *free_bytes,
234                min_free_disk_bytes: *min_free_disk_bytes,
235            },
236        }
237    }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct BackgroundPlanSummary {
242    pub plan_id: Option<String>,
243    pub policy: String,
244    pub totals: BackgroundPlanTotals,
245}
246
247impl BackgroundPlanSummary {
248    pub fn from_plan(policy: PolicyKind, plan: &Plan) -> Self {
249        Self::from_totals(policy, None, plan.totals)
250    }
251
252    pub fn from_plan_id_and_totals(
253        policy: PolicyKind,
254        plan_id: &PlanId,
255        totals: PlanTotals,
256    ) -> Self {
257        Self::from_totals(policy, Some(plan_id.as_str().to_owned()), totals)
258    }
259
260    fn from_totals(policy: PolicyKind, plan_id: Option<String>, totals: PlanTotals) -> Self {
261        Self {
262            plan_id,
263            policy: policy_label(policy).to_owned(),
264            totals: BackgroundPlanTotals::from_plan_totals(totals),
265        }
266    }
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
270pub struct BackgroundPlanTotals {
271    pub entry_count: usize,
272    pub total_bytes: u64,
273    pub preserved_count: usize,
274    pub delete_candidate_count: usize,
275    #[serde(default, skip_serializing_if = "is_zero")]
276    pub skipped_path_count: usize,
277}
278
279impl BackgroundPlanTotals {
280    fn from_plan_totals(totals: PlanTotals) -> Self {
281        Self {
282            entry_count: totals.entry_count,
283            total_bytes: totals.total_bytes,
284            preserved_count: totals.preserved_count,
285            delete_candidate_count: totals.delete_candidate_count,
286            skipped_path_count: totals.skipped_path_count,
287        }
288    }
289}
290
291fn is_zero(value: &usize) -> bool {
292    *value == 0
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct BackgroundSkippedProject {
297    pub path: String,
298    pub reason: String,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub message: Option<String>,
301}
302
303impl BackgroundSkippedProject {
304    fn from_skip(skip: &PlanSkip) -> Self {
305        Self {
306            path: skip.path.display().to_string(),
307            reason: skip.reason.label().to_owned(),
308            message: skip.message.clone(),
309        }
310    }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct BackgroundApplySummary {
315    pub plan_id: String,
316    pub dry_run: bool,
317    pub totals: BackgroundApplyTotals,
318    pub notable_entries: Vec<BackgroundApplyEntrySummary>,
319}
320
321impl BackgroundApplySummary {
322    pub fn from_apply_report(report: &ApplyReport) -> Self {
323        Self {
324            plan_id: report.plan_id.as_str().to_owned(),
325            dry_run: report.dry_run,
326            totals: BackgroundApplyTotals::from_apply_totals(&report.totals),
327            notable_entries: report
328                .entries
329                .iter()
330                .filter(|entry| {
331                    matches!(
332                        entry.status,
333                        ApplyEntryStatus::Deleted
334                            | ApplyEntryStatus::DeleteFailed
335                            | ApplyEntryStatus::SkipStalePlan
336                    )
337                })
338                .map(BackgroundApplyEntrySummary::from_apply_entry_result)
339                .collect(),
340        }
341    }
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
345pub struct BackgroundApplyTotals {
346    pub entry_count: usize,
347    pub delete_candidate_count: usize,
348    pub would_delete_count: usize,
349    pub skipped_count: usize,
350    pub stale_skip_count: usize,
351    pub applied_count: usize,
352    pub failed_count: usize,
353    pub would_delete_bytes: u64,
354    pub applied_bytes: u64,
355}
356
357impl BackgroundApplyTotals {
358    fn from_apply_totals(totals: &ApplyTotals) -> Self {
359        Self {
360            entry_count: totals.entry_count,
361            delete_candidate_count: totals.delete_candidate_count,
362            would_delete_count: totals.would_delete_count,
363            skipped_count: totals.skipped_count,
364            stale_skip_count: totals.stale_skip_count,
365            applied_count: totals.applied_count,
366            failed_count: totals.failed_count,
367            would_delete_bytes: totals.would_delete_bytes,
368            applied_bytes: totals.applied_bytes,
369        }
370    }
371}
372
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
374pub struct BackgroundApplyEntrySummary {
375    pub path: String,
376    pub planned_action: String,
377    pub status: String,
378    pub reason: String,
379    pub size_bytes: u64,
380    pub deleted_bytes: Option<u64>,
381}
382
383impl BackgroundApplyEntrySummary {
384    fn from_apply_entry_result(entry: &ApplyEntryResult) -> Self {
385        Self {
386            path: entry.path.clone(),
387            planned_action: entry.planned_action.clone(),
388            status: apply_entry_status_label(entry.status).to_owned(),
389            reason: entry.reason.clone(),
390            size_bytes: entry.size_bytes,
391            deleted_bytes: entry.deleted_bytes,
392        }
393    }
394}
395
396pub fn append_background_run_log_record(
397    path: impl AsRef<Path>,
398    record: &BackgroundRunLogRecord,
399) -> BackgroundRunLogResult<()> {
400    let path = path.as_ref();
401
402    if let Some(parent) = path.parent()
403        && !parent.as_os_str().is_empty()
404    {
405        fs::create_dir_all(parent).map_err(|source| BackgroundRunLogError::Io {
406            path: parent.to_path_buf(),
407            source,
408        })?;
409    }
410
411    let mut file = OpenOptions::new()
412        .create(true)
413        .append(true)
414        .open(path)
415        .map_err(|source| BackgroundRunLogError::Io {
416            path: path.to_path_buf(),
417            source,
418        })?;
419
420    serde_json::to_writer(&mut file, record).map_err(|source| {
421        BackgroundRunLogError::Serialize {
422            path: path.to_path_buf(),
423            source,
424        }
425    })?;
426    file.write_all(b"\n")
427        .and_then(|_| file.flush())
428        .map_err(|source| BackgroundRunLogError::Io {
429            path: path.to_path_buf(),
430            source,
431        })
432}
433
434pub fn read_background_run_log(
435    path: impl AsRef<Path>,
436) -> BackgroundRunLogResult<Vec<BackgroundRunLogRecord>> {
437    let path = path.as_ref();
438    let file =
439        OpenOptions::new()
440            .read(true)
441            .open(path)
442            .map_err(|source| BackgroundRunLogError::Io {
443                path: path.to_path_buf(),
444                source,
445            })?;
446
447    let mut records = Vec::new();
448    for (line_index, line) in BufReader::new(file).lines().enumerate() {
449        let line_number = line_index + 1;
450        let line = line.map_err(|source| BackgroundRunLogError::Io {
451            path: path.to_path_buf(),
452            source,
453        })?;
454        let record: BackgroundRunLogRecord =
455            serde_json::from_str(&line).map_err(|source| BackgroundRunLogError::Json {
456                path: path.to_path_buf(),
457                line: line_number,
458                source,
459            })?;
460
461        if record.schema_version != BACKGROUND_RUN_LOG_SCHEMA_VERSION {
462            return Err(BackgroundRunLogError::UnsupportedSchemaVersion {
463                path: path.to_path_buf(),
464                line: line_number,
465                found: record.schema_version,
466                expected: BACKGROUND_RUN_LOG_SCHEMA_VERSION,
467            });
468        }
469
470        records.push(record);
471    }
472
473    Ok(records)
474}
475
476fn policy_label(policy: PolicyKind) -> &'static str {
477    match policy {
478        PolicyKind::Observe => "observe",
479        PolicyKind::Conservative => "conservative",
480        PolicyKind::Balanced => "balanced",
481        PolicyKind::Aggressive => "aggressive",
482        PolicyKind::Custom => "custom",
483    }
484}
485
486fn watcher_decision_state_label(state: WatcherDecisionState) -> &'static str {
487    match state {
488        WatcherDecisionState::Inactive => "inactive",
489        WatcherDecisionState::NonThresholdMode => "non_threshold_mode",
490        WatcherDecisionState::NotTriggered => "not_triggered",
491        WatcherDecisionState::TriggeredPlanOnly => "triggered_plan_only",
492        WatcherDecisionState::TriggeredPlanAndApply => "triggered_plan_and_apply",
493    }
494}
495
496fn apply_entry_status_label(status: ApplyEntryStatus) -> &'static str {
497    match status {
498        ApplyEntryStatus::WouldDelete => "would_delete",
499        ApplyEntryStatus::Deleted => "deleted",
500        ApplyEntryStatus::NotPlannedForDeletion => "not_planned_for_deletion",
501        ApplyEntryStatus::SkipStalePlan => "skip_stale_plan",
502        ApplyEntryStatus::DeleteFailed => "delete_failed",
503    }
504}