Skip to main content

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