vtcode_commons/
workspace_snapshot.rs1#![expect(
2 clippy::indexing_slicing,
3 clippy::cast_possible_truncation,
4 unused_results,
5 reason = "Snapshot reads use bounded counts and serialize platform timestamps into the documented compact format."
6)]
7
8use std::collections::BTreeMap;
21use std::io;
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25
26const SKIP_DIRS: &[&str] = &[
28 ".git",
29 ".svn",
30 "target",
31 "node_modules",
32 "dist",
33 "build",
34 ".vtcode",
35 ".next",
36 "vendor",
37];
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub struct FileStat {
42 size: u64,
44 mtime_ns: i64,
46 head_hash: u64,
48}
49
50const HASH_SAMPLE_BYTES: usize = 4096;
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct WorkspaceSnapshot {
56 files: BTreeMap<String, FileStat>,
58 captured_at: String,
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct SnapshotDelta {
65 added: Vec<String>,
67 changed: Vec<String>,
69 removed: Vec<String>,
71}
72
73fn capture(workspace: &Path, max_file_bytes: u64) -> io::Result<WorkspaceSnapshot> {
78 let mut files = BTreeMap::new();
79 collect(workspace, workspace, max_file_bytes, &mut files)?;
80 Ok(WorkspaceSnapshot { files, captured_at: now_rfc3339() })
81}
82
83fn collect(root: &Path, dir: &Path, max_file_bytes: u64, out: &mut BTreeMap<String, FileStat>) -> io::Result<()> {
84 for entry in std::fs::read_dir(dir)? {
85 let entry = entry?;
86 let path = entry.path();
87 let ft = entry.file_type()?;
88 if ft.is_dir() {
89 if let Some(name) = path.file_name().and_then(|n| n.to_str())
90 && SKIP_DIRS.contains(&name)
91 {
92 continue;
93 }
94 collect(root, &path, max_file_bytes, out)?;
95 } else if ft.is_file() {
96 let rel = match path.strip_prefix(root).ok().and_then(|p| p.to_str()) {
97 Some(r) => r.to_string(),
98 None => continue,
99 };
100 match entry.metadata() {
101 Ok(meta) => {
102 let size = meta.len();
103 let mtime_ns = meta
104 .modified()
105 .ok()
106 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
107 .map(|d| d.as_nanos() as i64)
108 .unwrap_or(0);
109 let head_hash = if size <= max_file_bytes { hash_head(&path) } else { 0 };
110 out.insert(rel, FileStat { size, mtime_ns, head_hash });
111 }
112 Err(_) => continue,
113 }
114 }
115 }
116 Ok(())
117}
118
119fn hash_head(path: &Path) -> u64 {
121 const SEED: u64 = 0xcbf29ce484222325;
122 const PRIME: u64 = 0x100000001b3;
123 let mut hash = SEED;
124 let mut buf = [0u8; HASH_SAMPLE_BYTES];
125 if let Ok(mut f) = std::fs::File::open(path) {
126 use std::io::Read;
127 if let Ok(n) = f.read(&mut buf) {
128 for &b in &buf[..n] {
129 hash ^= u64::from(b);
130 hash = hash.wrapping_mul(PRIME);
131 }
132 }
133 }
134 hash
135}
136
137#[must_use]
139pub fn diff(old: &WorkspaceSnapshot, new: &WorkspaceSnapshot) -> SnapshotDelta {
140 let mut delta = SnapshotDelta::default();
141 for (path, new_stat) in &new.files {
142 match old.files.get(path) {
143 None => delta.added.push(path.clone()),
144 Some(old_stat) if old_stat != new_stat => delta.changed.push(path.clone()),
145 Some(_) => {}
146 }
147 }
148 for path in old.files.keys() {
149 if !new.files.contains_key(path) {
150 delta.removed.push(path.clone());
151 }
152 }
153 delta
154}
155
156#[must_use]
158fn is_drift(delta: &SnapshotDelta) -> bool {
159 !delta.added.is_empty() || !delta.changed.is_empty() || !delta.removed.is_empty()
160}
161
162fn save_json(snapshot: &WorkspaceSnapshot, path: &Path) -> io::Result<()> {
164 if let Some(parent) = path.parent() {
165 std::fs::create_dir_all(parent)?;
166 }
167 let bytes = serde_json::to_vec_pretty(snapshot).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
168 std::fs::write(path, bytes)
169}
170
171fn load_json(path: &Path) -> io::Result<WorkspaceSnapshot> {
173 let bytes = std::fs::read(path)?;
174 serde_json::from_slice(&bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
175}
176
177fn now_rfc3339() -> String {
178 chrono::Utc::now().to_rfc3339()
179}
180
181#[must_use]
183pub fn snapshot_path(session_dir: &Path) -> PathBuf {
184 session_dir.join("derived").join("workspace_snapshot.json")
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn capture_and_diff_detects_changes() {
193 let tmp = std::env::temp_dir().join(format!("vtcode-snap-{}", std::process::id()));
194 let ws = tmp.join("ws");
195 std::fs::create_dir_all(&ws).unwrap();
196 std::fs::write(ws.join("a.txt"), b"hello").unwrap();
197 std::fs::write(ws.join("b.txt"), b"world").unwrap();
198
199 let snap1 = capture(&ws, 1_000_000).unwrap();
200 assert_eq!(snap1.files.len(), 2);
201 assert!(snap1.files.contains_key("a.txt"));
202 assert!(snap1.files.contains_key("b.txt"));
203
204 std::fs::write(ws.join("a.txt"), b"changed").unwrap();
206 std::fs::write(ws.join("c.txt"), b"new").unwrap();
207 std::fs::remove_file(ws.join("b.txt")).unwrap();
208
209 let snap2 = capture(&ws, 1_000_000).unwrap();
210 let delta = diff(&snap1, &snap2);
211 assert_eq!(delta.added, vec!["c.txt".to_string()]);
212 assert_eq!(delta.changed, vec!["a.txt".to_string()]);
213 assert_eq!(delta.removed, vec!["b.txt".to_string()]);
214 assert!(is_drift(&delta));
215 drop(std::fs::remove_dir_all(&tmp));
216 }
217
218 #[test]
219 fn json_round_trips() {
220 let tmp = std::env::temp_dir().join(format!("vtcode-snap-json-{}", std::process::id()));
221 let path = tmp.join("snap.json");
222 let snap = WorkspaceSnapshot {
223 files: {
224 let mut m = BTreeMap::new();
225 m.insert("x.rs".to_string(), FileStat { size: 3, mtime_ns: 42, head_hash: 7 });
226 m
227 },
228 captured_at: "2026-01-01T00:00:00Z".to_string(),
229 };
230 save_json(&snap, &path).unwrap();
231 let loaded = load_json(&path).unwrap();
232 assert_eq!(loaded, snap);
233 drop(std::fs::remove_dir_all(&tmp));
234 }
235}