Skip to main content

rx4/
snapshot.rs

1//! Pre-edit file snapshots and observed-file version guards (not git).
2
3use sha2::{Digest, Sha256};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct FileSnapshot {
9    pub path: PathBuf,
10    pub bytes: Vec<u8>,
11    pub digest: String,
12}
13
14impl FileSnapshot {
15    pub fn from_bytes(path: impl Into<PathBuf>, bytes: Vec<u8>) -> Self {
16        let digest = digest_bytes(&bytes);
17        Self {
18            path: path.into(),
19            bytes,
20            digest,
21        }
22    }
23
24    pub fn from_path(path: &Path) -> std::io::Result<Self> {
25        let bytes = std::fs::read(path)?;
26        Ok(Self::from_bytes(path, bytes))
27    }
28}
29
30#[derive(Debug, Clone, Default)]
31pub struct SnapshotStore {
32    by_path: HashMap<PathBuf, FileSnapshot>,
33}
34
35impl SnapshotStore {
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    pub fn snapshot_file(&mut self, path: &Path) -> Option<FileSnapshot> {
41        let snap = FileSnapshot::from_path(path).ok()?;
42        self.by_path.insert(snap.path.clone(), snap.clone());
43        Some(snap)
44    }
45
46    pub fn snapshot_bytes(&mut self, path: impl Into<PathBuf>, bytes: Vec<u8>) -> FileSnapshot {
47        let snap = FileSnapshot::from_bytes(path, bytes);
48        self.by_path.insert(snap.path.clone(), snap.clone());
49        snap
50    }
51
52    pub fn get(&self, path: &Path) -> Option<&FileSnapshot> {
53        self.by_path.get(path)
54    }
55}
56
57#[derive(Debug, Clone, Default)]
58pub struct FileVersionGuard {
59    observed: HashMap<PathBuf, String>,
60}
61
62impl FileVersionGuard {
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    pub fn observe(&mut self, path: impl Into<PathBuf>, bytes: &[u8]) {
68        self.observed.insert(path.into(), digest_bytes(bytes));
69    }
70
71    pub fn observe_path(&mut self, path: &Path) -> std::io::Result<()> {
72        let bytes = std::fs::read(path)?;
73        self.observe(path, &bytes);
74        Ok(())
75    }
76
77    pub fn check(&self, path: &Path) -> Result<(), String> {
78        let Some(expected) = self.observed.get(path) else {
79            return Ok(());
80        };
81        let current = match std::fs::read(path) {
82            Ok(bytes) => digest_bytes(&bytes),
83            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
84                return Err(format!("stale observed file {} (missing)", path.display()));
85            }
86            Err(e) => return Err(format!("stale observed file {}: {e}", path.display())),
87        };
88        if &current != expected {
89            return Err(format!(
90                "stale observed file {} (digest changed)",
91                path.display()
92            ));
93        }
94        Ok(())
95    }
96}
97
98pub fn digest_bytes(bytes: &[u8]) -> String {
99    Sha256::digest(bytes)
100        .iter()
101        .map(|b| format!("{b:02x}"))
102        .collect()
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn snapshot_round_trips_bytes() {
111        let mut store = SnapshotStore::new();
112        let snap = store.snapshot_bytes("src/lib.rs", b"hello".to_vec());
113        assert_eq!(snap.bytes, b"hello");
114        assert_eq!(
115            store.get(Path::new("src/lib.rs")).unwrap().digest,
116            snap.digest
117        );
118    }
119
120    #[test]
121    fn version_guard_allows_unobserved_and_matching() {
122        let dir = tempfile::tempdir().unwrap();
123        let path = dir.path().join("f.txt");
124        std::fs::write(&path, b"a").unwrap();
125        let mut guard = FileVersionGuard::new();
126        assert!(guard.check(&path).is_ok());
127        guard.observe(&path, b"a");
128        assert!(guard.check(&path).is_ok());
129    }
130
131    #[test]
132    fn version_guard_fail_closed_on_stale() {
133        let dir = tempfile::tempdir().unwrap();
134        let path = dir.path().join("f.txt");
135        std::fs::write(&path, b"a").unwrap();
136        let mut guard = FileVersionGuard::new();
137        guard.observe_path(&path).unwrap();
138        std::fs::write(&path, b"b").unwrap();
139        assert!(guard.check(&path).unwrap_err().contains("stale"));
140    }
141}