1use std::collections::HashMap;
2use std::fs;
3use std::marker::PhantomData;
4use std::path::{Path, PathBuf};
5
6use chrono::{DateTime, Local, Utc};
7use serde::{Deserialize, Serialize};
8use walkdir::WalkDir;
9
10use crate::error::{DotAgentError, Result};
11use crate::metadata::compute_hash;
12
13const TARGET_SNAPSHOTS_DIR: &str = "target-snapshots";
14const PROFILE_SNAPSHOTS_DIR: &str = "profile-snapshots";
15const MANIFEST_FILE: &str = "manifest.toml";
16
17const EXCLUDED_DIRS: &[&str] = &[
19 "debug",
20 "file-history",
21 "paste-cache",
22 "cache",
23 "backups",
24 "plans",
25 "ide",
26 "data",
27 "config",
28 "projects",
29 "todos",
30 ".claude",
31 "_bk",
32];
33
34const EXCLUDED_FILES: &[&str] = &[
36 ".DS_Store",
37 "history.jsonl",
38 "__store.db",
39 "config.json",
40 ".dot-agent-meta.toml",
41];
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "kebab-case")]
46pub enum SnapshotTrigger {
47 PreInstall,
49 PreUninstall,
51 PreUpdate,
53 Manual,
55}
56
57impl SnapshotTrigger {
58 pub fn as_str(&self) -> &'static str {
59 match self {
60 Self::PreInstall => "pre-install",
61 Self::PreUninstall => "pre-uninstall",
62 Self::PreUpdate => "pre-update",
63 Self::Manual => "manual",
64 }
65 }
66}
67
68impl std::fmt::Display for SnapshotTrigger {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 write!(f, "{}", self.as_str())
71 }
72}
73
74pub trait SnapshotTarget {
80 fn storage_dir(&self, base_dir: &Path) -> PathBuf;
82
83 fn content_path(&self) -> &Path;
85
86 fn identifier(&self) -> String;
88
89 fn should_exclude(&self, path: &Path, base: &Path) -> bool;
91
92 fn not_found_error(&self) -> DotAgentError;
94}
95
96#[derive(Debug, Clone)]
98pub struct TargetDir {
99 path: PathBuf,
100}
101
102impl TargetDir {
103 pub fn new(path: PathBuf) -> Self {
104 Self { path }
105 }
106}
107
108impl SnapshotTarget for TargetDir {
109 fn storage_dir(&self, base_dir: &Path) -> PathBuf {
110 let hash = compute_hash(self.path.to_string_lossy().as_bytes());
111 let short_hash = &hash[..12];
112 base_dir.join(TARGET_SNAPSHOTS_DIR).join(short_hash)
113 }
114
115 fn content_path(&self) -> &Path {
116 &self.path
117 }
118
119 fn identifier(&self) -> String {
120 self.path.to_string_lossy().to_string()
121 }
122
123 fn should_exclude(&self, path: &Path, base: &Path) -> bool {
124 should_exclude_target(path, base)
125 }
126
127 fn not_found_error(&self) -> DotAgentError {
128 DotAgentError::TargetNotFound {
129 path: self.path.clone(),
130 }
131 }
132}
133
134#[derive(Debug, Clone)]
136pub struct ProfileDir {
137 name: String,
138 path: PathBuf,
139}
140
141impl ProfileDir {
142 pub fn new(name: String, path: PathBuf) -> Self {
143 Self { name, path }
144 }
145}
146
147impl SnapshotTarget for ProfileDir {
148 fn storage_dir(&self, base_dir: &Path) -> PathBuf {
149 base_dir.join(PROFILE_SNAPSHOTS_DIR).join(&self.name)
150 }
151
152 fn content_path(&self) -> &Path {
153 &self.path
154 }
155
156 fn identifier(&self) -> String {
157 self.name.clone()
158 }
159
160 fn should_exclude(&self, path: &Path, _base: &Path) -> bool {
161 should_exclude_profile(path)
162 }
163
164 fn not_found_error(&self) -> DotAgentError {
165 DotAgentError::ProfileNotFound {
166 name: self.name.clone(),
167 }
168 }
169}
170
171fn should_exclude_target(path: &Path, base: &Path) -> bool {
176 if let Some(name) = path.file_name() {
177 let name_str = name.to_string_lossy();
178 if EXCLUDED_FILES.iter().any(|&f| name_str == f) {
179 return true;
180 }
181 if name_str.starts_with(".dot-agent") {
182 return true;
183 }
184 }
185
186 if let Ok(relative) = path.strip_prefix(base) {
187 if let Some(first_component) = relative.components().next() {
188 let first = first_component.as_os_str().to_string_lossy();
189 if EXCLUDED_DIRS.iter().any(|&d| first == d) {
190 return true;
191 }
192 }
193 }
194
195 false
196}
197
198fn should_exclude_profile(path: &Path) -> bool {
199 if let Some(name) = path.file_name() {
200 let name_str = name.to_string_lossy();
201 if name_str.starts_with('.') || name_str == ".DS_Store" {
202 return true;
203 }
204 }
205 false
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct Snapshot {
215 pub id: String,
216 pub timestamp: DateTime<Utc>,
217 pub trigger: SnapshotTrigger,
218 pub message: Option<String>,
219 pub profiles_affected: Vec<String>,
220 pub file_count: usize,
221}
222
223impl Snapshot {
224 pub fn display_time(&self) -> String {
226 self.timestamp
227 .with_timezone(&Local)
228 .format("%Y-%m-%d %H:%M:%S")
229 .to_string()
230 }
231}
232
233#[derive(Debug, Serialize, Deserialize)]
235struct SnapshotManifest {
236 target_path: String,
237 created_at: DateTime<Utc>,
238 snapshots: Vec<Snapshot>,
239}
240
241impl Default for SnapshotManifest {
242 fn default() -> Self {
243 Self {
244 target_path: String::new(),
245 created_at: Utc::now(),
246 snapshots: Vec::new(),
247 }
248 }
249}
250
251#[derive(Debug, Default)]
253pub struct SnapshotDiff {
254 pub unchanged: Vec<String>,
255 pub modified: Vec<String>,
256 pub added: Vec<String>,
257 pub deleted: Vec<String>,
258}
259
260impl SnapshotDiff {
261 pub fn has_changes(&self) -> bool {
262 !self.modified.is_empty() || !self.added.is_empty() || !self.deleted.is_empty()
263 }
264}
265
266pub struct GenericSnapshotManager<T: SnapshotTarget> {
272 base_dir: PathBuf,
273 _marker: PhantomData<T>,
274}
275
276impl<T: SnapshotTarget> GenericSnapshotManager<T> {
277 pub fn new(base_dir: PathBuf) -> Self {
278 Self {
279 base_dir,
280 _marker: PhantomData,
281 }
282 }
283
284 fn load_manifest(&self, target: &T) -> Result<SnapshotManifest> {
285 let manifest_path = target.storage_dir(&self.base_dir).join(MANIFEST_FILE);
286 if !manifest_path.exists() {
287 return Ok(SnapshotManifest::default());
288 }
289 let content = fs::read_to_string(&manifest_path)?;
290 toml::from_str(&content).map_err(DotAgentError::TomlDe)
291 }
292
293 fn save_manifest(&self, target: &T, manifest: &SnapshotManifest) -> Result<()> {
294 let storage_dir = target.storage_dir(&self.base_dir);
295 fs::create_dir_all(&storage_dir)?;
296 let manifest_path = storage_dir.join(MANIFEST_FILE);
297 let content = toml::to_string_pretty(manifest)?;
298 fs::write(manifest_path, content)?;
299 Ok(())
300 }
301
302 pub fn save(
304 &self,
305 target: &T,
306 trigger: SnapshotTrigger,
307 message: Option<&str>,
308 profiles_affected: &[String],
309 ) -> Result<Snapshot> {
310 let content_path = target.content_path();
311 if !content_path.exists() {
312 return Err(target.not_found_error());
313 }
314
315 let timestamp = Utc::now();
316 let id = timestamp.format("%Y%m%d_%H%M%S").to_string();
317
318 let snapshot_dir = target.storage_dir(&self.base_dir).join(&id);
319 fs::create_dir_all(&snapshot_dir)?;
320
321 let mut file_count = 0;
323 for entry in WalkDir::new(content_path)
324 .into_iter()
325 .filter_map(|e| e.ok())
326 .filter(|e| e.path().is_file())
327 {
328 let src = entry.path();
329
330 if target.should_exclude(src, content_path) {
331 continue;
332 }
333
334 if let Ok(relative) = src.strip_prefix(content_path) {
335 let dst = snapshot_dir.join(relative);
336 if let Some(parent) = dst.parent() {
337 fs::create_dir_all(parent)?;
338 }
339 fs::copy(src, dst)?;
340 file_count += 1;
341 }
342 }
343
344 let snapshot = Snapshot {
345 id,
346 timestamp,
347 trigger,
348 message: message.map(String::from),
349 profiles_affected: profiles_affected.to_vec(),
350 file_count,
351 };
352
353 let mut manifest = self.load_manifest(target)?;
355 if manifest.target_path.is_empty() {
356 manifest.target_path = target.identifier();
357 }
358 manifest.snapshots.push(snapshot.clone());
359 self.save_manifest(target, &manifest)?;
360
361 Ok(snapshot)
362 }
363
364 pub fn list(&self, target: &T) -> Result<Vec<Snapshot>> {
366 let manifest = self.load_manifest(target)?;
367 Ok(manifest.snapshots)
368 }
369
370 pub fn get(&self, target: &T, id: &str) -> Result<Snapshot> {
372 let manifest = self.load_manifest(target)?;
373 manifest
374 .snapshots
375 .into_iter()
376 .find(|s| s.id == id)
377 .ok_or_else(|| DotAgentError::SnapshotNotFound { id: id.to_string() })
378 }
379
380 pub fn restore(&self, target: &T, id: &str) -> Result<(usize, usize)> {
382 let _ = self.get(target, id)?;
383
384 let snapshot_dir = target.storage_dir(&self.base_dir).join(id);
385 if !snapshot_dir.exists() {
386 return Err(DotAgentError::SnapshotNotFound { id: id.to_string() });
387 }
388
389 let content_path = target.content_path();
390
391 let mut removed = 0;
393 if content_path.exists() {
394 for entry in WalkDir::new(content_path)
395 .into_iter()
396 .filter_map(|e| e.ok())
397 .filter(|e| e.path().is_file())
398 {
399 let path = entry.path();
400 if target.should_exclude(path, content_path) {
401 continue;
402 }
403 fs::remove_file(path)?;
404 removed += 1;
405 }
406 clean_empty_dirs(content_path)?;
407 }
408
409 fs::create_dir_all(content_path)?;
411 let mut restored = 0;
412 for entry in WalkDir::new(&snapshot_dir)
413 .into_iter()
414 .filter_map(|e| e.ok())
415 .filter(|e| e.path().is_file())
416 {
417 let src = entry.path();
418 if let Ok(relative) = src.strip_prefix(&snapshot_dir) {
419 let dst = content_path.join(relative);
420 if let Some(parent) = dst.parent() {
421 fs::create_dir_all(parent)?;
422 }
423 fs::copy(src, dst)?;
424 restored += 1;
425 }
426 }
427
428 Ok((removed, restored))
429 }
430
431 pub fn diff(&self, target: &T, id: &str) -> Result<SnapshotDiff> {
433 let _ = self.get(target, id)?;
434
435 let snapshot_dir = target.storage_dir(&self.base_dir).join(id);
436 if !snapshot_dir.exists() {
437 return Err(DotAgentError::SnapshotNotFound { id: id.to_string() });
438 }
439
440 let content_path = target.content_path();
441 let mut diff = SnapshotDiff::default();
442
443 let snapshot_files = collect_files(&snapshot_dir)?;
444 let current_files = if content_path.exists() {
445 collect_files_filtered(content_path, |p| target.should_exclude(p, content_path))?
446 } else {
447 HashMap::new()
448 };
449
450 for (path, snap_hash) in &snapshot_files {
451 match current_files.get(path) {
452 Some(curr_hash) if curr_hash == snap_hash => {
453 diff.unchanged.push(path.clone());
454 }
455 Some(_) => {
456 diff.modified.push(path.clone());
457 }
458 None => {
459 diff.deleted.push(path.clone());
460 }
461 }
462 }
463
464 for path in current_files.keys() {
465 if !snapshot_files.contains_key(path) {
466 diff.added.push(path.clone());
467 }
468 }
469
470 diff.unchanged.sort();
471 diff.modified.sort();
472 diff.added.sort();
473 diff.deleted.sort();
474
475 Ok(diff)
476 }
477
478 pub fn delete(&self, target: &T, id: &str) -> Result<()> {
480 let _ = self.get(target, id)?;
481
482 let snapshot_dir = target.storage_dir(&self.base_dir).join(id);
483 if snapshot_dir.exists() {
484 fs::remove_dir_all(&snapshot_dir)?;
485 }
486
487 let mut manifest = self.load_manifest(target)?;
488 manifest.snapshots.retain(|s| s.id != id);
489 self.save_manifest(target, &manifest)?;
490
491 Ok(())
492 }
493
494 pub fn prune(&self, target: &T, keep: usize) -> Result<Vec<String>> {
496 let mut manifest = self.load_manifest(target)?;
497
498 if manifest.snapshots.len() <= keep {
499 return Ok(vec![]);
500 }
501
502 manifest
503 .snapshots
504 .sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
505
506 let to_remove = manifest.snapshots.len() - keep;
507 let removed: Vec<Snapshot> = manifest.snapshots.drain(..to_remove).collect();
508
509 let mut deleted_ids = Vec::new();
510 for snap in &removed {
511 let snapshot_dir = target.storage_dir(&self.base_dir).join(&snap.id);
512 if snapshot_dir.exists() {
513 fs::remove_dir_all(&snapshot_dir)?;
514 }
515 deleted_ids.push(snap.id.clone());
516 }
517
518 self.save_manifest(target, &manifest)?;
519
520 Ok(deleted_ids)
521 }
522
523 pub fn latest(&self, target: &T) -> Result<Option<Snapshot>> {
525 let manifest = self.load_manifest(target)?;
526 Ok(manifest
527 .snapshots
528 .into_iter()
529 .max_by(|a, b| a.timestamp.cmp(&b.timestamp)))
530 }
531}
532
533pub type SnapshotManager = GenericSnapshotManager<TargetDir>;
539
540pub type ProfileSnapshotManager = GenericSnapshotManager<ProfileDir>;
542
543impl SnapshotManager {
548 pub fn save_target(
550 &self,
551 target: &Path,
552 trigger: SnapshotTrigger,
553 message: Option<&str>,
554 profiles_affected: &[String],
555 ) -> Result<Snapshot> {
556 let target_dir = TargetDir::new(target.to_path_buf());
557 self.save(&target_dir, trigger, message, profiles_affected)
558 }
559
560 pub fn list_target(&self, target: &Path) -> Result<Vec<Snapshot>> {
562 let target_dir = TargetDir::new(target.to_path_buf());
563 self.list(&target_dir)
564 }
565
566 pub fn get_target(&self, target: &Path, id: &str) -> Result<Snapshot> {
568 let target_dir = TargetDir::new(target.to_path_buf());
569 self.get(&target_dir, id)
570 }
571
572 pub fn restore_target(&self, target: &Path, id: &str) -> Result<(usize, usize)> {
574 let target_dir = TargetDir::new(target.to_path_buf());
575 self.restore(&target_dir, id)
576 }
577
578 pub fn diff_target(&self, target: &Path, id: &str) -> Result<SnapshotDiff> {
580 let target_dir = TargetDir::new(target.to_path_buf());
581 self.diff(&target_dir, id)
582 }
583
584 pub fn delete_target(&self, target: &Path, id: &str) -> Result<()> {
586 let target_dir = TargetDir::new(target.to_path_buf());
587 self.delete(&target_dir, id)
588 }
589
590 pub fn prune_target(&self, target: &Path, keep: usize) -> Result<Vec<String>> {
592 let target_dir = TargetDir::new(target.to_path_buf());
593 self.prune(&target_dir, keep)
594 }
595}
596
597impl ProfileSnapshotManager {
602 pub fn save_profile(
604 &self,
605 profile_name: &str,
606 profile_path: &Path,
607 message: Option<&str>,
608 ) -> Result<Snapshot> {
609 let profile_dir = ProfileDir::new(profile_name.to_string(), profile_path.to_path_buf());
610 self.save(
611 &profile_dir,
612 SnapshotTrigger::Manual,
613 message,
614 &[profile_name.to_string()],
615 )
616 }
617
618 pub fn list_profile(&self, profile_name: &str) -> Result<Vec<Snapshot>> {
620 let profile_dir = ProfileDir::new(profile_name.to_string(), PathBuf::new());
622 self.list(&profile_dir)
623 }
624
625 pub fn get_profile(&self, profile_name: &str, id: &str) -> Result<Snapshot> {
627 let profile_dir = ProfileDir::new(profile_name.to_string(), PathBuf::new());
628 self.get(&profile_dir, id)
629 }
630
631 pub fn restore_profile(
633 &self,
634 profile_name: &str,
635 profile_path: &Path,
636 id: &str,
637 ) -> Result<(usize, usize)> {
638 let profile_dir = ProfileDir::new(profile_name.to_string(), profile_path.to_path_buf());
639 self.restore(&profile_dir, id)
640 }
641
642 pub fn diff_profile(
644 &self,
645 profile_name: &str,
646 profile_path: &Path,
647 id: &str,
648 ) -> Result<SnapshotDiff> {
649 let profile_dir = ProfileDir::new(profile_name.to_string(), profile_path.to_path_buf());
650 self.diff(&profile_dir, id)
651 }
652
653 pub fn delete_profile(&self, profile_name: &str, id: &str) -> Result<()> {
655 let profile_dir = ProfileDir::new(profile_name.to_string(), PathBuf::new());
656 self.delete(&profile_dir, id)
657 }
658
659 pub fn prune_profile(&self, profile_name: &str, keep: usize) -> Result<Vec<String>> {
661 let profile_dir = ProfileDir::new(profile_name.to_string(), PathBuf::new());
662 self.prune(&profile_dir, keep)
663 }
664}
665
666fn collect_files(dir: &Path) -> Result<HashMap<String, String>> {
671 let mut files = HashMap::new();
672 for entry in WalkDir::new(dir)
673 .into_iter()
674 .filter_map(|e| e.ok())
675 .filter(|e| e.path().is_file())
676 {
677 let path = entry.path();
678 if let Ok(relative) = path.strip_prefix(dir) {
679 let content = fs::read(path)?;
680 let hash = compute_hash(&content);
681 files.insert(relative.to_string_lossy().to_string(), hash);
682 }
683 }
684 Ok(files)
685}
686
687fn collect_files_filtered<F>(dir: &Path, should_exclude: F) -> Result<HashMap<String, String>>
688where
689 F: Fn(&Path) -> bool,
690{
691 let mut files = HashMap::new();
692 for entry in WalkDir::new(dir)
693 .into_iter()
694 .filter_map(|e| e.ok())
695 .filter(|e| e.path().is_file())
696 {
697 let path = entry.path();
698 if should_exclude(path) {
699 continue;
700 }
701 if let Ok(relative) = path.strip_prefix(dir) {
702 let content = fs::read(path)?;
703 let hash = compute_hash(&content);
704 files.insert(relative.to_string_lossy().to_string(), hash);
705 }
706 }
707 Ok(files)
708}
709
710fn clean_empty_dirs(dir: &Path) -> Result<()> {
711 for entry in WalkDir::new(dir)
712 .contents_first(true)
713 .into_iter()
714 .filter_map(|e| e.ok())
715 .filter(|e| e.path().is_dir())
716 {
717 let path = entry.path();
718 if path != dir && fs::read_dir(path)?.next().is_none() {
719 fs::remove_dir(path)?;
720 }
721 }
722 Ok(())
723}