Skip to main content

visual_rubric/batch/
snapshot.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7/// Content-hash snapshot for a caller-provided asset set.
8#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
9pub struct AssetSnapshot {
10    hashes: BTreeMap<PathBuf, String>,
11}
12
13impl AssetSnapshot {
14    /// Captures hashes for the supplied asset paths.
15    ///
16    /// # Errors
17    ///
18    /// Returns an IO error when any asset cannot be read.
19    pub fn capture<I, P, F>(paths: I, hasher: F) -> std::io::Result<Self>
20    where
21        I: IntoIterator<Item = P>,
22        P: AsRef<Path>,
23        F: Fn(&[u8]) -> String,
24    {
25        let mut hashes = BTreeMap::new();
26        for path in paths {
27            let path = path.as_ref();
28            let bytes = fs::read(path)?;
29            hashes.insert(path.to_path_buf(), hasher(&bytes));
30        }
31        Ok(Self { hashes })
32    }
33
34    /// Builds a snapshot directly from path/hash pairs.
35    #[must_use]
36    pub fn from_hashes<I, P, S>(entries: I) -> Self
37    where
38        I: IntoIterator<Item = (P, S)>,
39        P: Into<PathBuf>,
40        S: Into<String>,
41    {
42        Self {
43            hashes: entries
44                .into_iter()
45                .map(|(path, hash)| (path.into(), hash.into()))
46                .collect(),
47        }
48    }
49}
50
51/// Content change for one asset between two snapshots.
52#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
53#[serde(tag = "change", content = "path", rename_all = "snake_case")]
54pub enum AssetChange {
55    /// Asset exists only in the after snapshot.
56    Added(PathBuf),
57    /// Asset exists in both snapshots but has a different content hash.
58    Changed(PathBuf),
59    /// Asset exists only in the before snapshot.
60    Deleted(PathBuf),
61    /// Asset exists in both snapshots with the same content hash.
62    Unchanged(PathBuf),
63}
64
65impl AssetChange {
66    /// Returns the changed asset path.
67    #[must_use]
68    pub fn path(&self) -> &Path {
69        match self {
70            Self::Added(path)
71            | Self::Changed(path)
72            | Self::Deleted(path)
73            | Self::Unchanged(path) => path,
74        }
75    }
76
77    pub(super) fn status(&self) -> &'static str {
78        match self {
79            Self::Added(_) => "added",
80            Self::Changed(_) => "changed",
81            Self::Deleted(_) => "deleted",
82            Self::Unchanged(_) => "unchanged",
83        }
84    }
85}
86
87/// Compares two asset snapshots.
88#[must_use]
89pub fn diff_snapshots(before: &AssetSnapshot, after: &AssetSnapshot) -> Vec<AssetChange> {
90    let keys: BTreeSet<_> = before.hashes.keys().chain(after.hashes.keys()).collect();
91    keys.into_iter()
92        .map(
93            |path| match (before.hashes.get(path), after.hashes.get(path)) {
94                (None, Some(_)) => AssetChange::Added(path.clone()),
95                (Some(_), None) => AssetChange::Deleted(path.clone()),
96                (Some(old), Some(new)) if old == new => AssetChange::Unchanged(path.clone()),
97                (Some(_), Some(_)) => AssetChange::Changed(path.clone()),
98                (None, None) => unreachable!("path came from snapshot keys"),
99            },
100        )
101        .collect()
102}
103
104/// Asset selection mode for a batch rubric run.
105#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
106#[serde(rename_all = "snake_case")]
107pub enum SelectionMode {
108    /// Evaluate only added or changed assets.
109    #[default]
110    ChangedOnly,
111    /// Evaluate added, changed, and unchanged assets.
112    IncludeUnchanged,
113}
114
115/// Selects the assets that should be evaluated from a snapshot diff.
116#[must_use]
117pub fn select_changed(changes: &[AssetChange], mode: SelectionMode) -> Vec<PathBuf> {
118    changes
119        .iter()
120        .filter_map(|change| match (change, mode) {
121            (AssetChange::Added(path) | AssetChange::Changed(path), _) => Some(path.clone()),
122            (AssetChange::Unchanged(path), SelectionMode::IncludeUnchanged) => Some(path.clone()),
123            (AssetChange::Deleted(_) | AssetChange::Unchanged(_), SelectionMode::ChangedOnly) => {
124                None
125            }
126            (AssetChange::Deleted(_), SelectionMode::IncludeUnchanged) => None,
127        })
128        .collect()
129}