dot_agent_core/
snapshot.rs

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
17/// Directories to exclude from target snapshots (Claude Code system directories)
18const 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
34/// Files to exclude from target snapshots
35const EXCLUDED_FILES: &[&str] = &[
36    ".DS_Store",
37    "history.jsonl",
38    "__store.db",
39    "config.json",
40    ".dot-agent-meta.toml",
41];
42
43/// Trigger that caused the snapshot to be created
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "kebab-case")]
46pub enum SnapshotTrigger {
47    /// Automatically created before install operation
48    PreInstall,
49    /// Automatically created before uninstall operation
50    PreUninstall,
51    /// Automatically created before update operation
52    PreUpdate,
53    /// Manually created by user
54    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
74// ============================================================================
75// Snapshot Target Trait
76// ============================================================================
77
78/// Trait for types that can be snapshot targets
79pub trait SnapshotTarget {
80    /// Get the storage directory for snapshots of this target
81    fn storage_dir(&self, base_dir: &Path) -> PathBuf;
82
83    /// Get the content path to snapshot
84    fn content_path(&self) -> &Path;
85
86    /// Get identifier for display/messages
87    fn identifier(&self) -> String;
88
89    /// Check if a file should be excluded from snapshot
90    fn should_exclude(&self, path: &Path, base: &Path) -> bool;
91
92    /// Error to return when source doesn't exist
93    fn not_found_error(&self) -> DotAgentError;
94}
95
96/// Target directory snapshot (e.g., ~/.claude or project/.claude)
97#[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/// Profile source directory snapshot
135#[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
171// ============================================================================
172// Exclusion Filters
173// ============================================================================
174
175fn 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// ============================================================================
209// Snapshot Data Structures
210// ============================================================================
211
212/// Snapshot metadata
213#[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    /// Format timestamp for display in local timezone
225    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/// Manifest storing list of snapshots for a target
234#[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/// Differences between a snapshot and current state
252#[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
266// ============================================================================
267// Generic Snapshot Manager
268// ============================================================================
269
270/// Generic snapshot manager that works with any SnapshotTarget
271pub 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    /// Save a snapshot of the target
303    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        // Copy files from content to snapshot
322        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        // Update manifest
354        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    /// List all snapshots for a target
365    pub fn list(&self, target: &T) -> Result<Vec<Snapshot>> {
366        let manifest = self.load_manifest(target)?;
367        Ok(manifest.snapshots)
368    }
369
370    /// Get a specific snapshot
371    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    /// Restore a snapshot
381    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        // Clear content directory
392        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        // Copy files from snapshot to content
410        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    /// Compare snapshot with current state
432    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    /// Delete a snapshot
479    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    /// Prune old snapshots, keeping only the most recent `keep` snapshots
495    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    /// Get the latest snapshot
524    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
533// ============================================================================
534// Type Aliases for Backward Compatibility
535// ============================================================================
536
537/// Snapshot manager for target directories (e.g., installed .claude folders)
538pub type SnapshotManager = GenericSnapshotManager<TargetDir>;
539
540/// Snapshot manager for profile source directories
541pub type ProfileSnapshotManager = GenericSnapshotManager<ProfileDir>;
542
543// ============================================================================
544// Convenience Methods for SnapshotManager
545// ============================================================================
546
547impl SnapshotManager {
548    /// Save a snapshot of a target directory
549    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    /// List snapshots for a target directory
561    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    /// Get a specific snapshot for a target directory
567    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    /// Restore a snapshot to a target directory
573    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    /// Compare snapshot with current target state
579    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    /// Delete a snapshot for a target directory
585    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    /// Prune old snapshots for a target directory
591    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
597// ============================================================================
598// Convenience Methods for ProfileSnapshotManager
599// ============================================================================
600
601impl ProfileSnapshotManager {
602    /// Save a snapshot of a profile
603    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    /// List snapshots for a profile
619    pub fn list_profile(&self, profile_name: &str) -> Result<Vec<Snapshot>> {
620        // Use empty path for listing - we only need the name for storage_dir
621        let profile_dir = ProfileDir::new(profile_name.to_string(), PathBuf::new());
622        self.list(&profile_dir)
623    }
624
625    /// Get a specific snapshot for a profile
626    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    /// Restore a snapshot to a profile directory
632    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    /// Compare snapshot with current profile state
643    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    /// Delete a snapshot for a profile
654    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    /// Prune old snapshots for a profile
660    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
666// ============================================================================
667// Helper Functions
668// ============================================================================
669
670fn 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}