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