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