1use sha2::{Digest, Sha256};
2use std::collections::{BTreeSet, HashSet};
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6const MAX_SNAPSHOT_FILE_CONTENT_BYTES: u64 = 1_000_000; #[derive(Debug, Clone)]
12pub struct SnapshotEntry {
13 pub path: PathBuf,
15 pub hash: String,
17 pub content: Option<String>,
19}
20
21#[derive(Debug, Clone)]
25pub struct WorkspaceSnapshot {
26 pub id: String,
28 pub entries: Vec<SnapshotEntry>,
30 pub roots: Vec<PathBuf>,
33 pub created_at: u64,
35}
36
37impl WorkspaceSnapshot {
38 pub fn is_path_in_entry(&self, path: &Path) -> bool {
40 self.entries.iter().any(|e| e.path == path)
41 }
42}
43
44#[derive(Debug, Clone)]
48pub struct ChangeSet {
49 pub files_created: Vec<PathBuf>,
51 pub files_modified: Vec<PathBuf>,
53 pub files_deleted: Vec<PathBuf>,
55 pub diff: Option<String>,
57}
58
59impl ChangeSet {
60 pub fn is_empty(&self) -> bool {
62 self.files_created.is_empty()
63 && self.files_modified.is_empty()
64 && self.files_deleted.is_empty()
65 }
66
67 pub fn total(&self) -> usize {
69 self.files_created.len() + self.files_modified.len() + self.files_deleted.len()
70 }
71}
72
73pub struct SandboxManager;
78
79impl SandboxManager {
80 pub fn create_snapshot(paths: &[PathBuf]) -> WorkspaceSnapshot {
85 let mut entries = Vec::new();
86 let mut visited = BTreeSet::new();
87 let mut root_set = BTreeSet::new();
88
89 for p in paths {
90 if p.is_dir() {
91 root_set.insert(p.canonicalize().unwrap_or_else(|_| p.clone()));
92 collect_files(p, &mut entries, &mut visited);
93 } else if p.is_file() {
94 let snapshot_path = p.canonicalize().unwrap_or_else(|_| p.clone());
95 if let Some(parent) = snapshot_path.parent() {
96 root_set.insert(parent.to_path_buf());
97 }
98 if visited.insert(snapshot_path.clone()) {
99 entries.push(snapshot_file(&snapshot_path));
100 }
101 } else {
102 let parent = p.parent().unwrap_or(Path::new("."));
103 if let Ok(canon_parent) = parent.canonicalize() {
104 root_set.insert(canon_parent);
105 } else {
106 root_set.insert(parent.to_path_buf());
107 }
108 }
109 }
110
111 let created_at = SystemTime::now()
112 .duration_since(UNIX_EPOCH)
113 .map(|d| d.as_secs())
114 .unwrap_or(0);
115 let id = format!("snap_{}", created_at);
116
117 WorkspaceSnapshot {
118 id,
119 entries,
120 roots: root_set.into_iter().collect(),
121 created_at,
122 }
123 }
124
125 pub fn compute_changes(snapshot: &WorkspaceSnapshot) -> ChangeSet {
128 let mut files_created = Vec::new();
129 let mut files_modified = Vec::new();
130 let mut files_deleted = Vec::new();
131
132 let entry_paths: HashSet<&PathBuf> = snapshot.entries.iter().map(|e| &e.path).collect();
134
135 for entry in &snapshot.entries {
137 if !entry.path.exists() {
138 files_deleted.push(entry.path.clone());
139 } else if entry.path.is_file() {
140 if let Ok(current) = hash_file(&entry.path) {
141 if current != entry.hash {
142 files_modified.push(entry.path.clone());
143 }
144 }
145 }
146 }
147
148 for root in &snapshot.roots {
150 if root.is_dir() {
151 find_new_files(root, &entry_paths, &mut files_created);
152 }
153 }
154
155 ChangeSet {
156 files_created,
157 files_modified,
158 files_deleted,
159 diff: None,
160 }
161 }
162
163 pub fn rollback(snapshot: &WorkspaceSnapshot) -> Result<(), String> {
172 for entry in &snapshot.entries {
174 let needs_restore = if !entry.path.exists() {
175 true
176 } else if entry.path.is_file() {
177 hash_file(&entry.path)
178 .map(|h| h != entry.hash)
179 .unwrap_or(false)
180 } else {
181 false
182 };
183
184 if needs_restore {
185 let content = entry.content.as_ref().ok_or_else(|| {
186 format!(
187 "cannot restore `{}`: content not available (file > 1 MB)",
188 entry.path.display()
189 )
190 })?;
191 if let Some(parent) = entry.path.parent() {
192 std::fs::create_dir_all(parent).map_err(|e| {
193 format!(
194 "failed to create parent directory `{}`: {e}",
195 parent.display()
196 )
197 })?;
198 }
199 std::fs::write(&entry.path, content)
200 .map_err(|e| format!("failed to restore `{}`: {e}", entry.path.display()))?;
201 }
202 }
203
204 let entry_paths: HashSet<&PathBuf> = snapshot.entries.iter().map(|e| &e.path).collect();
206 for root in &snapshot.roots {
207 if root.is_dir() {
208 delete_new_files(root, &entry_paths)?;
209 }
210 }
211
212 Ok(())
213 }
214
215 pub fn has_staged_changes() -> bool {
220 let output = std::process::Command::new("git")
221 .args(["diff", "--cached", "--quiet"])
222 .stdout(std::process::Stdio::null())
223 .stderr(std::process::Stdio::null())
224 .status();
225 match output {
226 Ok(status) => !status.success(),
227 Err(_) => false,
228 }
229 }
230}
231
232fn hash_file(path: &Path) -> Result<String, String> {
236 let data =
237 std::fs::read(path).map_err(|e| format!("failed to read `{}`: {e}", path.display()))?;
238 let mut hasher = Sha256::new();
239 hasher.update(&data);
240 Ok(hex::encode(hasher.finalize()))
241}
242
243fn snapshot_file(path: &Path) -> SnapshotEntry {
245 let data = std::fs::read(path).unwrap_or_default();
246 let hash = {
247 let mut hasher = Sha256::new();
248 hasher.update(&data);
249 hex::encode(hasher.finalize())
250 };
251 let content = if data.len() as u64 <= MAX_SNAPSHOT_FILE_CONTENT_BYTES {
252 String::from_utf8(data).ok()
253 } else {
254 None
255 };
256 SnapshotEntry {
257 path: path.to_path_buf(),
258 hash,
259 content,
260 }
261}
262
263fn collect_files(dir: &Path, entries: &mut Vec<SnapshotEntry>, visited: &mut BTreeSet<PathBuf>) {
265 let Ok(iter) = std::fs::read_dir(dir) else {
266 return;
267 };
268 for entry in iter {
269 let Ok(entry) = entry else {
270 continue;
271 };
272 let path = entry.path();
273 if !visited.insert(path.clone()) {
274 continue;
275 }
276 if path.is_dir() {
277 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
278 if !matches!(
279 name,
280 ".git" | "target" | "node_modules" | ".cache" | ".venv" | "venv" | "__pycache__"
281 ) {
282 collect_files(&path, entries, visited);
283 }
284 } else if path.is_file() {
285 entries.push(snapshot_file(&path));
286 }
287 }
288}
289
290fn find_new_files(dir: &Path, snapshot_paths: &HashSet<&PathBuf>, created: &mut Vec<PathBuf>) {
292 let Ok(iter) = std::fs::read_dir(dir) else {
293 return;
294 };
295 for entry in iter {
296 let Ok(entry) = entry else {
297 continue;
298 };
299 let path = entry.path();
300 if path.is_dir() {
301 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
302 if !matches!(name, ".git" | "target" | "node_modules") {
303 find_new_files(&path, snapshot_paths, created);
304 }
305 } else if path.is_file() && !snapshot_paths.contains(&path) {
306 created.push(path);
307 }
308 }
309}
310
311fn delete_new_files(dir: &Path, snapshot_paths: &HashSet<&PathBuf>) -> Result<(), String> {
313 let Ok(iter) = std::fs::read_dir(dir) else {
314 return Ok(());
315 };
316 for entry in iter {
317 let Ok(entry) = entry else {
318 continue;
319 };
320 let path = entry.path();
321 if path.is_dir() {
322 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
323 if !matches!(name, ".git" | "target" | "node_modules") {
324 delete_new_files(&path, snapshot_paths)?;
325 }
326 let _ = std::fs::remove_dir(&path);
328 } else if path.is_file() && !snapshot_paths.contains(&path) {
329 std::fs::remove_file(&path)
330 .map_err(|e| format!("failed to delete `{}`: {e}", path.display()))?;
331 }
332 }
333 Ok(())
334}
335
336#[cfg(test)]
339mod tests {
340 use super::*;
341 use std::fs;
342
343 fn write(path: &Path, content: &str) {
344 if let Some(parent) = path.parent() {
345 fs::create_dir_all(parent).unwrap();
346 }
347 fs::write(path, content).unwrap();
348 }
349
350 #[test]
353 fn snapshot_captures_file_state() {
354 let dir = tempfile::tempdir().expect("tempdir");
355 let f1 = dir.path().join("a.txt");
356 let f2 = dir.path().join("b.txt");
357 write(&f1, "hello");
358 write(&f2, "world");
359
360 let snap = SandboxManager::create_snapshot(&[f1.clone(), f2.clone()]);
361 assert_eq!(snap.entries.len(), 2);
362 assert!(snap.entries.iter().any(|e| e.path == f1));
363 assert!(snap.entries.iter().any(|e| e.path == f2));
364 assert!(snap.entries.iter().all(|e| !e.hash.is_empty()));
365 assert!(snap.entries.iter().all(|e| e.content.is_some()));
366 assert!(snap.created_at > 0);
367 }
368
369 #[test]
370 fn snapshot_captures_directory_recursively() {
371 let dir = tempfile::tempdir().expect("tempdir");
372 let sub = dir.path().join("a").join("b");
373 fs::create_dir_all(&sub).unwrap();
374 write(&sub.join("deep.txt"), "deep");
375 write(&dir.path().join("root.txt"), "root");
376
377 let snap = SandboxManager::create_snapshot(&[dir.path().to_path_buf()]);
378 assert_eq!(snap.entries.len(), 2);
379 assert!(snap.entries.iter().any(|e| e.path == sub.join("deep.txt")));
380 assert!(
381 snap.entries
382 .iter()
383 .any(|e| e.path == dir.path().join("root.txt"))
384 );
385 }
386
387 #[test]
388 fn snapshot_skips_git_directory() {
389 let dir = tempfile::tempdir().expect("tempdir");
390 let git = dir.path().join(".git");
391 fs::create_dir_all(&git).unwrap();
392 write(&git.join("HEAD"), "ref: refs/heads/main");
393 write(&dir.path().join("actual.txt"), "real");
394
395 let snap = SandboxManager::create_snapshot(&[dir.path().to_path_buf()]);
396 assert_eq!(snap.entries.len(), 1);
397 assert!(
398 snap.entries
399 .iter()
400 .any(|e| e.path == dir.path().join("actual.txt"))
401 );
402 }
403
404 #[test]
407 fn rollback_restores_modified_file() {
408 let dir = tempfile::tempdir().expect("tempdir");
409 let f = dir.path().join("test.txt");
410 write(&f, "original");
411
412 let snap = SandboxManager::create_snapshot(&[f.clone()]);
413 write(&f, "modified content");
414 assert_eq!(fs::read_to_string(&f).unwrap(), "modified content");
415
416 SandboxManager::rollback(&snap).unwrap();
417 assert_eq!(fs::read_to_string(&f).unwrap(), "original");
418 }
419
420 #[test]
421 fn rollback_restores_deleted_file() {
422 let dir = tempfile::tempdir().expect("tempdir");
423 let f = dir.path().join("gone.txt");
424 write(&f, "will be restored");
425
426 let snap = SandboxManager::create_snapshot(&[f.clone()]);
427 fs::remove_file(&f).unwrap();
428 assert!(!f.exists());
429
430 SandboxManager::rollback(&snap).unwrap();
431 assert!(f.exists());
432 assert_eq!(fs::read_to_string(&f).unwrap(), "will be restored");
433 }
434
435 #[test]
438 fn rollback_deletes_created_file() {
439 let dir = tempfile::tempdir().expect("tempdir");
440 let existing = dir.path().join("keep.txt");
441 write(&existing, "original");
442
443 let snap = SandboxManager::create_snapshot(&[dir.path().to_path_buf()]);
444 let created = dir.path().join("created.txt");
445 write(&created, "new file");
446 assert!(created.exists());
447
448 SandboxManager::rollback(&snap).unwrap();
449 assert!(!created.exists(), "created file should be deleted");
450 assert!(existing.exists(), "existing file should be kept");
451 }
452
453 #[test]
454 fn rollback_deletes_file_created_at_snapshotted_future_path() {
455 let dir = tempfile::tempdir().expect("tempdir");
456 let created = dir.path().join("future").join("created.txt");
457
458 let snap = SandboxManager::create_snapshot(std::slice::from_ref(&created));
459 write(&created, "new file");
460 assert!(created.exists());
461
462 SandboxManager::rollback(&snap).unwrap();
463 assert!(!created.exists(), "created file should be deleted");
464 }
465
466 #[test]
467 fn rollback_restores_modified_and_deletes_created_in_single_call() {
468 let dir = tempfile::tempdir().expect("tempdir");
469 let a = dir.path().join("a.txt");
470 write(&a, "a original");
471
472 let snap = SandboxManager::create_snapshot(&[dir.path().to_path_buf()]);
473 write(&a, "a modified");
474 let b = dir.path().join("b.txt");
475 write(&b, "b new");
476
477 SandboxManager::rollback(&snap).unwrap();
478 assert_eq!(fs::read_to_string(&a).unwrap(), "a original");
479 assert!(!b.exists());
480 }
481
482 #[test]
485 fn rollback_without_content_returns_error() {
486 let dir = tempfile::tempdir().expect("tempdir");
487 let f = dir.path().join("large.txt");
488 write(&f, "data");
489
490 let snap = WorkspaceSnapshot {
491 id: "test".into(),
492 entries: vec![SnapshotEntry {
493 path: f.clone(),
494 hash: hash_file(&f).unwrap(),
495 content: None,
496 }],
497 roots: vec![],
498 created_at: 0,
499 };
500
501 write(&f, "modified");
503 let err = SandboxManager::rollback(&snap).unwrap_err();
504 assert!(err.contains("not available"), "error: {err}");
505 }
506
507 #[test]
508 fn snapshot_binary_content_is_not_lossy() {
509 let dir = tempfile::tempdir().expect("tempdir");
510 let f = dir.path().join("binary.bin");
511 fs::write(&f, [0xff, 0x00, 0x61]).unwrap();
512
513 let snap = SandboxManager::create_snapshot(std::slice::from_ref(&f));
514 assert_eq!(snap.entries.len(), 1);
515 assert!(
516 snap.entries[0].content.is_none(),
517 "binary content must not be stored through lossy UTF-8"
518 );
519 }
520
521 #[test]
524 fn rollback_of_empty_snapshot_succeeds() {
525 let snap = WorkspaceSnapshot {
526 id: "empty".into(),
527 entries: vec![],
528 roots: vec![],
529 created_at: 0,
530 };
531 assert!(SandboxManager::rollback(&snap).is_ok());
532 }
533
534 #[test]
537 fn compute_changes_detects_created_modified_deleted() {
538 let dir = tempfile::tempdir().expect("tempdir");
539 let a = dir.path().join("a.txt");
540 let b = dir.path().join("b.txt");
541 write(&a, "a content");
542 write(&b, "b content");
543
544 let snap = SandboxManager::create_snapshot(&[dir.path().to_path_buf()]);
545
546 write(&a, "a modified");
548 fs::remove_file(&b).unwrap();
549 let c = dir.path().join("c.txt");
550 write(&c, "c new");
551
552 let changes = SandboxManager::compute_changes(&snap);
553 assert!(
554 changes.files_modified.iter().any(|p| p == &a),
555 "a should be modified"
556 );
557 assert!(
558 changes.files_deleted.iter().any(|p| p == &b),
559 "b should be deleted"
560 );
561 assert!(
562 changes.files_created.iter().any(|p| p == &c),
563 "c should be created"
564 );
565 }
566
567 #[test]
568 fn compute_changes_empty_when_no_changes() {
569 let dir = tempfile::tempdir().expect("tempdir");
570 let f = dir.path().join("stable.txt");
571 write(&f, "stable");
572
573 let snap = SandboxManager::create_snapshot(&[f.clone()]);
574 let changes = SandboxManager::compute_changes(&snap);
575 assert!(changes.is_empty());
576 assert_eq!(changes.total(), 0);
577 }
578
579 #[test]
582 fn has_staged_changes_returns_bool_without_panicking() {
583 let _ = SandboxManager::has_staged_changes();
586 }
587
588 #[test]
591 fn is_path_in_entry_works_correctly() {
592 let dir = tempfile::tempdir().expect("tempdir");
593 let a = dir.path().join("a.txt");
594 write(&a, "data");
595 let snap = SandboxManager::create_snapshot(&[a.clone()]);
596 assert!(snap.is_path_in_entry(&a));
597 assert!(!snap.is_path_in_entry(&dir.path().join("nonexistent.txt")));
598 }
599}