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] = &[".DS_Store", "history.jsonl", "__store.db", "config.json"];
37
38/// Trigger that caused the snapshot to be created
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "kebab-case")]
41pub enum SnapshotTrigger {
42    /// Automatically created before install operation
43    PreInstall,
44    /// Automatically created before uninstall operation
45    PreUninstall,
46    /// Automatically created before update operation
47    PreUpdate,
48    /// Manually created by user
49    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
69// ============================================================================
70// Snapshot Target Trait
71// ============================================================================
72
73/// Trait for types that can be snapshot targets
74pub trait SnapshotTarget {
75    /// Get the storage directory for snapshots of this target
76    fn storage_dir(&self, base_dir: &Path) -> PathBuf;
77
78    /// Get the content path to snapshot
79    fn content_path(&self) -> &Path;
80
81    /// Get identifier for display/messages
82    fn identifier(&self) -> String;
83
84    /// Check if a file should be excluded from snapshot
85    fn should_exclude(&self, path: &Path, base: &Path) -> bool;
86
87    /// Error to return when source doesn't exist
88    fn not_found_error(&self) -> DotAgentError;
89}
90
91/// Target directory snapshot (e.g., ~/.claude or project/.claude)
92#[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/// Profile source directory snapshot
130#[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
166// ============================================================================
167// Exclusion Filters
168// ============================================================================
169
170fn 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        // Exclude .dot-agent-history but include .dot-agent-meta.toml
177        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// ============================================================================
205// Snapshot Data Structures
206// ============================================================================
207
208/// Snapshot metadata
209#[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    /// Format timestamp for display in local timezone
221    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/// Manifest storing list of snapshots for a target
230#[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/// Differences between a snapshot and current state
248#[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
262// ============================================================================
263// Generic Snapshot Manager
264// ============================================================================
265
266/// Generic snapshot manager that works with any SnapshotTarget
267pub 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    /// Save a snapshot of the target
299    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        // Copy files from content to snapshot
318        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        // Update manifest
350        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    /// List all snapshots for a target
361    pub fn list(&self, target: &T) -> Result<Vec<Snapshot>> {
362        let manifest = self.load_manifest(target)?;
363        Ok(manifest.snapshots)
364    }
365
366    /// Get a specific snapshot
367    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    /// Restore a snapshot
377    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        // Clear content directory
388        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        // Copy files from snapshot to content
406        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    /// Compare snapshot with current state
428    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    /// Delete a snapshot
475    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    /// Prune old snapshots, keeping only the most recent `keep` snapshots
491    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    /// Get the latest snapshot
520    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
529// ============================================================================
530// Type Aliases for Backward Compatibility
531// ============================================================================
532
533/// Snapshot manager for target directories (e.g., installed .claude folders)
534pub type SnapshotManager = GenericSnapshotManager<TargetDir>;
535
536/// Snapshot manager for profile source directories
537pub type ProfileSnapshotManager = GenericSnapshotManager<ProfileDir>;
538
539// ============================================================================
540// Convenience Methods for SnapshotManager
541// ============================================================================
542
543impl SnapshotManager {
544    /// Save a snapshot of a target directory
545    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    /// List snapshots for a target directory
557    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    /// Get a specific snapshot for a target directory
563    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    /// Restore a snapshot to a target directory
569    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    /// Compare snapshot with current target state
575    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    /// Delete a snapshot for a target directory
581    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    /// Prune old snapshots for a target directory
587    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
593// ============================================================================
594// Convenience Methods for ProfileSnapshotManager
595// ============================================================================
596
597impl ProfileSnapshotManager {
598    /// Save a snapshot of a profile
599    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    /// List snapshots for a profile
615    pub fn list_profile(&self, profile_name: &str) -> Result<Vec<Snapshot>> {
616        // Use empty path for listing - we only need the name for storage_dir
617        let profile_dir = ProfileDir::new(profile_name.to_string(), PathBuf::new());
618        self.list(&profile_dir)
619    }
620
621    /// Get a specific snapshot for a profile
622    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    /// Restore a snapshot to a profile directory
628    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    /// Compare snapshot with current profile state
639    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    /// Delete a snapshot for a profile
650    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    /// Prune old snapshots for a profile
656    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
662// ============================================================================
663// Helper Functions
664// ============================================================================
665
666fn 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}