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] = &[
37 ".DS_Store",
38 "history.jsonl",
39 "__store.db",
40 "config.json",
41 ".dot-agent-meta.toml",
42];
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "kebab-case")]
47pub enum SnapshotTrigger {
48 PreInstall,
50 PreUninstall,
52 PreUpdate,
54 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
75pub trait SnapshotTarget {
81 fn storage_dir(&self, base_dir: &Path) -> PathBuf;
83
84 fn content_path(&self) -> &Path;
86
87 fn identifier(&self) -> String;
89
90 fn should_exclude(&self, path: &Path, base: &Path) -> bool;
92
93 fn not_found_error(&self) -> DotAgentError;
95}
96
97#[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#[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
172fn 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#[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 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#[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#[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
267pub 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 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 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 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 pub fn list(&self, target: &T) -> Result<Vec<Snapshot>> {
367 let manifest = self.load_manifest(target)?;
368 Ok(manifest.snapshots)
369 }
370
371 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 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 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 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 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 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 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 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
534pub type SnapshotManager = GenericSnapshotManager<TargetDir>;
540
541pub type ProfileSnapshotManager = GenericSnapshotManager<ProfileDir>;
543
544impl SnapshotManager {
549 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 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 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 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 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 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 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
598impl ProfileSnapshotManager {
603 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 pub fn list_profile(&self, profile_name: &str) -> Result<Vec<Snapshot>> {
621 let profile_dir = ProfileDir::new(profile_name.to_string(), PathBuf::new());
623 self.list(&profile_dir)
624 }
625
626 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 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 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 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 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
667fn 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}