1use std::collections::BTreeMap;
20use std::path::Path;
21
22use anyhow::{Context, Result};
23use perspt_sdk::srbn;
24use sha2::{Digest, Sha256};
25
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
28pub struct WorkspaceState {
29 pub files: BTreeMap<String, Option<String>>,
32}
33
34impl WorkspaceState {
35 pub fn root_hash(&self) -> String {
37 let mut hasher = Sha256::new();
38 for (path, hash) in &self.files {
39 hasher.update(path.as_bytes());
40 hasher.update([0u8]);
41 hasher.update(hash.as_deref().unwrap_or("absent").as_bytes());
42 hasher.update([0u8]);
43 }
44 hex_digest(hasher)
45 }
46}
47
48pub fn snapshot_workspace(root: &Path, paths: &[String]) -> Result<WorkspaceState> {
52 let mut files = BTreeMap::new();
53 for rel in paths {
54 let absolute = root.join(rel);
55 let hash = match std::fs::read(&absolute) {
56 Ok(bytes) => {
57 let mut hasher = Sha256::new();
58 hasher.update(&bytes);
59 Some(hex_digest(hasher))
60 }
61 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
62 Err(e) => return Err(e).with_context(|| format!("re-reading {rel:?}")),
63 };
64 files.insert(rel.clone(), hash);
65 }
66 Ok(WorkspaceState { files })
67}
68
69#[derive(Debug, Clone, Default, PartialEq, Eq)]
73pub struct ProjectionMismatch {
74 pub denied_proposals: u32,
75 pub stale_witnesses: u32,
76 pub ambiguous_edits: u32,
77 pub formatter_rewrites: u32,
78 pub partial_applications: u32,
79}
80
81impl ProjectionMismatch {
82 pub fn total(&self) -> u32 {
84 self.denied_proposals
85 + self.stale_witnesses
86 + self.ambiguous_edits
87 + self.formatter_rewrites
88 + self.partial_applications
89 }
90}
91
92pub struct SnapshotRealizer;
97
98impl srbn::Realizer<WorkspaceState> for SnapshotRealizer {
99 fn realize(
100 &mut self,
101 proposed: WorkspaceState,
102 ) -> srbn::SrbnResult<srbn::Realization<WorkspaceState>> {
103 Ok(srbn::Realization::unmeasured(proposed))
106 }
107}
108
109fn hex_digest(hasher: Sha256) -> String {
110 hasher
111 .finalize()
112 .iter()
113 .map(|b| format!("{b:02x}"))
114 .collect()
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn snapshot_reads_what_is_actually_on_disk() {
123 let dir = std::env::temp_dir().join(format!("perspt_realize_{}", uuid::Uuid::new_v4()));
124 std::fs::create_dir_all(&dir).unwrap();
125 std::fs::write(dir.join("a.rs"), "fn a() {}").unwrap();
126
127 let state =
128 snapshot_workspace(&dir, &["a.rs".to_string(), "missing.rs".to_string()]).unwrap();
129 assert!(state.files["a.rs"].is_some());
130 assert!(state.files["missing.rs"].is_none());
131
132 let again =
135 snapshot_workspace(&dir, &["a.rs".to_string(), "missing.rs".to_string()]).unwrap();
136 assert_eq!(state.root_hash(), again.root_hash());
137
138 std::fs::write(dir.join("a.rs"), "fn a() { changed() }").unwrap();
139 let changed = snapshot_workspace(&dir, &["a.rs".to_string()]).unwrap();
140 assert_ne!(state.files["a.rs"], changed.files["a.rs"]);
141 let _ = std::fs::remove_dir_all(&dir);
142 }
143
144 #[test]
145 fn root_hash_is_order_independent_by_construction() {
146 let mut a = WorkspaceState::default();
147 a.files.insert("x".into(), Some("h1".into()));
148 a.files.insert("y".into(), None);
149 let mut b = WorkspaceState::default();
150 b.files.insert("y".into(), None);
151 b.files.insert("x".into(), Some("h1".into()));
152 assert_eq!(a.root_hash(), b.root_hash());
153 }
154
155 #[test]
156 fn kernel_conformance_loop_runs_over_snapshots() {
157 let params = perspt_sdk::StabilityParameters::measured(0.5, 0.0);
160 let initial = WorkspaceState::default();
161 let barrier = |state: &WorkspaceState| {
162 let v = state.files.len() as f64;
163 perspt_sdk::AgentBarrierResult::new(state.files.is_empty(), v * v)
164 };
165 let updater = |mut state: WorkspaceState,
166 _b: &srbn::BarrierResult<
167 perspt_sdk::CorrectionDirectionSet,
168 perspt_sdk::Evidence,
169 >| {
170 state.files.pop_last();
171 Ok(state)
172 };
173 let mut start = initial;
174 for i in 0..3 {
175 start.files.insert(format!("f{i}"), None);
176 }
177 let result =
178 perspt_sdk::stabilize_realized(start, barrier, updater, SnapshotRealizer, ¶ms, 8)
179 .unwrap();
180 assert_eq!(result.status, srbn::Status::Stable);
181 assert!(result.max_realizability_residual().is_none(), "unmeasured");
182 }
183}