Skip to main content

backhopper_core/snapshot/
freshness.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5use std::fmt;
6
7use crate::config::Project;
8use crate::digest::DigestWriter;
9use crate::model::names::{ExtractionSettingsDigest, ExtractorVersion};
10use crate::model::snapshot::{FORMAT_VERSION, SnapshotHeader};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum StaleReason {
14    Unreadable,
15    FormatVersion { found: u32 },
16    ExtractorVersion { found: String },
17    ExtractionSettings,
18    HostFacts,
19    MissingApp,
20}
21
22impl fmt::Display for StaleReason {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Self::Unreadable => f.write_str("the file cannot be read"),
26            Self::FormatVersion { found } => {
27                write!(f, "format-version {found}, expected {FORMAT_VERSION}")
28            }
29            Self::ExtractorVersion { found } if found.is_empty() => {
30                f.write_str("no extractor version recorded")
31            }
32            Self::ExtractorVersion { found } => write!(f, "extractor version {found}"),
33            Self::ExtractionSettings => {
34                f.write_str("made with other extraction settings than the configured ones")
35            }
36            Self::HostFacts => f.write_str("made with other host release facts"),
37            Self::MissingApp => f.write_str("does not hold an app the plan needs"),
38        }
39    }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ExpectedExtraction {
44    pub extractor: ExtractorVersion,
45    pub settings: ExtractionSettingsDigest,
46}
47
48// List settings are sets: their order in the config does not change a snapshot.
49#[must_use]
50pub fn extraction_settings_digest(project: &Project) -> ExtractionSettingsDigest {
51    let mut writer = DigestWriter::new("extraction settings");
52    writer
53        .text(&project.layout.to_string())
54        .text(&project.manifest.to_string());
55    feed_set(&mut writer, project.scan_paths.iter().map(String::as_str));
56    feed_set(&mut writer, project.app_roots.iter().map(String::as_str));
57    feed_set(
58        &mut writer,
59        project.include_apps.iter().map(|app| app.as_str()),
60    );
61    feed_set(
62        &mut writer,
63        project.exclude_apps.iter().map(|app| app.as_str()),
64    );
65    feed_set(
66        &mut writer,
67        project.excluded_subdirs.iter().map(String::as_str),
68    );
69    feed_set(
70        &mut writer,
71        project.public_modules.iter().map(|module| module.as_str()),
72    );
73    feed_set(
74        &mut writer,
75        project
76            .internal_modules
77            .iter()
78            .map(|module| module.as_str()),
79    );
80    ExtractionSettingsDigest::from_digest(writer.finish())
81}
82
83fn feed_set<'a>(writer: &mut DigestWriter, values: impl Iterator<Item = &'a str>) {
84    let mut values: Vec<&str> = values.collect();
85    values.sort_unstable();
86    values.dedup();
87    writer.count(values.len());
88    for value in values {
89        writer.text(value);
90    }
91}
92
93#[must_use]
94pub fn extraction_freshness(
95    header: &SnapshotHeader,
96    expected: &ExpectedExtraction,
97) -> Option<StaleReason> {
98    if header.extractor_version != expected.extractor.as_str() {
99        return Some(StaleReason::ExtractorVersion {
100            found: header.extractor_version.clone(),
101        });
102    }
103    if header.format_version != FORMAT_VERSION {
104        return Some(StaleReason::FormatVersion {
105            found: header.format_version,
106        });
107    }
108    if header.extraction_settings.as_ref() != Some(&expected.settings) {
109        return Some(StaleReason::ExtractionSettings);
110    }
111    None
112}